diff --git a/pkg/attest/sign_test.go b/pkg/attest/sign_test.go index 3ff8865..2482b3e 100644 --- a/pkg/attest/sign_test.go +++ b/pkg/attest/sign_test.go @@ -22,12 +22,13 @@ import ( ) var ( - UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image") - NoProvenanceImage = filepath.Join("..", "..", "test", "testdata", "no-provenance-image") - PassPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-pass") - PassNoTLPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-no-tl") - FailPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-fail") - TestTempDir = "attest-sign-test" + UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image") + NoProvenanceImage = filepath.Join("..", "..", "test", "testdata", "no-provenance-image") + PassPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-pass") + PassMirrorPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-mirror") + PassNoTLPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-no-tl") + FailPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-fail") + TestTempDir = "attest-sign-test" ) func TestSignVerifyOCILayout(t *testing.T) { diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index dc72da7..faba273 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -3,8 +3,10 @@ package attest import ( "context" "fmt" + "strings" "time" + "github.com/distribution/reference" "github.com/docker/attest/pkg/attestation" "github.com/docker/attest/pkg/config" "github.com/docker/attest/pkg/oci" @@ -36,12 +38,12 @@ func Verify(ctx context.Context, src *oci.ImageSpec, opts *policy.PolicyOptions) } // this is overriding the mapping with a referrers config. Useful for testing if nothing else if opts.ReferrersRepo != "" { - pctx.Mapping.Attestations = &config.ReferrersConfig{ + pctx.Mapping.Attestations = &config.AttestationConfig{ Repo: opts.ReferrersRepo, Style: config.AttestationStyleReferrers, } } else if opts.AttestationStyle == config.AttestationStyleAttached { - pctx.Mapping.Attestations = &config.ReferrersConfig{ + pctx.Mapping.Attestations = &config.AttestationConfig{ Repo: opts.ReferrersRepo, Style: config.AttestationStyleAttached, } @@ -122,6 +124,19 @@ func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, p if err != nil { return nil, err } + + if pctx.ResolvedName != "" { + // this means the name we have is not the one we want to use for policy evaluation + // so we need to replace it with the one we resolved during policy resolution. + // this can happen if the name is an alias for another image, e.g. if it is a mirror + ref, err := reference.ParseNormalizedNamed(name) + if err != nil { + return nil, fmt.Errorf("failed to parse image name: %w", err) + } + oldName := ref.Name() + name = strings.Replace(name, oldName, pctx.ResolvedName, 1) + } + purl, canonical, err := oci.RefToPURL(name, platform) if err != nil { return nil, fmt.Errorf("failed to convert ref to purl: %w", err) diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go index d746774..57c4305 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -59,7 +59,7 @@ func TestVerifyAttestations(t *testing.T) { } ctx := policy.WithPolicyEvaluator(context.Background(), &mockPE) - _, err := VerifyAttestations(ctx, resolver, nil) + _, err := VerifyAttestations(ctx, resolver, &policy.Policy{ResolvedName: ""}) if tc.expectedError != nil { if assert.Error(t, err) { assert.Equal(t, tc.expectedError.Error(), err.Error()) @@ -189,22 +189,24 @@ func TestVerificationFailure(t *testing.T) { assert.Equal(t, "https://docker.com/official/policy/v0.1", attestationPredicate.Policy.URI) } -// test signing without a TL entry -func TestSignVerifyNoTL(t *testing.T) { +func TestSignVerify(t *testing.T) { ctx, signer := test.Setup(t) ctx = policy.WithPolicyEvaluator(ctx, policy.NewRegoEvaluator(true)) // setup an image with signed attestations outputLayout := test.CreateTempDir(t, "", TestTempDir) testCases := []struct { - name string - signTL bool - policyDir string - success bool + name string + signTL bool + policyDir string + imageName string + expectError bool }{ - {name: "happy path", signTL: true, policyDir: PassNoTLPolicyDir, success: true}, - {name: "sign tl, verify no tl", signTL: true, policyDir: PassPolicyDir, success: false}, - {name: "no tl", signTL: false, policyDir: PassPolicyDir, success: false}, + {name: "happy path", signTL: true, policyDir: PassNoTLPolicyDir}, + {name: "sign tl, verify no tl", signTL: true, policyDir: PassPolicyDir}, + {name: "no tl", signTL: false, policyDir: PassPolicyDir}, + {name: "mirror", signTL: true, policyDir: PassMirrorPolicyDir, imageName: "mirror.org/library/test-image:test"}, + {name: "mirror no match", signTL: true, policyDir: PassMirrorPolicyDir, imageName: "incorrect.org/library/test-image:test", expectError: true}, } attIdx, err := oci.IndexFromPath(UnsignedTestImage) @@ -222,13 +224,18 @@ func TestSignVerifyNoTL(t *testing.T) { signedIndex := attIdx.Index signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) require.NoError(t, err) + + imageName := tc.imageName + if imageName == "" { + imageName = attIdx.Name + } // output signed attestations idx := v1.ImageIndex(empty.Index) idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ Add: signedIndex, Descriptor: v1.Descriptor{ Annotations: map[string]string{ - oci.OciReferenceTarget: attIdx.Name, + oci.OciReferenceTarget: imageName, }, }, }) @@ -241,8 +248,17 @@ func TestSignVerifyNoTL(t *testing.T) { src, err := oci.ParseImageSpec("oci://"+outputLayout, oci.WithPlatform(LinuxAMD64)) require.NoError(t, err) results, err := Verify(ctx, src, policyOpts) + if tc.expectError { + require.Error(t, err) + return + } require.NoError(t, err) assert.Equal(t, OutcomeSuccess, results.Outcome) + platform, err := oci.ParsePlatform(LinuxAMD64) + require.NoError(t, err) + expectedPURL, _, err := oci.RefToPURL(attIdx.Name, platform) + require.NoError(t, err) + assert.Equal(t, expectedPURL, results.Input.Purl) }) } } diff --git a/pkg/attestation/referrers_test.go b/pkg/attestation/referrers_test.go index 3735b59..4fb44a1 100644 --- a/pkg/attestation/referrers_test.go +++ b/pkg/attestation/referrers_test.go @@ -37,6 +37,7 @@ func TestAttestationReferenceTypes(t *testing.T) { ctx = policy.WithPolicyEvaluator(ctx, policy.NewRegoEvaluator(true)) platforms := []string{"linux/amd64", "linux/arm64"} for _, tc := range []struct { + name string server *httptest.Server referrersServer *httptest.Server skipSubject bool @@ -44,46 +45,53 @@ func TestAttestationReferenceTypes(t *testing.T) { referrersRepo string attestationSource config.AttestationStyle expectFailure bool - policyDir string }{ { + name: "referrers support, defaults", server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), }, { + name: "no referrers support", server: httptest.NewServer(registry.New()), }, { + name: "attached attestations", server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), skipSubject: true, attestationSource: config.AttestationStyleAttached, }, { + name: "use digest", server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), useDigest: true, }, { + name: "attached attestations, referrers repo (mismatched args)", server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), expectFailure: true, //mismatched args attestationSource: config.AttestationStyleAttached, referrersRepo: "referrers", }, { + name: "referrers attestations, referrers repo (no policy)", server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), expectFailure: true, // no policy attestationSource: config.AttestationStyleReferrers, referrersRepo: "referrers", }, { + name: "referrers attestations", server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), attestationSource: config.AttestationStyleReferrers, }, { + name: "referrers attestations, no referrers support on server", server: httptest.NewServer(registry.New(registry.WithReferrersSupport(false))), attestationSource: config.AttestationStyleReferrers, referrersServer: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), }, } { - t.Run(fmt.Sprint(tc), func(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { s := tc.server defer s.Close() @@ -111,6 +119,7 @@ func TestAttestationReferenceTypes(t *testing.T) { signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) err = mirror.PushIndexToRegistry(attIdx.Index, indexName) + require.NoError(t, err) for _, img := range signedManifests { err = mirror.PushImageToRegistry(img.Attestation.Image, fmt.Sprintf("%s:tag-does-not-matter", repo)) require.NoError(t, err) @@ -142,9 +151,6 @@ func TestAttestationReferenceTypes(t *testing.T) { policyOpts := &policy.PolicyOptions{ LocalPolicyDir: LocalPolicy, } - if tc.policyDir != "" { - policyOpts.LocalPolicyDir = tc.policyDir - } if tc.referrersRepo != "" { policyOpts.ReferrersRepo = tc.referrersRepo @@ -192,65 +198,70 @@ func TestReferencesInDifferentRepo(t *testing.T) { ctx, signer := test.Setup(t) repoName := "repo" for _, tc := range []struct { + name string server *httptest.Server refServer *httptest.Server }{ { + name: "referrers support", server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), refServer: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), }, { + name: "no referrers support", server: httptest.NewServer(registry.New()), refServer: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), }, } { - server := tc.server - defer server.Close() - serverUrl, err := url.Parse(server.URL) - require.NoError(t, err) - - refServer := tc.refServer - defer refServer.Close() - refServerUrl, err := url.Parse(refServer.URL) - require.NoError(t, err) - - opts := &attestation.SigningOptions{ - Replace: true, - SkipTL: true, - } - attIdx, err := oci.IndexFromPath(UnsignedTestImage) - require.NoError(t, err) - - indexName := fmt.Sprintf("%s/%s:latest", serverUrl.Host, repoName) - err = mirror.PushIndexToRegistry(attIdx.Index, indexName) - require.NoError(t, err) - - signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) - require.NoError(t, err) - - // push signed attestation image to the ref server - for _, img := range signedManifests { - // push references using subject-digest.att convention - err = mirror.PushImageToRegistry(img.Attestation.Image, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerUrl.Host, repoName)) + t.Run(tc.name, func(t *testing.T) { + server := tc.server + defer server.Close() + serverUrl, err := url.Parse(server.URL) require.NoError(t, err) - } - mfs2, err := attIdx.Index.IndexManifest() - require.NoError(t, err) - for _, mf := range mfs2.Manifests { - //skip signed/unsigned attestations - if mf.Annotations[attestation.DockerReferenceType] == attestation.AttestationManifestType { - continue + + refServer := tc.refServer + defer refServer.Close() + refServerUrl, err := url.Parse(refServer.URL) + require.NoError(t, err) + + opts := &attestation.SigningOptions{ + Replace: true, + SkipTL: true, } - // can evaluate policy using referrers in a different repo - referencedImage := fmt.Sprintf("%s@%s", indexName, mf.Digest.String()) - policyOpts := &policy.PolicyOptions{ - LocalPolicyDir: PassPolicyDir, + attIdx, err := oci.IndexFromPath(UnsignedTestImage) + require.NoError(t, err) + + indexName := fmt.Sprintf("%s/%s:latest", serverUrl.Host, repoName) + err = mirror.PushIndexToRegistry(attIdx.Index, indexName) + require.NoError(t, err) + + signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + + // push signed attestation image to the ref server + for _, img := range signedManifests { + // push references using subject-digest.att convention + err = mirror.PushImageToRegistry(img.Attestation.Image, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerUrl.Host, repoName)) + require.NoError(t, err) } - src, err := oci.ParseImageSpec(referencedImage) + mfs2, err := attIdx.Index.IndexManifest() require.NoError(t, err) - results, err := attest.Verify(ctx, src, policyOpts) - require.NoError(t, err) - assert.Equal(t, attest.OutcomeSuccess, results.Outcome) - } + for _, mf := range mfs2.Manifests { + //skip signed/unsigned attestations + if mf.Annotations[attestation.DockerReferenceType] == attestation.AttestationManifestType { + continue + } + // can evaluate policy using referrers in a different repo + referencedImage := fmt.Sprintf("%s@%s", indexName, mf.Digest.String()) + policyOpts := &policy.PolicyOptions{ + LocalPolicyDir: PassPolicyDir, + } + src, err := oci.ParseImageSpec(referencedImage) + require.NoError(t, err) + results, err := attest.Verify(ctx, src, policyOpts) + require.NoError(t, err) + assert.Equal(t, attest.OutcomeSuccess, results.Outcome) + } + }) } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 4b30b4c..415e5b1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "github.com/docker/attest/pkg/tuf" goyaml "gopkg.in/yaml.v3" @@ -17,7 +18,7 @@ func LoadLocalMappings(configDir string) (*PolicyMappings, error) { if configDir == "" { return nil, nil } - mappings := &PolicyMappings{} + mappings := &policyMappingsFile{} path := filepath.Join(configDir, MappingFilename) mappingFile, err := os.ReadFile(path) if err != nil { @@ -27,7 +28,7 @@ func LoadLocalMappings(configDir string) (*PolicyMappings, error) { if err != nil { return nil, fmt.Errorf("failed to unmarshal policy mapping file %s: %w", path, err) } - return mappings, nil + return expandMappingFile(mappings) } func LoadTufMappings(tufClient tuf.TUFClient, localTargetsDir string) (*PolicyMappings, error) { @@ -39,11 +40,38 @@ func LoadTufMappings(tufClient tuf.TUFClient, localTargetsDir string) (*PolicyMa if err != nil { return nil, fmt.Errorf("failed to download policy mapping file %s: %w", filename, err) } - mappings := &PolicyMappings{} + mappings := &policyMappingsFile{} err = goyaml.Unmarshal(fileContents, mappings) if err != nil { return nil, fmt.Errorf("failed to unmarshal policy mapping file %s: %w", filename, err) } - return mappings, nil + return expandMappingFile(mappings) +} + +func expandMappingFile(mappingFile *policyMappingsFile) (*PolicyMappings, error) { + policies := make(map[string]*PolicyMapping) + for _, policy := range mappingFile.Policies { + policies[policy.Id] = policy + } + + var rules []*PolicyRule + for _, rule := range mappingFile.Rules { + r, err := regexp.Compile(rule.Pattern) + if err != nil { + return nil, err + } + rules = append(rules, &PolicyRule{ + Pattern: r, + PolicyId: rule.PolicyId, + Replacement: rule.Replacement, + }) + } + + return &PolicyMappings{ + Version: mappingFile.Version, + Kind: mappingFile.Kind, + Policies: policies, + Rules: rules, + }, nil } diff --git a/pkg/config/types.go b/pkg/config/types.go index 87e9dc5..bbb8f55 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -1,10 +1,25 @@ package config +import "regexp" + +type policyMappingsFile struct { + Version string `yaml:"version"` + Kind string `yaml:"kind"` + Policies []*PolicyMapping `yaml:"policies"` + Rules []*policyRuleFile `yaml:"rules"` +} + +type policyRuleFile struct { + Pattern string `yaml:"pattern"` + PolicyId string `yaml:"policy-id"` + Replacement string `yaml:"rewrite"` +} + type PolicyMappings struct { - Version string `json:"version"` - Kind string `json:"kind"` - Policies []*PolicyMapping `json:"policies"` - Mirrors []*PolicyMirror `json:"mirrors"` + Version string + Kind string + Policies map[string]*PolicyMapping + Rules []*PolicyRule } type AttestationStyle string @@ -15,34 +30,23 @@ const ( ) type PolicyMapping struct { - Id string `json:"id"` - Description string `json:"description"` - Origin *PolicyOrigin `json:"origin"` - Files []PolicyMappingFile `json:"files"` - Attestations *ReferrersConfig `json:"attestations"` + Id string `yaml:"id"` + Description string `yaml:"description"` + Files []PolicyMappingFile `yaml:"files"` + Attestations *AttestationConfig `yaml:"attestations"` } -type ReferrersConfig struct { - Style AttestationStyle `json:"style"` - Repo string `json:"repo"` +type AttestationConfig struct { + Style AttestationStyle `yaml:"style"` + Repo string `yaml:"repo"` } type PolicyMappingFile struct { - Path string `json:"path"` + Path string `yaml:"path"` } -type PolicyMirror struct { - PolicyId string `yaml:"policy-id"` - Mirror MirrorSpec `json:"mirror"` -} - -type MirrorSpec struct { - Domains []string `json:"domains"` - Prefix string `json:"prefix"` -} - -type PolicyOrigin struct { - Name string `json:"name"` - Prefix string `json:"prefix"` - Domain string `json:"domain"` +type PolicyRule struct { + Pattern *regexp.Regexp + PolicyId string + Replacement string } diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index 184cfcf..dde953c 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -6,15 +6,13 @@ import ( "os" "path" "path/filepath" - "slices" - "strings" "github.com/distribution/reference" "github.com/docker/attest/pkg/config" "github.com/docker/attest/pkg/oci" ) -func resolveLocalPolicy(opts *PolicyOptions, mapping *config.PolicyMapping) (*Policy, error) { +func resolveLocalPolicy(opts *PolicyOptions, mapping *config.PolicyMapping, imageName string, matchedName string) (*Policy, error) { if opts.LocalPolicyDir == "" { return nil, fmt.Errorf("local policy dir not set") } @@ -35,10 +33,13 @@ func resolveLocalPolicy(opts *PolicyOptions, mapping *config.PolicyMapping) (*Po InputFiles: files, Mapping: mapping, } + if imageName != matchedName { + policy.ResolvedName = matchedName + } return policy, nil } -func resolveTufPolicy(opts *PolicyOptions, mapping *config.PolicyMapping) (*Policy, error) { +func resolveTufPolicy(opts *PolicyOptions, mapping *config.PolicyMapping, imageName string, matchedName string) (*Policy, error) { files := make([]*PolicyFile, 0, len(mapping.Files)) for _, f := range mapping.Files { filename := f.Path @@ -55,32 +56,68 @@ func resolveTufPolicy(opts *PolicyOptions, mapping *config.PolicyMapping) (*Poli InputFiles: files, Mapping: mapping, } + if imageName != matchedName { + policy.ResolvedName = matchedName + } return policy, nil } -func findPolicyMatch(named reference.Named, mappings *config.PolicyMappings) (*config.PolicyMapping, *config.PolicyMirror) { - if mappings != nil { - for _, mapping := range mappings.Policies { - if mapping.Origin.Domain == reference.Domain(named) && - strings.HasPrefix(reference.Path(named), mapping.Origin.Prefix) { - return mapping, nil - } - } - // now search mirrors - for _, mirror := range mappings.Mirrors { - if (slices.Contains(mirror.Mirror.Domains, reference.Domain(named)) || - slices.Contains(mirror.Mirror.Domains, "*")) && - strings.HasPrefix(reference.Path(named), mirror.Mirror.Prefix) { - for _, mapping := range mappings.Policies { - if mapping.Id == mirror.PolicyId { - return mapping, nil - } +type matchType string + +const ( + matchTypePolicy matchType = "policy" + matchTypeMatchNoPolicy matchType = "match_no_policy" + matchTypeNoMatch matchType = "no_match" +) + +type policyMatch struct { + matchType matchType + policy *config.PolicyMapping + rule *config.PolicyRule + matchedName string +} + +func findPolicyMatch(imageName string, mappings *config.PolicyMappings) (*policyMatch, error) { + if mappings == nil { + return &policyMatch{matchType: matchTypeNoMatch, matchedName: imageName}, nil + } + return findPolicyMatchImpl(imageName, mappings, make(map[*config.PolicyRule]bool)) +} + +func findPolicyMatchImpl(imageName string, mappings *config.PolicyMappings, matched map[*config.PolicyRule]bool) (*policyMatch, error) { + for _, rule := range mappings.Rules { + if rule.Pattern.MatchString(imageName) { + switch { + case rule.PolicyId == "" && rule.Replacement == "": + return nil, fmt.Errorf("rule %s has neither policy-id nor rewrite", rule.Pattern) + case rule.PolicyId != "" && rule.Replacement != "": + return nil, fmt.Errorf("rule %s has both policy-id and rewrite", rule.Pattern) + case rule.PolicyId != "": + policy := mappings.Policies[rule.PolicyId] + if policy != nil { + return &policyMatch{ + matchType: matchTypePolicy, + policy: policy, + rule: rule, + matchedName: imageName, + }, nil } - return nil, mirror + return &policyMatch{ + matchType: matchTypeMatchNoPolicy, + rule: rule, + matchedName: imageName, + }, nil + case rule.Replacement != "": + if matched[rule] { + return nil, fmt.Errorf("rewrite loop detected") + } + matched[rule] = true + imageName = rule.Pattern.ReplaceAllString(imageName, rule.Replacement) + return findPolicyMatchImpl(imageName, mappings, matched) } } } - return nil, nil + return &policyMatch{matchType: matchTypeNoMatch, matchedName: imageName}, nil } func resolvePolicyById(opts *PolicyOptions) (*Policy, error) { @@ -90,10 +127,9 @@ func resolvePolicyById(opts *PolicyOptions) (*Policy, error) { return nil, fmt.Errorf("failed to load local policy mappings: %w", err) } if localMappings != nil { - for _, mapping := range localMappings.Policies { - if mapping.Id == opts.PolicyId { - return resolveLocalPolicy(opts, mapping) - } + policy := localMappings.Policies[opts.PolicyId] + if policy != nil { + return resolveLocalPolicy(opts, policy, "", "") } } @@ -102,10 +138,9 @@ func resolvePolicyById(opts *PolicyOptions) (*Policy, error) { if err != nil { return nil, fmt.Errorf("failed to load tuf policy mappings by id: %w", err) } - for _, mapping := range tufMappings.Policies { - if mapping.Id == opts.PolicyId { - return resolveTufPolicy(opts, mapping) - } + policy := tufMappings.Policies[opts.PolicyId] + if policy != nil { + return resolveTufPolicy(opts, policy, "", "") } return nil, fmt.Errorf("policy with id %s not found", opts.PolicyId) } @@ -124,7 +159,7 @@ func ResolvePolicy(ctx context.Context, detailsResolver oci.ImageDetailsResolver if err != nil { return nil, fmt.Errorf("failed to get image name: %w", err) } - named, err := reference.ParseNormalizedNamed(imageName) + imageName, err = normalizeImageName(imageName) if err != nil { return nil, fmt.Errorf("failed to parse image name: %w", err) } @@ -132,9 +167,12 @@ func ResolvePolicy(ctx context.Context, detailsResolver oci.ImageDetailsResolver if err != nil { return nil, fmt.Errorf("failed to load local policy mappings: %w", err) } - mapping, mirror := findPolicyMatch(named, localMappings) - if mapping != nil { - return resolveLocalPolicy(opts, mapping) + match, err := findPolicyMatch(imageName, localMappings) + if err != nil { + return nil, err + } + if match.matchType == matchTypePolicy { + return resolveLocalPolicy(opts, match.policy, imageName, match.matchedName) } // must check tuf tufMappings, err := config.LoadTufMappings(opts.TufClient, opts.LocalTargetsDir) @@ -143,20 +181,31 @@ func ResolvePolicy(ctx context.Context, detailsResolver oci.ImageDetailsResolver } // it's a mirror of a tuf policy - if mirror != nil { + if match.matchType == matchTypeMatchNoPolicy { for _, mapping := range tufMappings.Policies { - if mapping.Id == mirror.PolicyId { - return resolveTufPolicy(opts, mapping) + if mapping.Id == match.rule.PolicyId { + return resolveTufPolicy(opts, mapping, imageName, match.matchedName) } } } // try to resolve a tuf policy directly - mapping, _ = findPolicyMatch(named, tufMappings) - if mapping == nil { - return nil, nil + match, err = findPolicyMatch(imageName, tufMappings) + if err != nil { + return nil, err } - return resolveTufPolicy(opts, mapping) + if match.matchType == matchTypePolicy { + return resolveTufPolicy(opts, match.policy, imageName, match.matchedName) + } + return nil, nil +} + +func normalizeImageName(imageName string) (string, error) { + named, err := reference.ParseNormalizedNamed(imageName) + if err != nil { + return "", fmt.Errorf("failed to parse image name: %w", err) + } + return named.Name(), nil } func CreateImageDetailsResolver(imageSource *oci.ImageSpec) (oci.ImageDetailsResolver, error) { diff --git a/pkg/policy/policy_match_test.go b/pkg/policy/policy_match_test.go new file mode 100644 index 0000000..62d71bc --- /dev/null +++ b/pkg/policy/policy_match_test.go @@ -0,0 +1,121 @@ +package policy + +import ( + "path/filepath" + "testing" + + "github.com/docker/attest/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindPolicyMatch(t *testing.T) { + testCases := []struct { + name string + imageName string + mappingDir string + + expectError bool + expectedMatchType matchType + expectedPolicyID string + expectedImageName string + }{ + { + name: "alpine", + mappingDir: "doi", + imageName: "docker.io/library/alpine", + + expectedMatchType: matchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/library/alpine", + }, + { + name: "no match", + mappingDir: "doi", + imageName: "docker.io/something/else", + + expectedMatchType: matchTypeNoMatch, + expectedImageName: "docker.io/something/else", + }, + { + name: "match, no policy", + mappingDir: "local", + imageName: "docker.io/library/alpine", + + expectedMatchType: matchTypeMatchNoPolicy, + expectedImageName: "docker.io/library/alpine", + }, + { + name: "simple rewrite", + mappingDir: "simple-rewrite", + imageName: "mycoolmirror.org/library/alpine", + + expectedMatchType: matchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/library/alpine", + }, + { + name: "rewrite no match", + mappingDir: "rewrite-to-no-match", + imageName: "mycoolmirror.org/library/alpine", + + expectedMatchType: matchTypeNoMatch, + expectedImageName: "badredirect.org/alpine", + }, + { + name: "rewrite to match, no policy", + mappingDir: "rewrite-to-local", + imageName: "mycoolmirror.org/library/alpine", + + expectedMatchType: matchTypeMatchNoPolicy, + expectedImageName: "docker.io/library/alpine", + }, + { + name: "multiple rewrites", + mappingDir: "rewrite-multiple", + imageName: "myevencoolermirror.org/library/alpine", + + expectedMatchType: matchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/library/alpine", + }, + { + name: "invalid rewrites", + mappingDir: "rewrite-invalid", + imageName: "mycoolmirror.org/library/alpine", + + expectError: true, + expectedMatchType: matchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/library/alpine", + }, + { + name: "rewrite loop", + mappingDir: "rewrite-loop", + imageName: "yin/alpine", + + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mappings, err := config.LoadLocalMappings(filepath.Join("testdata", "mappings", tc.mappingDir)) + require.NoError(t, err) + match, err := findPolicyMatch(tc.imageName, mappings) + if tc.expectError { + require.Error(t, err) + // TODO: check error matches expected error message + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedMatchType, match.matchType) + if match.matchType == matchTypePolicy { + if assert.NotNil(t, match.policy) { + assert.Equal(t, tc.expectedPolicyID, match.policy.Id) + } + } + assert.Equal(t, tc.expectedImageName, match.matchedName) + }) + } +} diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index 65166b0..c45e445 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -84,6 +84,7 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { src, err := oci.ParseImageSpec(imageName, oci.WithPlatform(platform.String())) require.NoError(t, err) resolver, err := policy.CreateImageDetailsResolver(src) + require.NoError(t, err) policy, err := policy.ResolvePolicy(ctx, resolver, tc.policy) if tc.errorStr != "" { require.Error(t, err) @@ -91,6 +92,7 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { return } require.NoErrorf(t, err, "failed to resolve policy") + require.NotNil(t, policy, "policy should not be nil") result, err := re.Evaluate(ctx, tc.resolver, policy, input) require.NoErrorf(t, err, "Evaluate failed") @@ -107,8 +109,10 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { func TestLoadingMappings(t *testing.T) { policyMappings, err := config.LoadLocalMappings(filepath.Join("testdata", "mock-tuf-allow")) require.NoError(t, err) - assert.Equal(t, len(policyMappings.Mirrors), 1) - for _, mirror := range policyMappings.Mirrors { - assert.Equal(t, "docker-official-images", mirror.PolicyId) + assert.Equal(t, len(policyMappings.Rules), 3) + for _, mirror := range policyMappings.Rules { + if mirror.PolicyId != "" { + assert.Equal(t, "docker-official-images", mirror.PolicyId) + } } } diff --git a/pkg/policy/testdata/mappings/doi/mapping.yaml b/pkg/policy/testdata/mappings/doi/mapping.yaml new file mode 100644 index 0000000..317e6ef --- /dev/null +++ b/pkg/policy/testdata/mappings/doi/mapping.yaml @@ -0,0 +1,10 @@ +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images diff --git a/pkg/policy/testdata/mappings/local/mapping.yaml b/pkg/policy/testdata/mappings/local/mapping.yaml new file mode 100644 index 0000000..93ed8eb --- /dev/null +++ b/pkg/policy/testdata/mappings/local/mapping.yaml @@ -0,0 +1,10 @@ +version: v1 +kind: policy-mapping +policies: + - id: local-policy + description: Local Policy + files: + - path: local-policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images # note this policy does not exist in this file diff --git a/pkg/policy/testdata/mappings/rewrite-invalid/mapping.yaml b/pkg/policy/testdata/mappings/rewrite-invalid/mapping.yaml new file mode 100644 index 0000000..2cd4c87 --- /dev/null +++ b/pkg/policy/testdata/mappings/rewrite-invalid/mapping.yaml @@ -0,0 +1,13 @@ +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images + - pattern: "^mycoolmirror[.]org/library/(.*)$" + rewrite: "docker.io/library/$1" + policy-id: docker-official-images # invalid to specify both rewrite and policy-id diff --git a/pkg/policy/testdata/mappings/rewrite-loop/mapping.yaml b/pkg/policy/testdata/mappings/rewrite-loop/mapping.yaml new file mode 100644 index 0000000..94f5f0a --- /dev/null +++ b/pkg/policy/testdata/mappings/rewrite-loop/mapping.yaml @@ -0,0 +1,14 @@ +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images + - pattern: "^yin/(.*)$" + rewrite: "yang/$1" + - pattern: "^yang/(.*)$" + rewrite: "yin/$1" diff --git a/pkg/policy/testdata/mappings/rewrite-multiple/mapping.yaml b/pkg/policy/testdata/mappings/rewrite-multiple/mapping.yaml new file mode 100644 index 0000000..62e9199 --- /dev/null +++ b/pkg/policy/testdata/mappings/rewrite-multiple/mapping.yaml @@ -0,0 +1,14 @@ +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images + - pattern: "^mycoolmirror[.]org/library/(.*)$" + rewrite: "docker.io/library/$1" + - pattern: "^myevencoolermirror[.]org/library/(.*)$" + rewrite: "mycoolmirror.org/library/$1" diff --git a/pkg/policy/testdata/mappings/rewrite-to-local/mapping.yaml b/pkg/policy/testdata/mappings/rewrite-to-local/mapping.yaml new file mode 100644 index 0000000..b703c08 --- /dev/null +++ b/pkg/policy/testdata/mappings/rewrite-to-local/mapping.yaml @@ -0,0 +1,12 @@ +version: v1 +kind: policy-mapping +policies: + - id: local-policy + description: Local Policy + files: + - path: local-policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images # note this policy does not exist in this file + - pattern: "^mycoolmirror[.]org/library/(.*)$" + rewrite: "docker.io/library/$1" diff --git a/pkg/policy/testdata/mappings/rewrite-to-no-match/mapping.yaml b/pkg/policy/testdata/mappings/rewrite-to-no-match/mapping.yaml new file mode 100644 index 0000000..523d7de --- /dev/null +++ b/pkg/policy/testdata/mappings/rewrite-to-no-match/mapping.yaml @@ -0,0 +1,12 @@ +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images + - pattern: "^mycoolmirror[.]org/library/(.*)$" + rewrite: "badredirect.org/$1" # no matching rule for this rewrite diff --git a/pkg/policy/testdata/mappings/simple-rewrite/mapping.yaml b/pkg/policy/testdata/mappings/simple-rewrite/mapping.yaml new file mode 100644 index 0000000..e2f9ce5 --- /dev/null +++ b/pkg/policy/testdata/mappings/simple-rewrite/mapping.yaml @@ -0,0 +1,12 @@ +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images + - pattern: "^mycoolmirror[.]org/library/(.*)$" + rewrite: "docker.io/library/$1" diff --git a/pkg/policy/testdata/mock-tuf-allow-canonical/mapping.yaml b/pkg/policy/testdata/mock-tuf-allow-canonical/mapping.yaml index c324a86..c25a3f7 100644 --- a/pkg/policy/testdata/mock-tuf-allow-canonical/mapping.yaml +++ b/pkg/policy/testdata/mock-tuf-allow-canonical/mapping.yaml @@ -2,15 +2,16 @@ version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: docker-official-images + - id: docker-official-images description: Docker Official Images + attestations: + repo: "localhost:5001/library-refs" files: - path: doi/policy.rego -mirrors: - - policy-id: docker-official-images - mirror: - domains: [localhost:5001, registry.local:5000] - prefix: "" +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images + - pattern: ^localhost:5001/(.*)$ + rewrite: docker.io/library/$1 + - pattern: ^registry[.]local:5000/(.*)$ + rewrite: docker.io/library/$1 diff --git a/pkg/policy/testdata/mock-tuf-allow/mapping.yaml b/pkg/policy/testdata/mock-tuf-allow/mapping.yaml index c6064b5..c25a3f7 100644 --- a/pkg/policy/testdata/mock-tuf-allow/mapping.yaml +++ b/pkg/policy/testdata/mock-tuf-allow/mapping.yaml @@ -2,17 +2,16 @@ version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: docker-official-images + - id: docker-official-images description: Docker Official Images attestations: repo: "localhost:5001/library-refs" files: - path: doi/policy.rego -mirrors: - - policy-id: docker-official-images - mirror: - domains: [localhost:5001, registry.local:5000] - prefix: "" +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images + - pattern: ^localhost:5001/(.*)$ + rewrite: docker.io/library/$1 + - pattern: ^registry[.]local:5000/(.*)$ + rewrite: docker.io/library/$1 diff --git a/pkg/policy/testdata/mock-tuf-deny/mapping.yaml b/pkg/policy/testdata/mock-tuf-deny/mapping.yaml index 1aa09d7..28252b9 100644 --- a/pkg/policy/testdata/mock-tuf-deny/mapping.yaml +++ b/pkg/policy/testdata/mock-tuf-deny/mapping.yaml @@ -2,10 +2,10 @@ version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: docker-official-images + - id: docker-official-images description: Docker Official Images files: - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images diff --git a/pkg/policy/testdata/mock-tuf-verify-sig/mapping.yaml b/pkg/policy/testdata/mock-tuf-verify-sig/mapping.yaml index 1aa09d7..28252b9 100644 --- a/pkg/policy/testdata/mock-tuf-verify-sig/mapping.yaml +++ b/pkg/policy/testdata/mock-tuf-verify-sig/mapping.yaml @@ -2,10 +2,10 @@ version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: docker-official-images + - id: docker-official-images description: Docker Official Images files: - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images diff --git a/pkg/policy/testdata/mock-tuf-wrong-key/mapping.yaml b/pkg/policy/testdata/mock-tuf-wrong-key/mapping.yaml index 1aa09d7..28252b9 100644 --- a/pkg/policy/testdata/mock-tuf-wrong-key/mapping.yaml +++ b/pkg/policy/testdata/mock-tuf-wrong-key/mapping.yaml @@ -2,10 +2,10 @@ version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: docker-official-images + - id: docker-official-images description: Docker Official Images files: - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images diff --git a/pkg/policy/types.go b/pkg/policy/types.go index d44665a..6f534a4 100644 --- a/pkg/policy/types.go +++ b/pkg/policy/types.go @@ -36,9 +36,10 @@ type PolicyOptions struct { } type Policy struct { - InputFiles []*PolicyFile - Query string - Mapping *config.PolicyMapping + InputFiles []*PolicyFile + Query string + Mapping *config.PolicyMapping + ResolvedName string } type PolicyInput struct { diff --git a/test/testdata/local-policy-fail/mapping.yaml b/test/testdata/local-policy-fail/mapping.yaml index f729528..73cd138 100644 --- a/test/testdata/local-policy-fail/mapping.yaml +++ b/test/testdata/local-policy-fail/mapping.yaml @@ -1,11 +1,10 @@ -# map repos to policies version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: test-images + - id: test-images description: Local test images files: - - path: doi/policy.rego + - path: policy.rego +rules: + - pattern: ".*" + policy-id: test-images diff --git a/test/testdata/local-policy-fail/doi/policy.rego b/test/testdata/local-policy-fail/policy.rego similarity index 100% rename from test/testdata/local-policy-fail/doi/policy.rego rename to test/testdata/local-policy-fail/policy.rego diff --git a/test/testdata/local-policy-mirror/mapping.yaml b/test/testdata/local-policy-mirror/mapping.yaml new file mode 100644 index 0000000..2197d07 --- /dev/null +++ b/test/testdata/local-policy-mirror/mapping.yaml @@ -0,0 +1,12 @@ +version: v1 +kind: policy-mapping +policies: + - id: test-images + description: Local test images + files: + - path: policy.rego +rules: + - pattern: "^docker[.]io/library/test-image$" + policy-id: test-images + - pattern: "^mirror[.]org/library/(.*)$" + rewrite: docker.io/library/$1 diff --git a/test/testdata/local-policy-mirror/policy.rego b/test/testdata/local-policy-mirror/policy.rego new file mode 100644 index 0000000..2922834 --- /dev/null +++ b/test/testdata/local-policy-mirror/policy.rego @@ -0,0 +1,54 @@ +package attest + +import rego.v1 + +keys := [{ + "id": "6b241993defaba26558c64f94a94303ce860e7ad9163d801495c91cf57197c75", + "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZmicqYSY38DprGr42jU0V3ND0ROj\nzSRH1+yjsxhh0bi52Hh/DuOhrSq2KJ5a09lW3ybnDjljowbkof0Y1i9Oow==\n-----END PUBLIC KEY-----", + "from": "2023-12-15T14:00:00Z", + "to": null, + # this key is still active + "status": "active", + "signing-format": "dssev1", +}] + +provs(pred) := p if { + res := attest.fetch(pred) + not res.error + p := res.value +} + +atts := union({ + provs("https://slsa.dev/provenance/v0.2"), + provs("https://spdx.dev/Document"), +}) + +opts := {"keys": keys} + +statements contains s if { + some att in atts + res := attest.verify(att, opts) + not res.error + s := res.value +} + +subjects contains subject if { + some statement in statements + some subject in statement.subject +} + +success if { + print("input:",input) + true +} + +result := { + "success": success, + "violations": set(), + "summary": { + "subjects": subjects, + "slsa_levels": ["SLSA_BUILD_LEVEL_3"], + "verifier": "docker-official-images", + "policy_uri": "https://docker.com/official/policy/v0.1", + }, +} diff --git a/test/testdata/local-policy-no-tl/mapping.yaml b/test/testdata/local-policy-no-tl/mapping.yaml index f729528..73cd138 100644 --- a/test/testdata/local-policy-no-tl/mapping.yaml +++ b/test/testdata/local-policy-no-tl/mapping.yaml @@ -1,11 +1,10 @@ -# map repos to policies version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: test-images + - id: test-images description: Local test images files: - - path: doi/policy.rego + - path: policy.rego +rules: + - pattern: ".*" + policy-id: test-images diff --git a/test/testdata/local-policy-no-tl/doi/policy.rego b/test/testdata/local-policy-no-tl/policy.rego similarity index 100% rename from test/testdata/local-policy-no-tl/doi/policy.rego rename to test/testdata/local-policy-no-tl/policy.rego diff --git a/test/testdata/local-policy-pass/mapping.yaml b/test/testdata/local-policy-pass/mapping.yaml index 1aeb316..73cd138 100644 --- a/test/testdata/local-policy-pass/mapping.yaml +++ b/test/testdata/local-policy-pass/mapping.yaml @@ -1,16 +1,10 @@ -# map repos to policies version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: test-images + - id: test-images description: Local test images files: - - path: doi/policy.rego -mirrors: - - policy-id: test-images - mirror: - domains: ["*"] - prefix: "" + - path: policy.rego +rules: + - pattern: ".*" + policy-id: test-images diff --git a/test/testdata/local-policy-pass/doi/policy.rego b/test/testdata/local-policy-pass/policy.rego similarity index 100% rename from test/testdata/local-policy-pass/doi/policy.rego rename to test/testdata/local-policy-pass/policy.rego diff --git a/test/testdata/local-policy/mapping.yaml b/test/testdata/local-policy/mapping.yaml index 557c8d5..1c3bdbe 100644 --- a/test/testdata/local-policy/mapping.yaml +++ b/test/testdata/local-policy/mapping.yaml @@ -1,29 +1,18 @@ -# map repos to policies version: v1 kind: policy-mapping policies: - - origin: - domain: docker.io - prefix: library/ - id: test-images - description: Local test images + - id: docker-official-images + description: Docker Official Images files: - - path: "doi/policy.rego" - -mirrors: - - policy-id: test-images - mirror: - domains: ["*"] - prefix: "repo" - - policy-id: test-images - mirror: - domains: ["*"] - prefix: "library/" - - policy-id: test-images - mirror: - domains: ["*"] - prefix: "test-image" - - policy-id: test-images - mirror: - domains: ["*"] - prefix: "image-signer-verifier-test" + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + policy-id: docker-official-images + - pattern: "repo$" + policy-id: docker-official-images + - pattern: "test-image$" + policy-id: docker-official-images + - pattern: "image-signer-verifier-test$" + policy-id: docker-official-images + - pattern: "library/(.*)$" + rewrite: docker.io/library/$1