diff --git a/README.md b/README.md index 2255842..6488a9a 100644 --- a/README.md +++ b/README.md @@ -203,8 +203,14 @@ rules: - pattern: "^docker[.]io/library/(.*)$" policy-id: docker-official-images - pattern: "^public[.]ecr[.]aws/docker/library/(.*)$" + platforms: ["linux/amd64"] # optional: restrict image platforms for matching policies (default: all) rewrite: docker.io/library/$1 ``` +`platforms` in the second rule above is optional and can be used to restrict the platforms for which the policy + is evaluated. If the `platforms` field is not present, the policy will be applied to all platforms. + It's important to note that the `platforms` field is a filter, and is applied before the `pattern` + field is processed, so both `platforms` and `pattern` need to match in order for the policy to be selected + (or the rewrite to be processed if present). As before, any repository in the `docker.io/library` namespace will be evaluated against the policy in `doi/policy.rego`. The second rule will rewrite any repository in the `public.ecr.aws/docker/library` namespace to `docker.io/library`. diff --git a/attestation/referrers_test.go b/attestation/referrers_test.go index 4432b55..e6c8d39 100644 --- a/attestation/referrers_test.go +++ b/attestation/referrers_test.go @@ -9,8 +9,8 @@ import ( "github.com/docker/attest" "github.com/docker/attest/attestation" - "github.com/docker/attest/config" "github.com/docker/attest/internal/test" + "github.com/docker/attest/mapping" "github.com/docker/attest/oci" "github.com/docker/attest/policy" "github.com/google/go-containerregistry/pkg/name" @@ -39,7 +39,7 @@ func TestAttestationReferenceTypes(t *testing.T) { referrersServer *httptest.Server useDigest bool referrersRepo string - attestationSource config.AttestationStyle + attestationSource mapping.AttestationStyle expectFailure bool }{ { @@ -55,26 +55,26 @@ func TestAttestationReferenceTypes(t *testing.T) { name: "attached attestations, referrers repo (mismatched args)", server: test.NewLocalRegistry(ctx, registry.WithReferrersSupport(true)), expectFailure: true, // mismatched args - attestationSource: config.AttestationStyleAttached, + attestationSource: mapping.AttestationStyleAttached, referrersRepo: "referrers", }, { name: "referrers attestations, referrers repo (no policy)", server: test.NewLocalRegistry(ctx, registry.WithReferrersSupport(true)), expectFailure: true, // no policy - attestationSource: config.AttestationStyleReferrers, + attestationSource: mapping.AttestationStyleReferrers, referrersRepo: "referrers", }, { name: "referrers attestations", server: test.NewLocalRegistry(ctx, registry.WithReferrersSupport(true)), - attestationSource: config.AttestationStyleReferrers, + attestationSource: mapping.AttestationStyleReferrers, }, { name: "referrers attestations, no referrers support on server", server: test.NewLocalRegistry(ctx, registry.WithReferrersSupport(false)), - attestationSource: config.AttestationStyleReferrers, + attestationSource: mapping.AttestationStyleReferrers, referrersServer: test.NewLocalRegistry(ctx, registry.WithReferrersSupport(true)), }, } { diff --git a/config/config.go b/mapping/mapping.go similarity index 82% rename from config/config.go rename to mapping/mapping.go index 6a626b3..342454a 100644 --- a/config/config.go +++ b/mapping/mapping.go @@ -1,4 +1,4 @@ -package config +package mapping import ( "errors" @@ -8,6 +8,7 @@ import ( "regexp" "github.com/docker/attest/tuf" + v1 "github.com/google/go-containerregistry/pkg/v1" "sigs.k8s.io/yaml" ) @@ -33,6 +34,13 @@ func validateMappingsFile(mappings *policyMappingsFile) error { if rule.PolicyID != "" && rule.Replacement != "" { validationErrors = append(validationErrors, fmt.Errorf("rule cannot have both policy-id and replacement: %s", rule)) } + if rule.Platforms != nil { + for _, platform := range rule.Platforms { + if platform == "" { + validationErrors = append(validationErrors, fmt.Errorf("rule has empty platform: %s", rule)) + } + } + } } for _, policy := range mappings.Policies { if policy.ID == "" { @@ -100,14 +108,24 @@ func expandMappingFile(mappingFile *policyMappingsFile) (*PolicyMappings, error) var rules []*PolicyRule for _, rule := range mappingFile.Rules { - r, err := regexp.Compile(rule.Pattern) + patternRegex, err := regexp.Compile(rule.Pattern) if err != nil { return nil, err } + platforms := make([]*v1.Platform, 0, len(rule.Platforms)) + for _, platform := range rule.Platforms { + parsedPlatform, err := v1.ParsePlatform(platform) + if err != nil { + return nil, fmt.Errorf("failed to parse platform %s: %w", platform, err) + } + platforms = append(platforms, parsedPlatform) + } + rules = append(rules, &PolicyRule{ - Pattern: r, + Pattern: patternRegex, PolicyID: rule.PolicyID, Replacement: rule.Replacement, + Platforms: platforms, }) } diff --git a/config/config_test.go b/mapping/mapping_test.go similarity index 99% rename from config/config_test.go rename to mapping/mapping_test.go index d22ae11..06d4a94 100644 --- a/config/config_test.go +++ b/mapping/mapping_test.go @@ -1,4 +1,4 @@ -package config +package mapping import ( "testing" diff --git a/mapping/match.go b/mapping/match.go new file mode 100644 index 0000000..cfac8b6 --- /dev/null +++ b/mapping/match.go @@ -0,0 +1,80 @@ +package mapping + +import ( + "fmt" + + v1 "github.com/google/go-containerregistry/pkg/v1" +) + +type matchType string + +const ( + MatchTypePolicy matchType = "policy" + MatchTypeMatchNoPolicy matchType = "match_no_policy" + MatchTypeNoMatch matchType = "no_match" +) + +type PolicyMatch struct { + MatchType matchType + Policy *PolicyMapping + Rule *PolicyRule + MatchedName string +} + +func (mappings *PolicyMappings) FindPolicyMatch(imageName string, platform *v1.Platform) (*PolicyMatch, error) { + if mappings == nil { + return &PolicyMatch{MatchType: MatchTypeNoMatch, MatchedName: imageName}, nil + } + return mappings.findPolicyMatchImpl(imageName, platform, make(map[*PolicyRule]bool)) +} + +func (mappings *PolicyMappings) findPolicyMatchImpl(imageName string, platform *v1.Platform, matched map[*PolicyRule]bool) (*PolicyMatch, error) { + for _, rule := range mappings.Rules { + if !rule.matchesPlatform(platform) { + continue + } + 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 &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 mappings.findPolicyMatchImpl(imageName, platform, matched) + } + } + } + return &PolicyMatch{MatchType: MatchTypeNoMatch}, nil +} + +func (rule *PolicyRule) matchesPlatform(platform *v1.Platform) bool { + if len(rule.Platforms) == 0 { + return true + } + for i := range rule.Platforms { + if rule.Platforms[i].Equals(*platform) { + return true + } + } + return false +} diff --git a/mapping/match_test.go b/mapping/match_test.go new file mode 100644 index 0000000..31f0c61 --- /dev/null +++ b/mapping/match_test.go @@ -0,0 +1,200 @@ +package mapping + +import ( + "path/filepath" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindPolicyMatch(t *testing.T) { + defaultPlatform, err := v1.ParsePlatform("linux/amd64") + require.NoError(t, err) + testCases := []struct { + name string + imageName string + mappingDir string + expectError bool + expectLoadingError bool + expectedMatchType matchType + expectedPolicyID string + expectedImageName string + platform 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, + }, + { + 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, + }, + { + 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: "rewrite loop", + mappingDir: "rewrite-loop", + imageName: "yin/alpine", + + expectError: true, + }, + { + name: "alpine with platform", + mappingDir: "doi", + imageName: "docker.io/library/alpine", + platform: "linux/amd64", + expectedMatchType: MatchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/library/alpine", + }, + { + name: "alpine with platform", + mappingDir: "doi-platform", + imageName: "docker.io/library/alpine", + platform: "linux/amd64", + expectedMatchType: MatchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/library/alpine", + }, + { + name: "alpine with no matching platform", + mappingDir: "doi-platform", + imageName: "docker.io/library/alpine", + platform: "linux/arm64", + expectedMatchType: MatchTypeNoMatch, + expectedPolicyID: "docker-official-images", + }, + { + name: "alpine with platform", + mappingDir: "doi-platform", + imageName: "docker.io/library/alpine", + platform: "linux/amd64", + expectedMatchType: MatchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/library/alpine", + }, + { + name: "alpine with invalid platform in mapping", + mappingDir: "doi-platform-broken", + imageName: "docker.io/library/alpine", + platform: "linux/amd64", + expectLoadingError: true, + }, + { + name: "firefox with > 1 platforms in policy", + mappingDir: "doi-platform", + imageName: "docker.io/mozilla/firefox", + platform: "linux/arm64", + expectedMatchType: MatchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/mozilla/firefox", + }, + { + name: "firefox with > 1 platforms in policy (no match)", + mappingDir: "doi-platform", + imageName: "docker.io/mozilla/firefox", + platform: "macOs/arm64", + expectedMatchType: MatchTypeNoMatch, + expectedPolicyID: "docker-official-images", + }, + { + name: "rewrite and platform", + mappingDir: "doi-platform", + imageName: "mycoolmirror.org/library/alpine", + platform: "linux/amd64", + expectedMatchType: MatchTypePolicy, + expectedPolicyID: "docker-official-images", + expectedImageName: "docker.io/library/alpine", + }, + { + name: "rewrite and platform mismatch", + mappingDir: "doi-platform", + imageName: "mycoolmirror.org/library/alpine", + platform: "macOs/amd64", + expectedMatchType: MatchTypeNoMatch, + expectedPolicyID: "docker-official-images", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mappings, err := LoadLocalMappings(filepath.Join("testdata", "mappings", tc.mappingDir)) + if tc.expectLoadingError { + require.Error(t, err) + return + } + require.NoError(t, err) + + platform := defaultPlatform + if tc.platform != "" { + platform, err = v1.ParsePlatform(tc.platform) + require.NoError(t, err) + } + match, err := mappings.FindPolicyMatch(tc.imageName, platform) + 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) + } + } + if match.MatchType == MatchTypeMatchNoPolicy || match.MatchType == MatchTypePolicy { + assert.Equal(t, tc.expectedImageName, match.MatchedName) + } + }) + } +} diff --git a/mapping/testdata/mappings/doi-platform-broken/mapping.yaml b/mapping/testdata/mappings/doi-platform-broken/mapping.yaml new file mode 100644 index 0000000..f9fd063 --- /dev/null +++ b/mapping/testdata/mappings/doi-platform-broken/mapping.yaml @@ -0,0 +1,11 @@ +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + platforms: ["linux/amd64/broken/platform/spec/1.0:foobar"] + policy-id: docker-official-images diff --git a/mapping/testdata/mappings/doi-platform/mapping.yaml b/mapping/testdata/mappings/doi-platform/mapping.yaml new file mode 100644 index 0000000..23826cd --- /dev/null +++ b/mapping/testdata/mappings/doi-platform/mapping.yaml @@ -0,0 +1,17 @@ +version: v1 +kind: policy-mapping +policies: + - id: docker-official-images + description: Docker Official Images + files: + - path: doi/policy.rego +rules: + - pattern: "^docker[.]io/library/(.*)$" + platforms: ["linux/amd64"] + policy-id: docker-official-images + - pattern: "^docker.io/mozilla/(.*)$" + platforms: ["linux/amd64", "linux/arm64"] + policy-id: docker-official-images + - pattern: "^mycoolmirror[.]org/library/(.*)$" + platforms: ["linux/amd64"] + rewrite: "docker.io/library/$1" diff --git a/policy/testdata/mappings/doi/mapping.yaml b/mapping/testdata/mappings/doi/mapping.yaml similarity index 100% rename from policy/testdata/mappings/doi/mapping.yaml rename to mapping/testdata/mappings/doi/mapping.yaml diff --git a/policy/testdata/mappings/local/mapping.yaml b/mapping/testdata/mappings/local/mapping.yaml similarity index 100% rename from policy/testdata/mappings/local/mapping.yaml rename to mapping/testdata/mappings/local/mapping.yaml diff --git a/policy/testdata/mappings/rewrite-invalid/mapping.yaml b/mapping/testdata/mappings/rewrite-invalid/mapping.yaml similarity index 100% rename from policy/testdata/mappings/rewrite-invalid/mapping.yaml rename to mapping/testdata/mappings/rewrite-invalid/mapping.yaml diff --git a/policy/testdata/mappings/rewrite-loop/mapping.yaml b/mapping/testdata/mappings/rewrite-loop/mapping.yaml similarity index 100% rename from policy/testdata/mappings/rewrite-loop/mapping.yaml rename to mapping/testdata/mappings/rewrite-loop/mapping.yaml diff --git a/policy/testdata/mappings/rewrite-multiple/mapping.yaml b/mapping/testdata/mappings/rewrite-multiple/mapping.yaml similarity index 100% rename from policy/testdata/mappings/rewrite-multiple/mapping.yaml rename to mapping/testdata/mappings/rewrite-multiple/mapping.yaml diff --git a/policy/testdata/mappings/rewrite-to-local/mapping.yaml b/mapping/testdata/mappings/rewrite-to-local/mapping.yaml similarity index 100% rename from policy/testdata/mappings/rewrite-to-local/mapping.yaml rename to mapping/testdata/mappings/rewrite-to-local/mapping.yaml diff --git a/policy/testdata/mappings/rewrite-to-no-match/mapping.yaml b/mapping/testdata/mappings/rewrite-to-no-match/mapping.yaml similarity index 100% rename from policy/testdata/mappings/rewrite-to-no-match/mapping.yaml rename to mapping/testdata/mappings/rewrite-to-no-match/mapping.yaml diff --git a/policy/testdata/mappings/simple-rewrite/mapping.yaml b/mapping/testdata/mappings/simple-rewrite/mapping.yaml similarity index 100% rename from policy/testdata/mappings/simple-rewrite/mapping.yaml rename to mapping/testdata/mappings/simple-rewrite/mapping.yaml diff --git a/config/types.go b/mapping/types.go similarity index 80% rename from config/types.go rename to mapping/types.go index e3ebc83..96192e3 100644 --- a/config/types.go +++ b/mapping/types.go @@ -1,7 +1,9 @@ -package config +package mapping import ( "regexp" + + v1 "github.com/google/go-containerregistry/pkg/v1" ) type policyMappingsFile struct { @@ -12,9 +14,10 @@ type policyMappingsFile struct { } type policyRuleFile struct { - Pattern string `json:"pattern"` - PolicyID string `json:"policy-id"` - Replacement string `json:"rewrite"` + Pattern string `json:"pattern"` + Platforms []string `json:"platforms"` + PolicyID string `json:"policy-id"` + Replacement string `json:"rewrite"` } type PolicyMappings struct { @@ -51,4 +54,5 @@ type PolicyRule struct { Pattern *regexp.Regexp PolicyID string Replacement string + Platforms []*v1.Platform } diff --git a/policy/match.go b/policy/match.go deleted file mode 100644 index 374aec2..0000000 --- a/policy/match.go +++ /dev/null @@ -1,65 +0,0 @@ -package policy - -import ( - "fmt" - - "github.com/docker/attest/config" -) - -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 &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 &policyMatch{matchType: matchTypeNoMatch, matchedName: imageName}, nil -} diff --git a/policy/match_test.go b/policy/match_test.go deleted file mode 100644 index b8f743d..0000000 --- a/policy/match_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package policy - -import ( - "path/filepath" - "testing" - - "github.com/docker/attest/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 - expectLoadingError 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: "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/policy/policy.go b/policy/policy.go index 86c7310..d63ab6a 100644 --- a/policy/policy.go +++ b/policy/policy.go @@ -6,7 +6,7 @@ import ( "github.com/distribution/reference" "github.com/docker/attest/attestation" - "github.com/docker/attest/config" + "github.com/docker/attest/mapping" "github.com/docker/attest/oci" intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/package-url/packageurl-go" @@ -22,9 +22,9 @@ func CreateImageDetailsResolver(imageSource *oci.ImageSpec) (oci.ImageDetailsRes return nil, fmt.Errorf("unsupported image source type: %s", imageSource.Type) } -func CreateAttestationResolver(resolver oci.ImageDetailsResolver, mapping *config.PolicyMapping) (attestation.Resolver, error) { - if mapping.Attestations != nil { - if mapping.Attestations.Style == config.AttestationStyleAttached { +func CreateAttestationResolver(resolver oci.ImageDetailsResolver, policyMapping *mapping.PolicyMapping) (attestation.Resolver, error) { + if policyMapping.Attestations != nil { + if policyMapping.Attestations.Style == mapping.AttestationStyleAttached { switch resolver := resolver.(type) { case *oci.RegistryImageDetailsResolver: return attestation.NewRegistryResolver(resolver) @@ -34,8 +34,8 @@ func CreateAttestationResolver(resolver oci.ImageDetailsResolver, mapping *confi return nil, fmt.Errorf("unsupported image details resolver type: %T", resolver) } } - if mapping.Attestations.Repo != "" { - return attestation.NewReferrersResolver(resolver, attestation.WithReferrersRepo(mapping.Attestations.Repo)) + if policyMapping.Attestations.Repo != "" { + return attestation.NewReferrersResolver(resolver, attestation.WithReferrersRepo(policyMapping.Attestations.Repo)) } } return attestation.NewReferrersResolver(resolver) diff --git a/policy/policy_test.go b/policy/policy_test.go index a29e58f..57c4dde 100644 --- a/policy/policy_test.go +++ b/policy/policy_test.go @@ -8,8 +8,8 @@ import ( "testing" "github.com/docker/attest/attestation" - "github.com/docker/attest/config" "github.com/docker/attest/internal/test" + "github.com/docker/attest/mapping" "github.com/docker/attest/oci" "github.com/docker/attest/policy" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -45,7 +45,8 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { defaultResolver := attestation.MockResolver{ Envs: []*attestation.Envelope{loadAttestation(t, ExampleAttestation)}, } - + defaultPlatform, err := v1.ParsePlatform("linux/amd64") + require.NoError(t, err) testCases := []struct { policyPath string expectSuccess bool @@ -87,7 +88,7 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { imageName, err := tc.resolver.ImageName(ctx) require.NoError(t, err) resolver := policy.NewResolver(nil, tc.opts) - policy, err := resolver.ResolvePolicy(ctx, imageName) + policy, err := resolver.ResolvePolicy(ctx, imageName, defaultPlatform) if tc.resolveErrorStr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.resolveErrorStr) @@ -108,7 +109,7 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { } func TestLoadingMappings(t *testing.T) { - policyMappings, err := config.LoadLocalMappings(filepath.Join("testdata", "policies", "allow")) + policyMappings, err := mapping.LoadLocalMappings(filepath.Join("testdata", "policies", "allow")) require.NoError(t, err) assert.Equal(t, len(policyMappings.Rules), 3) for _, mirror := range policyMappings.Rules { @@ -125,32 +126,32 @@ func TestCreateAttestationResolver(t *testing.T) { layoutResolver := &attestation.LayoutResolver{} registryResolver := &oci.RegistryImageDetailsResolver{} - nilRepoReferrers := &config.PolicyMapping{ - Attestations: &config.AttestationConfig{ - Style: config.AttestationStyleReferrers, + nilRepoReferrers := &mapping.PolicyMapping{ + Attestations: &mapping.AttestationConfig{ + Style: mapping.AttestationStyleReferrers, }, } - referrers := &config.PolicyMapping{ - Attestations: &config.AttestationConfig{ + referrers := &mapping.PolicyMapping{ + Attestations: &mapping.AttestationConfig{ Repo: "localhost:5000/repo", - Style: config.AttestationStyleReferrers, + Style: mapping.AttestationStyleReferrers, }, } - attached := &config.PolicyMapping{ - Attestations: &config.AttestationConfig{ - Style: config.AttestationStyleAttached, + attached := &mapping.PolicyMapping{ + Attestations: &mapping.AttestationConfig{ + Style: mapping.AttestationStyleAttached, }, } testCases := []struct { name string resolver oci.ImageDetailsResolver - mapping *config.PolicyMapping + mapping *mapping.PolicyMapping errorStr string }{ {name: "referrers", resolver: layoutResolver, mapping: referrers}, {name: "referrers (no mapped repo)", resolver: layoutResolver, mapping: nilRepoReferrers}, - {name: "referrers (no mapping)", resolver: layoutResolver, mapping: &config.PolicyMapping{Attestations: nil}}, + {name: "referrers (no mapping)", resolver: layoutResolver, mapping: &mapping.PolicyMapping{Attestations: nil}}, {name: "attached (registry)", resolver: registryResolver, mapping: attached}, {name: "attached (layout)", resolver: layoutResolver, mapping: attached}, {name: "attached (unsupported)", resolver: mockResolver, mapping: attached, errorStr: "unsupported image details resolver type"}, @@ -169,11 +170,11 @@ func TestCreateAttestationResolver(t *testing.T) { } switch resolver.(type) { case *attestation.ReferrersResolver: - assert.Equal(t, tc.mapping.Attestations.Style, config.AttestationStyleReferrers) + assert.Equal(t, tc.mapping.Attestations.Style, mapping.AttestationStyleReferrers) case *attestation.RegistryResolver: - assert.Equal(t, tc.mapping.Attestations.Style, config.AttestationStyleAttached) + assert.Equal(t, tc.mapping.Attestations.Style, mapping.AttestationStyleAttached) case *attestation.LayoutResolver: - assert.Equal(t, tc.mapping.Attestations.Style, config.AttestationStyleAttached) + assert.Equal(t, tc.mapping.Attestations.Style, mapping.AttestationStyleAttached) } }) } diff --git a/policy/resolver.go b/policy/resolver.go index 2e3a445..66cfb47 100644 --- a/policy/resolver.go +++ b/policy/resolver.go @@ -8,9 +8,10 @@ import ( "path/filepath" "github.com/distribution/reference" - "github.com/docker/attest/config" "github.com/docker/attest/internal/util" + "github.com/docker/attest/mapping" "github.com/docker/attest/tuf" + v1 "github.com/google/go-containerregistry/pkg/v1" ) type Resolver struct { @@ -25,7 +26,7 @@ func NewResolver(tufClient tuf.Downloader, opts *Options) *Resolver { } } -func (r *Resolver) ResolvePolicy(_ context.Context, imageName string) (*Policy, error) { +func (r *Resolver) ResolvePolicy(_ context.Context, imageName string, platform *v1.Platform) (*Policy, error) { p, err := r.resolvePolicyByID() if err != nil { return nil, fmt.Errorf("failed to resolve policy by id: %w", err) @@ -37,45 +38,45 @@ func (r *Resolver) ResolvePolicy(_ context.Context, imageName string) (*Policy, if err != nil { return nil, fmt.Errorf("failed to parse image name: %w", err) } - localMappings, err := config.LoadLocalMappings(r.opts.LocalPolicyDir) + localMappings, err := mapping.LoadLocalMappings(r.opts.LocalPolicyDir) if err != nil { return nil, fmt.Errorf("failed to load local policy mappings: %w", err) } - match, err := findPolicyMatch(imageName, localMappings) + match, err := localMappings.FindPolicyMatch(imageName, platform) if err != nil { return nil, err } - if match.matchType == matchTypePolicy { - return r.resolveLocalPolicy(match.policy, imageName, match.matchedName) + if match.MatchType == mapping.MatchTypePolicy { + return r.resolveLocalPolicy(match.Policy, imageName, match.MatchedName) } if !r.opts.DisableTUF { - tufMappings, err := config.LoadTUFMappings(r.tufClient, r.opts.LocalTargetsDir) + tufMappings, err := mapping.LoadTUFMappings(r.tufClient, r.opts.LocalTargetsDir) if err != nil { return nil, fmt.Errorf("failed to load tuf policy mappings as fallback: %w", err) } // it's a mirror of a tuf policy - if match.matchType == matchTypeMatchNoPolicy { + if match.MatchType == mapping.MatchTypeMatchNoPolicy { for _, mapping := range tufMappings.Policies { - if mapping.ID == match.rule.PolicyID { - return r.resolveTUFPolicy(mapping, imageName, match.matchedName) + if mapping.ID == match.Rule.PolicyID { + return r.resolveTUFPolicy(mapping, imageName, match.MatchedName) } } } // try to resolve a tuf policy directly - match, err = findPolicyMatch(imageName, tufMappings) + match, err = tufMappings.FindPolicyMatch(imageName, platform) if err != nil { return nil, err } - if match.matchType == matchTypePolicy { - return r.resolveTUFPolicy(match.policy, imageName, match.matchedName) + if match.MatchType == mapping.MatchTypePolicy { + return r.resolveTUFPolicy(match.Policy, imageName, match.MatchedName) } } return nil, nil } -func (r *Resolver) resolveLocalPolicy(mapping *config.PolicyMapping, imageName string, matchedName string) (*Policy, error) { +func (r *Resolver) resolveLocalPolicy(mapping *mapping.PolicyMapping, imageName string, matchedName string) (*Policy, error) { if r.opts.LocalPolicyDir == "" { return nil, fmt.Errorf("local policy dir not set") } @@ -118,7 +119,7 @@ func (r *Resolver) resolveLocalPolicy(mapping *config.PolicyMapping, imageName s return policy, nil } -func (r *Resolver) resolveTUFPolicy(mapping *config.PolicyMapping, imageName string, matchedName string) (*Policy, error) { +func (r *Resolver) resolveTUFPolicy(mapping *mapping.PolicyMapping, imageName string, matchedName string) (*Policy, error) { var URI string var digest map[string]string files := make([]*File, 0, len(mapping.Files)) @@ -159,7 +160,7 @@ func (r *Resolver) resolveTUFPolicy(mapping *config.PolicyMapping, imageName str func (r *Resolver) resolvePolicyByID() (*Policy, error) { if r.opts.PolicyID != "" { - localMappings, err := config.LoadLocalMappings(r.opts.LocalPolicyDir) + localMappings, err := mapping.LoadLocalMappings(r.opts.LocalPolicyDir) if err != nil { return nil, fmt.Errorf("failed to load local policy mappings: %w", err) } @@ -171,7 +172,7 @@ func (r *Resolver) resolvePolicyByID() (*Policy, error) { } if !r.opts.DisableTUF { - tufMappings, err := config.LoadTUFMappings(r.tufClient, r.opts.LocalTargetsDir) + tufMappings, err := mapping.LoadTUFMappings(r.tufClient, r.opts.LocalTargetsDir) if err != nil { return nil, fmt.Errorf("failed to load tuf policy mappings by id: %w", err) } diff --git a/policy/resolver_test.go b/policy/resolver_test.go index cb8ed5a..36412c5 100644 --- a/policy/resolver_test.go +++ b/policy/resolver_test.go @@ -7,6 +7,7 @@ import ( "github.com/docker/attest/internal/test" "github.com/docker/attest/policy" "github.com/docker/attest/tuf" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,7 +18,8 @@ func TestResolvePolicy(t *testing.T) { noLocalPolicyPath := "testdata/policies/no-policy" testPolicyID := "docker-official-images" testImageName := "localhost:5001/test/repo:tag" - + defaultPlatform, err := v1.ParsePlatform("linux/amd64") + require.NoError(t, err) testCases := []struct { name string policyPath string @@ -52,7 +54,7 @@ func TestResolvePolicy(t *testing.T) { opts.DisableTUF = tc.DisableTUF opts.LocalTargetsDir = tempDir resolver := policy.NewResolver(tufClient, opts) - policy, err := resolver.ResolvePolicy(context.Background(), testImageName) + policy, err := resolver.ResolvePolicy(context.Background(), testImageName, defaultPlatform) require.NoError(t, err) assert.NotNil(t, policy) if tc.DisableTUF || tc.localOverridesTUF { diff --git a/policy/types.go b/policy/types.go index 5a5f40c..d9d2873 100644 --- a/policy/types.go +++ b/policy/types.go @@ -2,7 +2,7 @@ package policy import ( "github.com/docker/attest/attestation" - "github.com/docker/attest/config" + "github.com/docker/attest/mapping" "github.com/docker/attest/tuf" intoto "github.com/in-toto/in-toto-golang/in_toto" ) @@ -34,7 +34,7 @@ type Options struct { LocalPolicyDir string PolicyID string ReferrersRepo string - AttestationStyle config.AttestationStyle + AttestationStyle mapping.AttestationStyle Debug bool AttestationVerifier attestation.Verifier } @@ -42,7 +42,7 @@ type Options struct { type Policy struct { InputFiles []*File Query string - Mapping *config.PolicyMapping + Mapping *mapping.PolicyMapping ResolvedName string URI string Digest map[string]string diff --git a/verify.go b/verify.go index 1c6b745..deb0476 100644 --- a/verify.go +++ b/verify.go @@ -10,7 +10,7 @@ import ( "github.com/distribution/reference" "github.com/docker/attest/attestation" - "github.com/docker/attest/config" + "github.com/docker/attest/mapping" "github.com/docker/attest/oci" "github.com/docker/attest/policy" "github.com/docker/attest/tuf" @@ -60,7 +60,12 @@ func (verifier *ImageVerifier) Verify(ctx context.Context, src *oci.ImageSpec) ( return nil, fmt.Errorf("failed to resolve image name: %w", err) } policyResolver := policy.NewResolver(verifier.tufClient, verifier.opts) - resolvedPolicy, err := policyResolver.ResolvePolicy(ctx, imageName) + + platform, err := detailsResolver.ImagePlatform(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get image platform: %w", err) + } + resolvedPolicy, err := policyResolver.ResolvePolicy(ctx, imageName, platform) if err != nil { return nil, fmt.Errorf("failed to resolve policy: %w", err) } @@ -72,14 +77,14 @@ func (verifier *ImageVerifier) Verify(ctx context.Context, src *oci.ImageSpec) ( } // this is overriding the mapping with a referrers config. Useful for testing if nothing else if verifier.opts.ReferrersRepo != "" { - resolvedPolicy.Mapping.Attestations = &config.AttestationConfig{ + resolvedPolicy.Mapping.Attestations = &mapping.AttestationConfig{ Repo: verifier.opts.ReferrersRepo, - Style: config.AttestationStyleReferrers, + Style: mapping.AttestationStyleReferrers, } - } else if verifier.opts.AttestationStyle == config.AttestationStyleAttached { - resolvedPolicy.Mapping.Attestations = &config.AttestationConfig{ + } else if verifier.opts.AttestationStyle == mapping.AttestationStyleAttached { + resolvedPolicy.Mapping.Attestations = &mapping.AttestationConfig{ Repo: verifier.opts.ReferrersRepo, - Style: config.AttestationStyleAttached, + Style: mapping.AttestationStyleAttached, } } // because we have a mapping now, we can select a resolver based on its contents (ie. referrers or attached) @@ -120,9 +125,9 @@ func populateDefaultOptions(opts *policy.Options) (err error) { } if opts.AttestationStyle == "" { - opts.AttestationStyle = config.AttestationStyleReferrers + opts.AttestationStyle = mapping.AttestationStyleReferrers } - if opts.ReferrersRepo != "" && opts.AttestationStyle != config.AttestationStyleReferrers { + if opts.ReferrersRepo != "" && opts.AttestationStyle != mapping.AttestationStyleReferrers { return fmt.Errorf("referrers repo specified but attestation source not set to referrers") } return nil diff --git a/verify_test.go b/verify_test.go index d9dd880..357338f 100644 --- a/verify_test.go +++ b/verify_test.go @@ -11,8 +11,8 @@ import ( "github.com/distribution/reference" "github.com/docker/attest/attestation" - "github.com/docker/attest/config" "github.com/docker/attest/internal/test" + "github.com/docker/attest/mapping" "github.com/docker/attest/oci" "github.com/docker/attest/policy" "github.com/docker/attest/tlog" @@ -97,7 +97,7 @@ func TestVSA(t *testing.T) { // mocked vsa query should pass policyOpts := &policy.Options{ LocalPolicyDir: PassPolicyDir, - AttestationStyle: config.AttestationStyleAttached, + AttestationStyle: mapping.AttestationStyleAttached, DisableTUF: true, } results, err := Verify(ctx, spec, policyOpts) @@ -152,7 +152,7 @@ func TestVerificationFailure(t *testing.T) { // mocked vsa query should fail policyOpts := &policy.Options{ LocalPolicyDir: FailPolicyDir, - AttestationStyle: config.AttestationStyleAttached, + AttestationStyle: mapping.AttestationStyleAttached, DisableTUF: true, } results, err := Verify(ctx, spec, policyOpts) @@ -273,7 +273,7 @@ func TestDefaultOptions(t *testing.T) { name string tufOpts *tuf.ClientOptions localTargetsDir string - attestationStyle config.AttestationStyle + attestationStyle mapping.AttestationStyle referrersRepo string expectedError string disableTuf bool @@ -282,9 +282,9 @@ func TestDefaultOptions(t *testing.T) { {name: "empty"}, {name: "tufClient provided", tufOpts: &tuf.ClientOptions{MetadataSource: "a", TargetsSource: "b"}}, {name: "localTargetsDir provided", localTargetsDir: test.CreateTempDir(t, "", TestTempDir)}, - {name: "attestationStyle provided", attestationStyle: config.AttestationStyleAttached}, + {name: "attestationStyle provided", attestationStyle: mapping.AttestationStyleAttached}, {name: "referrersRepo provided", referrersRepo: "referrers"}, - {name: "referrersRepo provided with attached", referrersRepo: "referrers", attestationStyle: config.AttestationStyleAttached, expectedError: "referrers repo specified but attestation source not set to referrers"}, + {name: "referrersRepo provided with attached", referrersRepo: "referrers", attestationStyle: mapping.AttestationStyleAttached, expectedError: "referrers repo specified but attestation source not set to referrers"}, {name: "tuf disabled and no local-policy-dir", disableTuf: true, expectedError: "local policy dir must be set if not using TUF"}, {name: "tuf disabled but options set", disableTuf: true, tufOpts: &tuf.ClientOptions{MetadataSource: "a", TargetsSource: "b"}, localPolicyDir: "foo", expectedError: "TUF client options set but TUF disabled"}, } @@ -320,7 +320,7 @@ func TestDefaultOptions(t *testing.T) { if tc.attestationStyle != "" { assert.Equal(t, tc.attestationStyle, opts.AttestationStyle) } else { - assert.Equal(t, config.AttestationStyleReferrers, opts.AttestationStyle) + assert.Equal(t, mapping.AttestationStyleReferrers, opts.AttestationStyle) } if tc.tufOpts != nil {