diff --git a/go.mod b/go.mod index ac941b8..e834fcb 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22.1 require ( github.com/Masterminds/semver/v3 v3.2.1 - github.com/aws/aws-sdk-go-v2/config v1.27.19 + github.com/aws/aws-sdk-go-v2/config v1.27.21 github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20231024185945-8841054dbdb8 github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 github.com/containerd/containerd v1.7.18 @@ -50,20 +50,20 @@ require ( github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/agnivade/levenshtein v1.1.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2 v1.28.0 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.19 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10 // indirect + github.com/aws/aws-sdk-go-v2 v1.30.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.21 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.12 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect github.com/aws/aws-sdk-go-v2/service/ecr v1.20.2 // indirect github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.18.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.14 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.32.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.20.12 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.21.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.25.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.29.1 // indirect github.com/aws/smithy-go v1.20.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect diff --git a/go.sum b/go.sum index 1505eb1..b705424 100644 --- a/go.sum +++ b/go.sum @@ -105,20 +105,20 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:W github.com/aws/aws-sdk-go v1.53.10 h1:3enP5l5WtezT9Ql+XZqs56JBf5YUd/FEzTCg///OIGY= github.com/aws/aws-sdk-go v1.53.10/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v1.21.2/go.mod h1:ErQhvNuEMhJjweavOYhxVkn2RUx7kQXVATHrjKtxIpM= -github.com/aws/aws-sdk-go-v2 v1.28.0 h1:ne6ftNhY0lUvlazMUQF15FF6NH80wKmPRFG7g2q6TCw= -github.com/aws/aws-sdk-go-v2 v1.28.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= -github.com/aws/aws-sdk-go-v2/config v1.27.19 h1:+DBS8gJP6VsxYkZ6UEV0/VsRM2rYpbQCYsosW9RRmeQ= -github.com/aws/aws-sdk-go-v2/config v1.27.19/go.mod h1:KzZcioJWzy9oV+oS5CobYXlDtU9+eW7bPG1g7gizTW4= -github.com/aws/aws-sdk-go-v2/credentials v1.17.19 h1:R18G7nBBGLby51CFEqUBFF2IVl7LUdCtYj6iosUwh/0= -github.com/aws/aws-sdk-go-v2/credentials v1.17.19/go.mod h1:xr9kUMnaLTB866HItT6pg58JgiBP77fSQLBwIa//zk8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6 h1:vVOuhRyslJ6T/HteG71ZWCTas1q2w6f0NKsNbkXHs/A= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6/go.mod h1:jimWaqLiT0sJGLh51dKCLLtExRYPtMU7MpxuCgtbkxg= +github.com/aws/aws-sdk-go-v2 v1.30.0 h1:6qAwtzlfcTtcL8NHtbDQAqgM5s6NDipQTkPxyH/6kAA= +github.com/aws/aws-sdk-go-v2 v1.30.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/config v1.27.21 h1:yPX3pjGCe2hJsetlmGNB4Mngu7UPmvWPzzWCv1+boeM= +github.com/aws/aws-sdk-go-v2/config v1.27.21/go.mod h1:4XtlEU6DzNai8RMbjSF5MgGZtYvrhBP/aKZcRtZAVdM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.21 h1:pjAqgzfgFhTv5grc7xPHtXCAaMapzmwA7aU+c/SZQGw= +github.com/aws/aws-sdk-go-v2/credentials v1.17.21/go.mod h1:nhK6PtBlfHTUDVmBLr1dg+WHCOCK+1Fu/WQyVHPsgNQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.8 h1:FR+oWPFb/8qMVYMWN98bUZAGqPvLHiyqg1wqQGfUAXY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.8/go.mod h1:EgSKcHiuuakEIxJcKGzVNWh5srVAQ3jKaSrBGRYvM48= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.43/go.mod h1:auo+PiyLl0n1l8A0e8RIeR8tOzYPfZZH/JNlrJ8igTQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10 h1:LZIUb8sQG2cb89QaVFtMSnER10gyKkqU1k3hP3g9das= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10/go.mod h1:BRIqay//vnIOCZjoXWSLffL2uzbtxEmnSlfbvVh7Z/4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.12 h1:SJ04WXGTwnHlWIODtC5kJzKbeuHt+OUNOgKg7nfnUGw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.12/go.mod h1:FkpvXhA92gb3GE9LD6Og0pHHycTxW7xGpnEh5E7Opwo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.37/go.mod h1:Qe+2KtKml+FEsQF/DHmDV+xjtche/hwoF75EG4UlHW8= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10 h1:HY7CXLA0GiQUo3WYxOP7WYkLcwvRX4cLPf5joUcrQGk= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10/go.mod h1:kfRBSxRa+I+VyON7el3wLZdrO91oxUxEwdAaWgFqN90= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.12 h1:hb5KgeYfObi5MHkSSZMEudnIvX30iB+E21evI4r6BnQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.12/go.mod h1:CroKe/eWJdyfy9Vx4rljP5wTUjNJfb+fPz1uMYUhEGM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= github.com/aws/aws-sdk-go-v2/service/ecr v1.20.2 h1:y6LX9GUoEA3mO0qpFl1ZQHj1rFyPWVphlzebiSt2tKE= @@ -127,16 +127,16 @@ github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.18.2 h1:PpbXaecV3sLAS6rjQiaKw4 github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.18.2/go.mod h1:fUHpGXr4DrXkEDpGAjClPsviWf+Bszeb0daKE0blxv8= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12 h1:kO2J7WMroF/OTHN9WTcUtMjPhJ7ZoNxx0dwv6UCXQgY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12/go.mod h1:mrNxrjYvXaSjZe5fkKaWgDnOQ6BExLn/7Ru9OpRsMPY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.14 h1:zSDPny/pVnkqABXYRicYuPf9z2bTqfH13HT3v6UheIk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.14/go.mod h1:3TTcI5JSzda1nw/pkVC9dhgLre0SNBFj2lYS4GctXKI= github.com/aws/aws-sdk-go-v2/service/kms v1.32.1 h1:FARrQLRQXpCFYylIUVF1dRij6YbPCmtwudq9NBk4kFc= github.com/aws/aws-sdk-go-v2/service/kms v1.32.1/go.mod h1:8lETO9lelSG2B6KMXFh2OwPPqGV6WQM3RqLAEjP1xaU= -github.com/aws/aws-sdk-go-v2/service/sso v1.20.12 h1:FsYii6U+2k8ynYBo+pywlCBY9HNAFRh+iICRHbn+Qyw= -github.com/aws/aws-sdk-go-v2/service/sso v1.20.12/go.mod h1:j9Rps+Lcs2A0tYypWsNBeJOjgsIYUf1Styppo9Es0Wo= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 h1:lEE+xEcq3lh9bk362tgErP1+n689q5ERdmTwmF1XT3M= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6/go.mod h1:2tR0x1DCL5IgnVZ1NQNFDNg5/XL/kiQgWI5l7I/N5Js= -github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 h1:TSzmuUeruVJ4XWYp3bYzKCXue70ECpJWmbP3UfEvhYY= -github.com/aws/aws-sdk-go-v2/service/sts v1.28.13/go.mod h1:FppRtFjBA9mSWTj2cIAWCP66+bbBPMuPpBfWRXC5Yi0= +github.com/aws/aws-sdk-go-v2/service/sso v1.21.1 h1:sd0BsnAvLH8gsp2e3cbaIr+9D7T1xugueQ7V/zUAsS4= +github.com/aws/aws-sdk-go-v2/service/sso v1.21.1/go.mod h1:lcQG/MmxydijbeTOp04hIuJwXGWPZGI3bwdFDGRTv14= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.25.1 h1:1uEFNNskK/I1KoZ9Q8wJxMz5V9jyBlsiaNrM7vA3YUQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.25.1/go.mod h1:z0P8K+cBIsFXUr5rzo/psUeJ20XjPN0+Nn8067Nd+E4= +github.com/aws/aws-sdk-go-v2/service/sts v1.29.1 h1:myX5CxqXE0QMZNja6FA1/FSE3Vu1rVmeUmpJMMzeZg0= +github.com/aws/aws-sdk-go-v2/service/sts v1.29.1/go.mod h1:N2mQiucsO0VwK9CYuS4/c2n6Smeh1v47Rz3dWCPFLdE= github.com/aws/smithy-go v1.15.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= diff --git a/pkg/attest/example_verify_test.go b/pkg/attest/example_verify_test.go index cb71f67..6f5571a 100644 --- a/pkg/attest/example_verify_test.go +++ b/pkg/attest/example_verify_test.go @@ -39,19 +39,6 @@ func ExampleVerify_remote() { // create a resolver for remote attestations image := "registry-1.docker.io/library/notary:server" platform := "linux/amd64" - resolver, err := oci.NewRegistryAttestationResolver( - image, // path to image index in OCI registry containing image attestations - platform) // platform of subject image (image that attestations are being verified against) - if err != nil { - panic(err) - } - // example using a local resolver - // path := "/myimage" - // platform := "linux/amd64" - // resolver := &oci.OCILayoutResolver{ - // Path: path, // file path to OCI layout containing image attestations - // Platform: platform, // platform of subject image (image that attestations are being verified against) - // } // configure policy options opts := &policy.PolicyOptions{ @@ -62,7 +49,11 @@ func ExampleVerify_remote() { } // verify attestations - result, err := attest.Verify(context.Background(), opts, resolver) + src, err := oci.ParseImageSpec(image, oci.WithPlatform(platform)) + if err != nil { + panic(err) + } + result, err := attest.Verify(context.Background(), src, opts) if err != nil { panic(err) } diff --git a/pkg/attest/sign_test.go b/pkg/attest/sign_test.go index 96c5eb6..8769674 100644 --- a/pkg/attest/sign_test.go +++ b/pkg/attest/sign_test.go @@ -48,7 +48,7 @@ func TestSignVerifyOCILayout(t *testing.T) { {"no provenance (replace)", NoProvenanceImage, 0, 2, true}, {"no provenance (no replace)", NoProvenanceImage, 2, 2, false}, } - policyResolver := &policy.PolicyOptions{ + policyOpts := &policy.PolicyOptions{ LocalPolicyDir: PassPolicyDir, } for _, tc := range testCases { @@ -74,9 +74,9 @@ func TestSignVerifyOCILayout(t *testing.T) { }) _, err = layout.Write(outputLayout, idx) require.NoError(t, err) - resolver, err := oci.NewOCILayoutAttestationResolver(outputLayout, "") + src, err := oci.ParseImageSpec("oci://" + outputLayout) require.NoError(t, err) - policy, err := Verify(ctx, policyResolver, resolver) + policy, err := Verify(ctx, src, policyOpts) require.NoError(t, err) assert.Equalf(t, OutcomeSuccess, policy.Outcome, "Policy should have been found") diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index e0bfd0b..dc72da7 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -6,13 +6,25 @@ import ( "time" "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/config" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/policy" intoto "github.com/in-toto/in-toto-golang/in_toto" ) -func Verify(ctx context.Context, opts *policy.PolicyOptions, resolver oci.AttestationResolver) (result *VerificationResult, err error) { - pctx, err := policy.ResolvePolicy(ctx, resolver, opts) +func Verify(ctx context.Context, src *oci.ImageSpec, opts *policy.PolicyOptions) (result *VerificationResult, err error) { + // so that we can resolve mapping from the image name earlier + detailsResolver, err := policy.CreateImageDetailsResolver(src) + if err != nil { + return nil, fmt.Errorf("failed to create image details resolver: %w", err) + } + if opts.AttestationStyle == "" { + opts.AttestationStyle = config.AttestationStyleReferrers + } + if opts.ReferrersRepo != "" && opts.AttestationStyle != config.AttestationStyleReferrers { + return nil, fmt.Errorf("referrers repo specified but attestation source not set to referrers") + } + pctx, err := policy.ResolvePolicy(ctx, detailsResolver, opts) if err != nil { return nil, fmt.Errorf("failed to resolve policy: %w", err) } @@ -22,7 +34,23 @@ func Verify(ctx context.Context, opts *policy.PolicyOptions, resolver oci.Attest Outcome: OutcomeNoPolicy, }, nil } - + // this is overriding the mapping with a referrers config. Useful for testing if nothing else + if opts.ReferrersRepo != "" { + pctx.Mapping.Attestations = &config.ReferrersConfig{ + Repo: opts.ReferrersRepo, + Style: config.AttestationStyleReferrers, + } + } else if opts.AttestationStyle == config.AttestationStyleAttached { + pctx.Mapping.Attestations = &config.ReferrersConfig{ + Repo: opts.ReferrersRepo, + Style: config.AttestationStyleAttached, + } + } + // because we have a mapping now, we can select a resolver based on its contents (ie. referrers or attached) + resolver, err := policy.CreateAttestationResolver(detailsResolver, pctx.Mapping) + if err != nil { + return nil, fmt.Errorf("failed to create attestation resolver: %w", err) + } result, err = VerifyAttestations(ctx, resolver, pctx) if err != nil { return nil, fmt.Errorf("failed to evaluate policy: %w", err) @@ -90,7 +118,7 @@ func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, p if err != nil { return nil, fmt.Errorf("failed to get image name: %w", err) } - platform, err := resolver.ImagePlatform() + platform, err := resolver.ImagePlatform(ctx) if err != nil { return nil, err } diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go index c49066f..287c524 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -25,6 +25,10 @@ var ( ExampleAttestation = filepath.Join("..", "..", "test", "testdata", "example_attestation.json") ) +const ( + LinuxAMD64 = "linux/amd64" +) + func TestVerifyAttestations(t *testing.T) { ex, err := os.ReadFile(ExampleAttestation) assert.NoError(t, err) @@ -94,14 +98,13 @@ func TestVSA(t *testing.T) { _, err = layout.Write(outputLayout, idx) assert.NoError(t, err) - resolver, err := oci.NewOCILayoutAttestationResolver(outputLayout, "linux/amd64") - require.NoError(t, err) - // mocked vsa query should pass policyOpts := &policy.PolicyOptions{ LocalPolicyDir: PassPolicyDir, } - results, err := Verify(ctx, policyOpts, resolver) + src, err := oci.ParseImageSpec("oci://"+outputLayout, oci.WithPlatform(LinuxAMD64)) + require.NoError(t, err) + results, err := Verify(ctx, src, policyOpts) require.NoError(t, err) assert.Equal(t, OutcomeSuccess, results.Outcome) assert.Empty(t, results.Violations) @@ -151,14 +154,13 @@ func TestVerificationFailure(t *testing.T) { _, err = layout.Write(outputLayout, idx) assert.NoError(t, err) - resolver, err := oci.NewOCILayoutAttestationResolver(outputLayout, "linux/amd64") - require.NoError(t, err) - // mocked vsa query should fail policyOpts := &policy.PolicyOptions{ LocalPolicyDir: FailPolicyDir, } - results, err := Verify(ctx, policyOpts, resolver) + src, err := oci.ParseImageSpec("oci://"+outputLayout, oci.WithPlatform(LinuxAMD64)) + require.NoError(t, err) + results, err := Verify(ctx, src, policyOpts) require.NoError(t, err) assert.Equal(t, OutcomeFailure, results.Outcome) assert.Len(t, results.Violations, 1) @@ -225,13 +227,12 @@ func TestSignVerifyNoTL(t *testing.T) { _, err = layout.Write(outputLayout, idx) assert.NoError(t, err) - resolver, err := oci.NewOCILayoutAttestationResolver(outputLayout, "linux/amd64") - require.NoError(t, err) - policyOpts := &policy.PolicyOptions{ LocalPolicyDir: tc.policyDir, } - results, err := Verify(ctx, policyOpts, resolver) + src, err := oci.ParseImageSpec("oci://"+outputLayout, oci.WithPlatform(LinuxAMD64)) + require.NoError(t, err) + results, err := Verify(ctx, src, policyOpts) require.NoError(t, err) assert.Equal(t, OutcomeSuccess, results.Outcome) }) diff --git a/pkg/attestation/referrers_test.go b/pkg/attestation/referrers_test.go index f8ad8f8..8ec705f 100644 --- a/pkg/attestation/referrers_test.go +++ b/pkg/attestation/referrers_test.go @@ -10,6 +10,7 @@ import ( "github.com/docker/attest/internal/test" "github.com/docker/attest/pkg/attest" "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/config" "github.com/docker/attest/pkg/mirror" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/policy" @@ -21,21 +22,29 @@ 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") + LocalPolicy = filepath.Join("..", "..", "test", "testdata", "local-policy") + LocalPolicyAttached = filepath.Join("..", "..", "test", "testdata", "local-policy-attached") + PassNoTLPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-no-tl") + FailPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy-fail") + TestTempDir = "attest-sign-test" ) func TestAttestationReferenceTypes(t *testing.T) { ctx, signer := test.Setup(t) + ctx = policy.WithPolicyEvaluator(ctx, policy.NewRegoEvaluator(true)) platforms := []string{"linux/amd64", "linux/arm64"} for _, tc := range []struct { - server *httptest.Server - skipSubject bool - useDigest bool + server *httptest.Server + referrersServer *httptest.Server + skipSubject bool + useDigest bool + referrersRepo string + attestationSource config.AttestationStyle + expectFailure bool + policyDir string }{ { server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), @@ -44,78 +53,135 @@ func TestAttestationReferenceTypes(t *testing.T) { server: httptest.NewServer(registry.New()), }, { - server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), - skipSubject: true, + server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), + skipSubject: true, + attestationSource: config.AttestationStyleAttached, }, { server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), useDigest: true, }, + { + server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), + expectFailure: true, //mismatched args + attestationSource: config.AttestationStyleAttached, + referrersRepo: "referrers", + }, + { + server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), + expectFailure: true, // no policy + attestationSource: config.AttestationStyleReferrers, + referrersRepo: "referrers", + }, + { + server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), + attestationSource: config.AttestationStyleReferrers, + }, + { + server: httptest.NewServer(registry.New(registry.WithReferrersSupport(false))), + attestationSource: config.AttestationStyleReferrers, + referrersServer: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), + }, } { - s := tc.server - defer s.Close() - u, err := url.Parse(s.URL) - require.NoError(t, err) + t.Run(fmt.Sprint(tc), func(t *testing.T) { + s := tc.server + defer s.Close() - opts := &attestation.SigningOptions{ - Replace: true, - SkipSubject: tc.skipSubject, - } - attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) - require.NoError(t, err) - signedIndex, err := attest.Sign(ctx, attIdx.Index, signer, opts) - require.NoError(t, err) - - indexName := fmt.Sprintf("%s/repo:root", u.Host) - require.NoError(t, err) - err = mirror.PushIndexToRegistry(signedIndex, indexName) - require.NoError(t, err) - - for _, platform := range platforms { - // can eval policy in the normal way - ref := indexName - if tc.useDigest { - options := oci.WithOptions(ctx, nil) - subjectRef, err := name.ParseReference(indexName) - require.NoError(t, err) - desc, err := remote.Index(subjectRef, options...) - require.NoError(t, err) - idxDigest, err := desc.Digest() - require.NoError(t, err) - ref = fmt.Sprintf("%s/repo@%s", u.Host, idxDigest.String()) + if tc.referrersServer != nil { + defer tc.referrersServer.Close() } - resolver, err := oci.NewRegistryAttestationResolver(ref, platform) + u, err := url.Parse(s.URL) require.NoError(t, err) - policyOpts := &policy.PolicyOptions{ - LocalPolicyDir: PassPolicyDir, + opts := &attestation.SigningOptions{ + Replace: true, + SkipSubject: tc.skipSubject, } - results, err := attest.Verify(ctx, policyOpts, resolver) + attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) require.NoError(t, err) - assert.Equal(t, attest.OutcomeSuccess, results.Outcome) - if !tc.skipSubject { - // can evaluate policy using referrers - if tc.useDigest { - p, err := oci.ParsePlatform(platform) + indexName := fmt.Sprintf("%s/repo:root", u.Host) + require.NoError(t, err) + + if tc.referrersServer != nil { + ru, err := url.Parse(s.URL) + require.NoError(t, err) + repo := fmt.Sprintf("%s/referrers", ru.Host) + tc.referrersRepo = repo + images, err := attest.SignedAttestationImages(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + err = mirror.PushIndexToRegistry(attIdx.Index, indexName) + for _, img := range images { + err = mirror.PushImageToRegistry(img.Image, fmt.Sprintf("%s:tag-does-not-matter", repo)) require.NoError(t, err) - options := oci.WithOptions(ctx, p) + } + } else { + signedIndex, err := attest.Sign(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + err = mirror.PushIndexToRegistry(signedIndex, indexName) + require.NoError(t, err) + } + + for _, platform := range platforms { + // can eval policy in the normal way + ref := indexName + if tc.useDigest { + options := oci.WithOptions(ctx, nil) subjectRef, err := name.ParseReference(indexName) require.NoError(t, err) - desc, err := remote.Image(subjectRef, options...) + desc, err := remote.Index(subjectRef, options...) require.NoError(t, err) - subjectDigest, err := desc.Digest() + idxDigest, err := desc.Digest() require.NoError(t, err) - ref = fmt.Sprintf("%s/repo@%s", u.Host, subjectDigest.String()) + ref = fmt.Sprintf("%s/repo@%s", u.Host, idxDigest.String()) } - referrersResolver, err := oci.NewReferrersAttestationResolver(ref, oci.WithPlatform(platform)) - require.NoError(t, err) - results, err = attest.Verify(ctx, policyOpts, referrersResolver) + policyOpts := &policy.PolicyOptions{ + LocalPolicyDir: LocalPolicy, + } + if tc.policyDir != "" { + policyOpts.LocalPolicyDir = tc.policyDir + } + + if tc.referrersRepo != "" { + policyOpts.ReferrersRepo = tc.referrersRepo + } + + if tc.attestationSource != "" { + policyOpts.AttestationStyle = tc.attestationSource + } + src, err := oci.ParseImageSpec(ref, oci.WithPlatform(platform)) + require.NoError(t, err) + results, err := attest.Verify(ctx, src, policyOpts) + if tc.expectFailure { + require.Error(t, err) + continue + } require.NoError(t, err) assert.Equal(t, attest.OutcomeSuccess, results.Outcome) + + if !tc.skipSubject { + // can evaluate policy using referrers + if tc.useDigest { + p, err := oci.ParsePlatform(platform) + require.NoError(t, err) + options := oci.WithOptions(ctx, p) + subjectRef, err := name.ParseReference(indexName) + require.NoError(t, err) + desc, err := remote.Image(subjectRef, options...) + require.NoError(t, err) + subjectDigest, err := desc.Digest() + require.NoError(t, err) + ref = fmt.Sprintf("%s/repo@%s", u.Host, subjectDigest.String()) + } + src, err := oci.ParseImageSpec(ref, oci.WithPlatform(platform)) + require.NoError(t, err) + results, err = attest.Verify(ctx, src, policyOpts) + require.NoError(t, err) + assert.Equal(t, attest.OutcomeSuccess, results.Outcome) + } } - } + }) } } @@ -173,14 +239,13 @@ func TestReferencesInDifferentRepo(t *testing.T) { continue } // can evaluate policy using referrers in a different repo - repo := fmt.Sprintf("%s/%s", refServerUrl.Host, repoName) referencedImage := fmt.Sprintf("%s@%s", indexName, mf.Digest.String()) - referrersResolver, err := oci.NewReferrersAttestationResolver(referencedImage, oci.WithReferrersRepo(repo)) - require.NoError(t, err) policyOpts := &policy.PolicyOptions{ LocalPolicyDir: PassPolicyDir, } - results, err := attest.Verify(ctx, policyOpts, referrersResolver) + 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 new file mode 100644 index 0000000..4b30b4c --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/docker/attest/pkg/tuf" + goyaml "gopkg.in/yaml.v3" +) + +const ( + MappingFilename = "mapping.yaml" +) + +func LoadLocalMappings(configDir string) (*PolicyMappings, error) { + if configDir == "" { + return nil, nil + } + mappings := &PolicyMappings{} + path := filepath.Join(configDir, MappingFilename) + mappingFile, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read local policy mapping file %s: %w", path, err) + } + err = goyaml.Unmarshal(mappingFile, mappings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal policy mapping file %s: %w", path, err) + } + return mappings, nil +} + +func LoadTufMappings(tufClient tuf.TUFClient, localTargetsDir string) (*PolicyMappings, error) { + if tufClient == nil { + return nil, fmt.Errorf("tuf client not set") + } + filename := MappingFilename + _, fileContents, err := tufClient.DownloadTarget(filename, filepath.Join(localTargetsDir, filename)) + if err != nil { + return nil, fmt.Errorf("failed to download policy mapping file %s: %w", filename, err) + } + mappings := &PolicyMappings{} + + 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 +} diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..87e9dc5 --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,48 @@ +package config + +type PolicyMappings struct { + Version string `json:"version"` + Kind string `json:"kind"` + Policies []*PolicyMapping `json:"policies"` + Mirrors []*PolicyMirror `json:"mirrors"` +} + +type AttestationStyle string + +const ( + AttestationStyleAttached AttestationStyle = "attached" + AttestationStyleReferrers AttestationStyle = "referrers" +) + +type PolicyMapping struct { + Id string `json:"id"` + Description string `json:"description"` + Origin *PolicyOrigin `json:"origin"` + Files []PolicyMappingFile `json:"files"` + Attestations *ReferrersConfig `json:"attestations"` +} + +type ReferrersConfig struct { + Style AttestationStyle `json:"style"` + Repo string `json:"repo"` +} + +type PolicyMappingFile struct { + Path string `json:"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"` +} diff --git a/pkg/mirror/mirror.go b/pkg/mirror/mirror.go index a821f20..e66749b 100644 --- a/pkg/mirror/mirror.go +++ b/pkg/mirror/mirror.go @@ -2,7 +2,6 @@ package mirror import ( "fmt" - "log" "os" "github.com/docker/attest/internal/embed" @@ -29,7 +28,7 @@ func NewTufMirror(root []byte, tufPath, metadataURL, targetsURL string, versionC func PushImageToRegistry(image v1.Image, imageName string) error { ref, err := name.ParseReference(imageName) if err != nil { - log.Fatalf("Failed to parse image name: %v", err) + return fmt.Errorf("Failed to parse image name '%s': %w", imageName, err) } // Push the image to the registry @@ -40,8 +39,9 @@ func PushIndexToRegistry(image v1.ImageIndex, imageName string) error { // Parse the index name ref, err := name.ParseReference(imageName) if err != nil { - log.Fatalf("Failed to parse image name: %v", err) + return fmt.Errorf("Failed to parse image name: %w", err) } + // Push the index to the registry return remote.WriteIndex(ref, image, oci.MultiKeychainOption()) } diff --git a/pkg/oci/layout.go b/pkg/oci/layout.go new file mode 100644 index 0000000..53dc93a --- /dev/null +++ b/pkg/oci/layout.go @@ -0,0 +1,151 @@ +package oci + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + att "github.com/docker/attest/pkg/attestation" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/pkg/errors" +) + +// implementation of AttestationResolver that closes over attestations from an oci layout +type OCILayoutResolver struct { + *AttestationManifest + *ImageSpec +} + +func NewOCILayoutAttestationResolver(src *ImageSpec) (*OCILayoutResolver, error) { + r := &OCILayoutResolver{ + ImageSpec: src, + } + _, err := r.fetchAttestationManifest() + if err != nil { + return nil, err + } + return r, nil +} + +func (r *OCILayoutResolver) fetchAttestationManifest() (*AttestationManifest, error) { + if r.AttestationManifest == nil { + m, err := attestationManifestFromOCILayout(r.Identifier, r.ImageSpec.Platform) + if err != nil { + return nil, err + } + r.AttestationManifest = m + } + + return r.AttestationManifest, nil +} + +func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) { + attestationImage := r.AttestationManifest.Image + layers, err := attestationImage.Layers() + if err != nil { + return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err) + } + var envs []*att.Envelope + manifest := r.AttestationManifest.Manifest + for i, l := range manifest.Layers { + if l.Annotations[InTotoPredicateType] != predicateType { + continue + } + layer := layers[i] + mt, err := layer.MediaType() + if err != nil { + return nil, fmt.Errorf("failed to get layer media type: %w", err) + } + mts := string(mt) + if !strings.HasSuffix(mts, "+dsse") { + continue + } + var env = new(att.Envelope) + // parse layer blob as json + r, err := layer.Uncompressed() + + if err != nil { + return nil, fmt.Errorf("failed to get layer contents: %w", err) + } + defer r.Close() + err = json.NewDecoder(r).Decode(env) + if err != nil { + return nil, fmt.Errorf("failed to decode envelope: %w", err) + } + envs = append(envs, env) + } + return envs, nil +} + +func (r *OCILayoutResolver) ImageName(ctx context.Context) (string, error) { + return r.Name, nil +} + +func (r *OCILayoutResolver) ImageDigest(ctx context.Context) (string, error) { + return r.Digest, nil +} + +func (r *OCILayoutResolver) ImagePlatform(ctx context.Context) (*v1.Platform, error) { + return r.ImageSpec.Platform, nil +} + +func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*AttestationManifest, error) { + idx, err := layout.ImageIndexFromPath(path) + if err != nil { + return nil, err + } + + idxm, err := idx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to get digest: %w", err) + } + + idxDescriptor := idxm.Manifests[0] + name := idxDescriptor.Annotations["org.opencontainers.image.ref.name"] + idxDigest := idxDescriptor.Digest + + mfs, err := idx.ImageIndex(idxDigest) + if err != nil { + return nil, fmt.Errorf("failed to extract ImageIndex for digest %s: %w", idxDigest.String(), err) + } + mfs2, err := mfs.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err) + } + var imageDigest string + for _, mf := range mfs2.Manifests { + if mf.Platform.Equals(*platform) { + imageDigest = mf.Digest.String() + } + } + for _, mf := range mfs2.Manifests { + if mf.Annotations[att.DockerReferenceType] != AttestationManifestType { + continue + } + + if mf.Annotations[att.DockerReferenceDigest] != imageDigest { + continue + } + + attestationImage, err := mfs.Image(mf.Digest) + if err != nil { + return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err) + } + manifest, err := attestationImage.Manifest() + if err != nil { + return nil, fmt.Errorf("failed to get manifest: %w", err) + } + attest := &AttestationManifest{ + Name: name, + Image: attestationImage, + Manifest: manifest, + Descriptor: &mf, + Digest: imageDigest, + Platform: platform, + } + return attest, nil + } + return nil, errors.New("attestation manifest not found") +} diff --git a/pkg/oci/oci.go b/pkg/oci/oci.go index a1b72fa..e6480b4 100644 --- a/pkg/oci/oci.go +++ b/pkg/oci/oci.go @@ -9,9 +9,7 @@ import ( "github.com/containerd/containerd/platforms" "github.com/distribution/reference" att "github.com/docker/attest/pkg/attestation" - "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -37,425 +35,6 @@ func ParsePlatform(platformStr string) (*v1.Platform, error) { } } -func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*AttestationManifest, error) { - idx, err := layout.ImageIndexFromPath(path) - if err != nil { - return nil, fmt.Errorf("failed to load image index: %w", err) - } - - idxm, err := idx.IndexManifest() - if err != nil { - return nil, fmt.Errorf("failed to get digest: %w", err) - } - - idxDescriptor := idxm.Manifests[0] - name := idxDescriptor.Annotations["org.opencontainers.image.ref.name"] - idxDigest := idxDescriptor.Digest - - mfs, err := idx.ImageIndex(idxDigest) - if err != nil { - return nil, fmt.Errorf("failed to extract ImageIndex for digest %s: %w", idxDigest.String(), err) - } - mfs2, err := mfs.IndexManifest() - if err != nil { - return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err) - } - var imageDigest string - for _, mf := range mfs2.Manifests { - if mf.Platform.Equals(*platform) { - imageDigest = mf.Digest.String() - } - } - for _, mf := range mfs2.Manifests { - if mf.Annotations[att.DockerReferenceType] != AttestationManifestType { - continue - } - - if mf.Annotations[att.DockerReferenceDigest] != imageDigest { - continue - } - - attestationImage, err := mfs.Image(mf.Digest) - if err != nil { - return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err) - } - manifest, err := attestationImage.Manifest() - if err != nil { - return nil, fmt.Errorf("failed to get manifest: %w", err) - } - attest := &AttestationManifest{ - Name: name, - Image: attestationImage, - Manifest: manifest, - Descriptor: &mf, - Digest: imageDigest, - Platform: platform, - } - return attest, nil - } - return nil, errors.New("attestation manifest not found") - -} - -// implementation of AttestationResolver that closes over attestations from an oci layout -type OCILayoutResolver struct { - path string - platform *v1.Platform - *AttestationManifest -} - -func NewOCILayoutAttestationResolver(path string, platform string) (*OCILayoutResolver, error) { - p, err := ParsePlatform(platform) - if err != nil { - return nil, err - } - return &OCILayoutResolver{ - path: path, - platform: p, - }, nil -} - -func (r *OCILayoutResolver) ImagePlatform() (*v1.Platform, error) { - return r.platform, nil -} -func (r *OCILayoutResolver) fetchAttestationManifest() (*AttestationManifest, error) { - if r.AttestationManifest == nil { - m, err := attestationManifestFromOCILayout(r.path, r.platform) - if err != nil { - return nil, fmt.Errorf("failed to get attestation manifest: %w", err) - } - r.AttestationManifest = m - } - return r.AttestationManifest, nil -} - -func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) { - if r.AttestationManifest == nil { - _, err := r.fetchAttestationManifest() - if err != nil { - return nil, fmt.Errorf("failed to get attestation manifest: %w", err) - } - } - attestationImage := r.AttestationManifest.Image - layers, err := attestationImage.Layers() - if err != nil { - return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err) - } - var envs []*att.Envelope - manifest := r.AttestationManifest.Manifest - for i, l := range manifest.Layers { - if l.Annotations[InTotoPredicateType] != predicateType { - continue - } - layer := layers[i] - mt, err := layer.MediaType() - if err != nil { - return nil, fmt.Errorf("failed to get layer media type: %w", err) - } - mts := string(mt) - if !strings.HasSuffix(mts, "+dsse") { - continue - } - var env = new(att.Envelope) - // parse layer blob as json - r, err := layer.Uncompressed() - - if err != nil { - return nil, fmt.Errorf("failed to get layer contents: %w", err) - } - defer r.Close() - err = json.NewDecoder(r).Decode(env) - if err != nil { - return nil, fmt.Errorf("failed to decode envelope: %w", err) - } - envs = append(envs, env) - } - return envs, nil -} - -func (r *OCILayoutResolver) ImageName(ctx context.Context) (string, error) { - if r.AttestationManifest == nil { - _, err := r.fetchAttestationManifest() - if err != nil { - return "", fmt.Errorf("failed to get attestation manifest: %w", err) - } - } - - return r.Name, nil -} - -func (r *OCILayoutResolver) ImageDigest(ctx context.Context) (string, error) { - if r.AttestationManifest == nil { - _, err := r.fetchAttestationManifest() - if err != nil { - return "", fmt.Errorf("failed to get attestation manifest: %w", err) - } - } - return r.Digest, nil -} - -type ReferrersResolver struct { - image string - platform *v1.Platform - digest string - referrersRepo string - manifests []*AttestationManifest -} - -func NewReferrersAttestationResolver(image string, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) { - res := &ReferrersResolver{ - image: image, - } - for _, opt := range options { - err := opt(res) - if err != nil { - return nil, err - } - } - return res, nil -} - -func WithReferrersRepo(repo string) func(*ReferrersResolver) error { - return func(r *ReferrersResolver) error { - r.referrersRepo = repo - return nil - } -} - -// WithPlatform sets the platform for the resolver (needed for platform specific tag resolution) -func WithPlatform(platform string) func(*ReferrersResolver) error { - return func(r *ReferrersResolver) error { - p, err := ParsePlatform(platform) - if err != nil { - return err - } - r.platform = p - return nil - } -} - -func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error { - if r.manifests == nil { - subjectRef, err := name.ParseReference(r.image) - if err != nil { - return fmt.Errorf("failed to parse reference: %w", err) - } - subjectDigest, err := r.ImageDigest(ctx) - if err != nil { - return fmt.Errorf("failed to get image digest: %w", err) - } - - var referrersSubjectRef name.Digest - if r.referrersRepo != "" { - referrersSubjectRef, err = name.NewDigest(fmt.Sprintf("%s@%s", r.referrersRepo, subjectDigest)) - if err != nil { - return fmt.Errorf("failed to create referrers reference: %w", err) - } - } else { - referrersSubjectRef = subjectRef.Context().Digest(subjectDigest) - } - referrersIndex, err := remote.Referrers(referrersSubjectRef) - if err != nil { - return fmt.Errorf("failed to get referrers: %w", err) - } - referrersIndexManifest, err := referrersIndex.IndexManifest() - if err != nil { - return fmt.Errorf("failed to get index manifest: %w", err) - } - if len(referrersIndexManifest.Manifests) == 0 { - return errors.New("no referrers found") - } - aManifests := make([]*AttestationManifest, 0) - for _, m := range referrersIndexManifest.Manifests { - - remoteRef := referrersSubjectRef.Context().Digest(m.Digest.String()) - attestationImage, err := remote.Image(remoteRef) - if err != nil { - return fmt.Errorf("failed to get referred image: %w", err) - } - manifest, err := attestationImage.Manifest() - if err != nil { - return fmt.Errorf("failed to get manifest: %w", err) - } - if manifest.Annotations[att.DockerReferenceType] != AttestationManifestType { - continue - } - if manifest.Annotations[att.DockerReferenceDigest] != subjectDigest { - continue - } - attest := &AttestationManifest{ - Name: r.image, - Image: attestationImage, - Manifest: manifest, - Descriptor: &m, - Digest: subjectDigest, - Platform: r.platform, - } - aManifests = append(aManifests, attest) - } - - if len(aManifests) == 0 { - return errors.New("no attestation manifests found") - } - r.manifests = aManifests - } - return nil -} - -func (r *ReferrersResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) { - err := r.resolveAttestations(ctx) - if err != nil { - return nil, fmt.Errorf("failed to resolve attestations: %w", err) - } - var envs []*att.Envelope - for _, attest := range r.manifests { - es, err := ExtractEnvelopes(attest, predicateType) - if err != nil { - return nil, fmt.Errorf("failed to extract envelopes: %w", err) - } - envs = append(envs, es...) - } - return envs, nil -} - -func (r *ReferrersResolver) ImageName(ctx context.Context) (string, error) { - return r.image, nil -} - -func (r *ReferrersResolver) ImagePlatform() (*v1.Platform, error) { - return r.platform, nil -} - -func (r *ReferrersResolver) ImageDigest(ctx context.Context) (string, error) { - if r.digest == "" { - subjectRef, err := name.ParseReference(r.image) - if err != nil { - return "", fmt.Errorf("failed to parse reference: %w", err) - } - switch t := subjectRef.(type) { - case name.Digest: - r.digest = t.DigestStr() - case name.Tag: - options := WithOptions(ctx, r.platform) - desc, err := remote.Image(t, options...) - if err != nil { - return "", fmt.Errorf("failed to get image manifest: %w", err) - } - subjectDigest, err := desc.Digest() - if err != nil { - return "", fmt.Errorf("failed to get image digest: %w", err) - } - r.digest = subjectDigest.String() - default: - return "", fmt.Errorf("unsupported reference type: %T", t) - } - } - return r.digest, nil -} - -type RegistryResolver struct { - image string - platform *v1.Platform - *AttestationManifest -} - -func NewRegistryAttestationResolver(image string, platform string) (*RegistryResolver, error) { - p, err := ParsePlatform(platform) - if err != nil { - return nil, err - } - return &RegistryResolver{ - image: image, - platform: p, - }, nil -} - -func (r *RegistryResolver) ImageName(ctx context.Context) (string, error) { - return r.image, nil -} - -func (r *RegistryResolver) ImagePlatform() (*v1.Platform, error) { - return r.platform, nil -} - -func (r *RegistryResolver) ImageDigest(ctx context.Context) (string, error) { - if r.AttestationManifest == nil { - attest, err := FetchAttestationManifest(ctx, r.image, r.platform) - if err != nil { - return "", fmt.Errorf("failed to get attestation manifest: %w", err) - } - r.AttestationManifest = attest - } - return r.Digest, nil -} - -func (r *RegistryResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) { - if r.AttestationManifest == nil { - attest, err := FetchAttestationManifest(ctx, r.image, r.platform) - if err != nil { - return nil, fmt.Errorf("failed to get attestation manifest: %w", err) - } - r.AttestationManifest = attest - } - return ExtractEnvelopes(r.AttestationManifest, predicateType) -} - -func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Platform) (*AttestationManifest, error) { - // we want to get to the image index, so ignoring platform for now - options := WithOptions(ctx, nil) - ref, err := name.ParseReference(image) - if err != nil { - return nil, fmt.Errorf("failed to parse reference: %w", err) - } - index, err := remote.Index(ref, options...) - if err != nil { - return nil, fmt.Errorf("failed to get index: %w", err) - } - indexManifest, err := index.IndexManifest() - if err != nil { - return nil, fmt.Errorf("failed to get index manifest: %w", err) - } - digest, err := imageDigestForPlatform(indexManifest, platform) - if err != nil { - return nil, fmt.Errorf("failed to obtain image for platform: %w", err) - } - ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), digest)) - if err != nil { - return nil, fmt.Errorf("failed to parse attestation reference: %w", err) - } - - attestationDigest, err := attestationDigestForDigest(indexManifest, digest, "attestation-manifest") - if err != nil { - return nil, fmt.Errorf("failed to obtain attestation for image: %w", err) - } - ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), attestationDigest)) - if err != nil { - return nil, fmt.Errorf("failed to parse attestation reference: %w", err) - } - remoteDescriptor, err := remote.Get(ref, options...) - if err != nil { - return nil, fmt.Errorf("failed to get attestation: %w", err) - } - manifest := new(v1.Manifest) - err = json.Unmarshal(remoteDescriptor.Manifest, manifest) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal attestation: %w", err) - } - attestationImage, err := remoteDescriptor.Image() - if err != nil { - return nil, fmt.Errorf("failed to get attestation image: %w", err) - } - attest := &AttestationManifest{ - Name: image, - Image: attestationImage, - Manifest: manifest, - Descriptor: &remoteDescriptor.Descriptor, - Digest: digest, - Platform: platform, - } - return attest, nil -} - func WithOptions(ctx context.Context, platform *v1.Platform) []remote.Option { // prepare options options := []remote.Option{MultiKeychainOption(), remote.WithTransport(HttpTransport()), remote.WithContext(ctx)} @@ -469,11 +48,9 @@ func WithOptions(ctx context.Context, platform *v1.Platform) []remote.Option { func ExtractEnvelopes(ia *AttestationManifest, predicateType string) ([]*att.Envelope, error) { manifest := ia.Manifest - im := ia.Image - + image := ia.Image var envs []*att.Envelope - - ls, err := im.Layers() + layers, err := image.Layers() if err != nil { return nil, fmt.Errorf("failed to get layers: %w", err) } @@ -481,7 +58,7 @@ func ExtractEnvelopes(ia *AttestationManifest, predicateType string) ([]*att.Env if (strings.HasPrefix(string(l.MediaType), "application/vnd.in-toto.")) && strings.HasSuffix(string(l.MediaType), "+dsse") && l.Annotations[InTotoPredicateType] == predicateType { - reader, err := ls[i].Uncompressed() + reader, err := layers[i].Uncompressed() if err != nil { return nil, fmt.Errorf("failed to get layer contents: %w", err) } diff --git a/pkg/oci/oci_test.go b/pkg/oci/oci_test.go index 953979f..eaff934 100644 --- a/pkg/oci/oci_test.go +++ b/pkg/oci/oci_test.go @@ -85,3 +85,24 @@ func TestImageDigestForPlatform(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "sha256:7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e", digest) } + +func TestWithoutTag(t *testing.T) { + tc := []struct { + name string + expected string + }{ + {name: "image:tag", expected: "index.docker.io/library/image"}, + {name: "image", expected: "index.docker.io/library/image"}, + {name: "image:sha256-digest.att", expected: "index.docker.io/library/image"}, + {name: "docker://image:tag", expected: "docker://index.docker.io/library/image"}, + {name: "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: "index.docker.io/library/image"}, + {name: "docker://image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: "docker://index.docker.io/library/image"}, + {name: "docker://127.0.0.1:36555/repo:latest", expected: "docker://127.0.0.1:36555/repo"}, + } + for _, c := range tc { + t.Run(c.name, func(t *testing.T) { + notag, _ := WithoutTag(c.name) + assert.Equal(t, c.expected, notag) + }) + } +} diff --git a/pkg/oci/referrers.go b/pkg/oci/referrers.go new file mode 100644 index 0000000..6629b6d --- /dev/null +++ b/pkg/oci/referrers.go @@ -0,0 +1,121 @@ +package oci + +import ( + "context" + "fmt" + + att "github.com/docker/attest/pkg/attestation" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/pkg/errors" +) + +type ReferrersResolver struct { + digest string + referrersRepo string + manifests []*AttestationManifest + *RegistryImageDetailsResolver +} + +func NewReferrersAttestationResolver(src *RegistryImageDetailsResolver, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) { + res := &ReferrersResolver{ + RegistryImageDetailsResolver: src, + } + for _, opt := range options { + err := opt(res) + if err != nil { + return nil, err + } + } + return res, nil +} + +func WithReferrersRepo(repo string) func(*ReferrersResolver) error { + return func(r *ReferrersResolver) error { + r.referrersRepo = repo + return nil + } +} + +func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error { + if r.manifests == nil { + subjectRef, err := name.ParseReference(r.Identifier) + if err != nil { + return fmt.Errorf("failed to parse reference: %w", err) + } + subjectDigest, err := r.ImageDigest(ctx) + if err != nil { + return fmt.Errorf("failed to get digest: %w", err) + } + var referrersSubjectRef name.Digest + if r.referrersRepo != "" { + referrersSubjectRef, err = name.NewDigest(fmt.Sprintf("%s@%s", r.referrersRepo, subjectDigest)) + if err != nil { + return fmt.Errorf("failed to create referrers reference: %w", err) + } + } else { + referrersSubjectRef = subjectRef.Context().Digest(subjectDigest) + } + referrersIndex, err := remote.Referrers(referrersSubjectRef) + if err != nil { + return fmt.Errorf("failed to get referrers: %w", err) + } + referrersIndexManifest, err := referrersIndex.IndexManifest() + if err != nil { + return fmt.Errorf("failed to get index manifest: %w", err) + } + if len(referrersIndexManifest.Manifests) == 0 { + return errors.New("no referrers found") + } + aManifests := make([]*AttestationManifest, 0) + for _, m := range referrersIndexManifest.Manifests { + + remoteRef := referrersSubjectRef.Context().Digest(m.Digest.String()) + attestationImage, err := remote.Image(remoteRef) + if err != nil { + return fmt.Errorf("failed to get referred image: %w", err) + } + manifest, err := attestationImage.Manifest() + if err != nil { + return fmt.Errorf("failed to get manifest: %w", err) + } + if manifest.Annotations[att.DockerReferenceType] != AttestationManifestType { + continue + } + if manifest.Annotations[att.DockerReferenceDigest] != subjectDigest { + continue + } + attest := &AttestationManifest{ + Name: r.Identifier, + Image: attestationImage, + Manifest: manifest, + Descriptor: &m, + Digest: subjectDigest, + Platform: r.Platform, + } + aManifests = append(aManifests, attest) + } + + if len(aManifests) == 0 { + return errors.New("no attestation manifests found") + } + r.manifests = aManifests + } + return nil +} + +func (r *ReferrersResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) { + err := r.resolveAttestations(ctx) + if err != nil { + return nil, fmt.Errorf("failed to resolve attestations: %w", err) + } + var envs []*att.Envelope + for _, attest := range r.manifests { + es, err := ExtractEnvelopes(attest, predicateType) + if err != nil { + return nil, fmt.Errorf("failed to extract envelopes: %w", err) + } + envs = append(envs, es...) + } + return envs, nil +} diff --git a/pkg/oci/registry.go b/pkg/oci/registry.go new file mode 100644 index 0000000..ab0f6a5 --- /dev/null +++ b/pkg/oci/registry.go @@ -0,0 +1,129 @@ +package oci + +import ( + "context" + "encoding/json" + "fmt" + + att "github.com/docker/attest/pkg/attestation" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +type RegistryResolver struct { + *RegistryImageDetailsResolver + *AttestationManifest +} + +type RegistryImageDetailsResolver struct { + *ImageSpec + digest string +} + +func NewRegistryImageDetailsResolver(src *ImageSpec) (*RegistryImageDetailsResolver, error) { + return &RegistryImageDetailsResolver{ + ImageSpec: src, + }, nil +} + +func NewRegistryAttestationResolver(src *RegistryImageDetailsResolver) (*RegistryResolver, error) { + return &RegistryResolver{ + RegistryImageDetailsResolver: src, + }, nil +} + +func (r *RegistryImageDetailsResolver) ImageName(ctx context.Context) (string, error) { + return r.Identifier, nil +} + +func (r *RegistryImageDetailsResolver) ImagePlatform(ctx context.Context) (*v1.Platform, error) { + return r.Platform, nil +} + +func (r *RegistryImageDetailsResolver) ImageDigest(ctx context.Context) (string, error) { + if r.digest == "" { + subjectRef, err := name.ParseReference(r.Identifier) + if err != nil { + return "", fmt.Errorf("failed to parse reference: %w", err) + } + options := WithOptions(ctx, r.Platform) + desc, err := remote.Image(subjectRef, options...) + if err != nil { + return "", fmt.Errorf("failed to get image manifest: %w", err) + } + subjectDigest, err := desc.Digest() + if err != nil { + return "", fmt.Errorf("failed to get image digest: %w", err) + } + r.digest = subjectDigest.String() + } + return r.digest, nil +} + +func (r *RegistryResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) { + if r.AttestationManifest == nil { + attest, err := FetchAttestationManifest(ctx, r.Identifier, r.ImageSpec.Platform) + if err != nil { + return nil, err + } + r.AttestationManifest = attest + } + return ExtractEnvelopes(r.AttestationManifest, predicateType) +} + +func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Platform) (*AttestationManifest, error) { + // we want to get to the image index, so ignoring platform for now + options := WithOptions(ctx, nil) + ref, err := name.ParseReference(image) + if err != nil { + return nil, fmt.Errorf("failed to parse reference: %w", err) + } + index, err := remote.Index(ref, options...) + if err != nil { + return nil, fmt.Errorf("failed to get index: %w", err) + } + indexManifest, err := index.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to get index manifest: %w", err) + } + digest, err := imageDigestForPlatform(indexManifest, platform) + if err != nil { + return nil, fmt.Errorf("failed to obtain image for platform: %w", err) + } + ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), digest)) + if err != nil { + return nil, fmt.Errorf("failed to parse attestation reference: %w", err) + } + + attestationDigest, err := attestationDigestForDigest(indexManifest, digest, "attestation-manifest") + if err != nil { + return nil, fmt.Errorf("failed to obtain attestation for image: %w", err) + } + ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), attestationDigest)) + if err != nil { + return nil, fmt.Errorf("failed to parse attestation reference: %w", err) + } + remoteDescriptor, err := remote.Get(ref, options...) + if err != nil { + return nil, fmt.Errorf("failed to get attestation: %w", err) + } + manifest := new(v1.Manifest) + err = json.Unmarshal(remoteDescriptor.Manifest, manifest) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal attestation: %w", err) + } + attestationImage, err := remoteDescriptor.Image() + if err != nil { + return nil, fmt.Errorf("failed to get attestation image: %w", err) + } + attest := &AttestationManifest{ + Name: image, + Image: attestationImage, + Manifest: manifest, + Descriptor: &remoteDescriptor.Descriptor, + Digest: digest, + Platform: platform, + } + return attest, nil +} diff --git a/pkg/oci/registry_test.go b/pkg/oci/registry_test.go new file mode 100644 index 0000000..c33e0c1 --- /dev/null +++ b/pkg/oci/registry_test.go @@ -0,0 +1,50 @@ +package oci_test + +import ( + "fmt" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/docker/attest/internal/test" + "github.com/docker/attest/pkg/attest" + "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/mirror" + "github.com/docker/attest/pkg/oci" + "github.com/docker/attest/pkg/policy" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistry(t *testing.T) { + ctx, signer := test.Setup(t) + server := httptest.NewServer(registry.New(registry.WithReferrersSupport(false))) + defer server.Close() + u, err := url.Parse(server.URL) + require.NoError(t, err) + + opts := &attestation.SigningOptions{ + Replace: true, + SkipSubject: true, + } + attIdx, err := oci.SubjectIndexFromPath(oci.UnsignedTestImage) + require.NoError(t, err) + signedIndex, err := attest.Sign(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + + indexName := fmt.Sprintf("%s/repo:root", u.Host) + require.NoError(t, err) + err = mirror.PushIndexToRegistry(signedIndex, indexName) + require.NoError(t, err) + + spec, err := oci.ParseImageSpec(indexName) + require.NoError(t, err) + + resolver, err := policy.CreateImageDetailsResolver(spec) + require.NoError(t, err) + digest, err := resolver.ImageDigest(ctx) + require.NoError(t, err) + assert.True(t, strings.Contains(digest, "sha256:")) +} diff --git a/pkg/oci/resolver.go b/pkg/oci/resolver.go index 754c769..c6628cb 100644 --- a/pkg/oci/resolver.go +++ b/pkg/oci/resolver.go @@ -23,12 +23,16 @@ type AttestationManifest struct { } type AttestationResolver interface { - ImageName(ctx context.Context) (string, error) - ImagePlatform() (*v1.Platform, error) - ImageDigest(ctx context.Context) (string, error) + ImageDetailsResolver Attestations(ctx context.Context, mediaType string) ([]*att.Envelope, error) } +type ImageDetailsResolver interface { + ImageName(ctx context.Context) (string, error) + ImagePlatform(ctx context.Context) (*v1.Platform, error) + ImageDigest(ctx context.Context) (string, error) +} + type MockResolver struct { Envs []*att.Envelope } @@ -45,6 +49,6 @@ func (r MockResolver) ImageDigest(ctx context.Context) (string, error) { return "sha256:test-digest", nil } -func (r MockResolver) ImagePlatform() (*v1.Platform, error) { +func (r MockResolver) ImagePlatform(ctx context.Context) (*v1.Platform, error) { return ParsePlatform("linux/amd64") } diff --git a/pkg/oci/types.go b/pkg/oci/types.go index af7407a..e9dfe29 100644 --- a/pkg/oci/types.go +++ b/pkg/oci/types.go @@ -2,7 +2,7 @@ package oci import ( "fmt" - "log" + "strings" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -11,16 +11,37 @@ import ( ) const ( - AttestationManifestType = "attestation-manifest" - InTotoPredicateType = "in-toto.io/predicate-type" - OciReferenceTarget = "org.opencontainers.image.ref.name" + AttestationManifestType = "attestation-manifest" + InTotoPredicateType = "in-toto.io/predicate-type" + OciReferenceTarget = "org.opencontainers.image.ref.name" + LocalPrefix = "oci://" + RegistryPrefix = "docker://" + OCI SourceType = "OCI" + Docker SourceType = "Docker" ) +type SourceType string type SubjectIndex struct { Index v1.ImageIndex Name string } +type AttestationOptions struct { + NoReferrers bool + Attach bool + ReferrersRepo string +} + +type ImageSpecOption func(*ImageSpec) error + +type ImageSpec struct { + // OCI or Docker + Type SourceType + // without oci:// or docker:// (name or path) + Identifier string + Platform *v1.Platform +} + func SubjectIndexFromPath(path string) (*SubjectIndex, error) { wrapperIdx, err := layout.ImageIndexFromPath(path) if err != nil { @@ -47,8 +68,9 @@ func SubjectIndexFromPath(path string) (*SubjectIndex, error) { func SubjectIndexFromRemote(image string) (*SubjectIndex, error) { ref, err := name.ParseReference(image) if err != nil { - log.Fatalf("Failed to parse image name: %v", err) + return nil, fmt.Errorf("failed to parse image reference %s: %w", image, err) } + // Pull the image from the registry idx, err := remote.Index(ref, MultiKeychainOption()) if err != nil { @@ -59,3 +81,103 @@ func SubjectIndexFromRemote(image string) (*SubjectIndex, error) { Name: image, }, nil } + +func LoadSubjectIndex(input *ImageSpec) (*SubjectIndex, error) { + if input.Type == OCI { + return SubjectIndexFromPath(input.Identifier) + } else { + return SubjectIndexFromRemote(input.Identifier) + } +} + +func (i *ImageSpec) ForPlatforms(platform string) ([]*ImageSpec, error) { + platforms := strings.Split(platform, ",") + var specs []*ImageSpec + for _, pStr := range platforms { + p, err := ParsePlatform(pStr) + if err != nil { + return nil, err + } + spec := &ImageSpec{ + Type: i.Type, + Identifier: i.Identifier, + Platform: p, + } + specs = append(specs, spec) + } + return specs, nil +} + +func ParseImageSpec(img string, options ...ImageSpecOption) (*ImageSpec, error) { + img = strings.TrimSpace(img) + if strings.Contains(img, ",") { + return nil, fmt.Errorf("only one image is supported") + } + withoutPrefix := strings.TrimPrefix(strings.TrimPrefix(img, LocalPrefix), RegistryPrefix) + src := &ImageSpec{ + Identifier: withoutPrefix, + } + if strings.HasPrefix(img, LocalPrefix) { + src.Type = OCI + } else { + src.Type = Docker + } + for _, option := range options { + err := option(src) + if err != nil { + return nil, err + } + } + if src.Platform == nil { + platform, err := ParsePlatform("") + if err != nil { + return nil, err + } + src.Platform = platform + } + return src, nil +} + +func WithPlatform(platform string) ImageSpecOption { + return func(i *ImageSpec) error { + if strings.Contains(platform, ",") { + return fmt.Errorf("only one platform is supported") + } + p, err := ParsePlatform(platform) + if err != nil { + return err + } + i.Platform = p + return nil + } +} + +func ParseImageSpecs(img string) ([]*ImageSpec, error) { + outputs := strings.Split(img, ",") + var sources []*ImageSpec + for _, output := range outputs { + src, err := ParseImageSpec(output) + if err != nil { + return nil, err + } + sources = append(sources, src) + } + return sources, nil +} + +func WithoutTag(image string) (string, error) { + if strings.HasPrefix(image, LocalPrefix) { + return image, nil + } + prefix := "" + if strings.HasPrefix(image, RegistryPrefix) { + image = strings.TrimPrefix(image, RegistryPrefix) + prefix = RegistryPrefix + } + ref, err := name.ParseReference(image) + if err != nil { + return "", err + } + repo := ref.Context().Name() + return prefix + repo, nil +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index fa7ae91..184cfcf 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -10,95 +10,11 @@ import ( "strings" "github.com/distribution/reference" + "github.com/docker/attest/pkg/config" "github.com/docker/attest/pkg/oci" - "github.com/docker/attest/pkg/tuf" - intoto "github.com/in-toto/in-toto-golang/in_toto" - - goyaml "gopkg.in/yaml.v3" ) -const ( - PolicyMappingFileName = "mapping.yaml" -) - -type Summary struct { - Subjects []intoto.Subject `json:"subjects"` - SLSALevels []string `json:"slsa_levels"` - Verifier string `json:"verifier"` - PolicyURI string `json:"policy_uri"` -} - -type Violation struct { - Type string `json:"type"` - Description string `json:"description"` - Attestation *intoto.Statement `json:"attestation"` - Details map[string]any `json:"details"` -} - -type Result struct { - Success bool `json:"success"` - Violations []Violation `json:"violations"` - Summary Summary `json:"summary"` -} - -type PolicyMappings struct { - Version string `json:"version"` - Kind string `json:"kind"` - Policies []PolicyMapping `json:"policies"` - Mirrors []PolicyMirror `json:"mirrors"` -} - -type PolicyMapping struct { - Id string `json:"id"` - Description string `json:"description"` - Origin PolicyOrigin `json:"origin"` - Files []PolicyMappingFile `json:"files"` -} - -type PolicyMappingFile struct { - Path string `json:"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 PolicyOptions struct { - TufClient tuf.TUFClient - LocalTargetsDir string - LocalPolicyDir string - PolicyId string -} - -type Policy struct { - InputFiles []*PolicyFile - Query string -} - -type PolicyInput struct { - Digest string `json:"digest"` - Purl string `json:"purl"` - IsCanonical bool `json:"isCanonical"` -} - -type PolicyFile struct { - Path string - Content []byte -} - -func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) (*Policy, error) { +func resolveLocalPolicy(opts *PolicyOptions, mapping *config.PolicyMapping) (*Policy, error) { if opts.LocalPolicyDir == "" { return nil, fmt.Errorf("local policy dir not set") } @@ -117,28 +33,12 @@ func resolveLocalPolicy(opts *PolicyOptions, mapping *PolicyMapping) (*Policy, e } policy := &Policy{ InputFiles: files, + Mapping: mapping, } return policy, nil } -func LoadLocalMappings(opts *PolicyOptions) (*PolicyMappings, error) { - if opts.LocalPolicyDir == "" { - return nil, nil - } - mappings := &PolicyMappings{} - path := path.Join(opts.LocalPolicyDir, PolicyMappingFileName) - mappingFile, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read policy mapping file %s: %w", path, err) - } - err = goyaml.Unmarshal(mappingFile, mappings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal policy mapping file %s: %w", path, err) - } - return mappings, nil -} - -func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) (*Policy, error) { +func resolveTufPolicy(opts *PolicyOptions, mapping *config.PolicyMapping) (*Policy, error) { files := make([]*PolicyFile, 0, len(mapping.Files)) for _, f := range mapping.Files { filename := f.Path @@ -153,34 +53,17 @@ func resolveTufPolicy(opts *PolicyOptions, mapping *PolicyMapping) (*Policy, err } policy := &Policy{ InputFiles: files, + Mapping: mapping, } return policy, nil } -func loadTufMappings(tufClient tuf.TUFClient, localTargetsDir string) (*PolicyMappings, error) { - if tufClient == nil { - return nil, fmt.Errorf("tuf client not set") - } - filename := PolicyMappingFileName - _, fileContents, err := tufClient.DownloadTarget(filename, filepath.Join(localTargetsDir, filename)) - if err != nil { - return nil, fmt.Errorf("failed to download policy file %s: %w", filename, err) - } - mappings := &PolicyMappings{} - - 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 -} - -func findPolicyMatch(named reference.Named, mappings *PolicyMappings) (*PolicyMapping, *PolicyMirror) { +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 + return mapping, nil } } // now search mirrors @@ -190,10 +73,10 @@ func findPolicyMatch(named reference.Named, mappings *PolicyMappings) (*PolicyMa strings.HasPrefix(reference.Path(named), mirror.Mirror.Prefix) { for _, mapping := range mappings.Policies { if mapping.Id == mirror.PolicyId { - return &mapping, nil + return mapping, nil } } - return nil, &mirror + return nil, mirror } } } @@ -202,26 +85,26 @@ func findPolicyMatch(named reference.Named, mappings *PolicyMappings) (*PolicyMa func resolvePolicyById(opts *PolicyOptions) (*Policy, error) { if opts.PolicyId != "" { - localMappings, err := LoadLocalMappings(opts) + localMappings, err := config.LoadLocalMappings(opts.LocalPolicyDir) if err != nil { 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) + return resolveLocalPolicy(opts, mapping) } } } // must check tuf - tufMappings, err := loadTufMappings(opts.TufClient, opts.LocalTargetsDir) + tufMappings, err := config.LoadTufMappings(opts.TufClient, opts.LocalTargetsDir) if err != nil { - return nil, fmt.Errorf("failed to load tuf policy mappings: %w", err) + 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) + return resolveTufPolicy(opts, mapping) } } return nil, fmt.Errorf("policy with id %s not found", opts.PolicyId) @@ -229,7 +112,7 @@ func resolvePolicyById(opts *PolicyOptions) (*Policy, error) { return nil, nil } -func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts *PolicyOptions) (*Policy, error) { +func ResolvePolicy(ctx context.Context, detailsResolver oci.ImageDetailsResolver, opts *PolicyOptions) (*Policy, error) { p, err := resolvePolicyById(opts) if err != nil { return nil, fmt.Errorf("failed to resolve policy by id: %w", err) @@ -237,8 +120,7 @@ func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts * if p != nil { return p, nil } - - imageName, err := resolver.ImageName(ctx) + imageName, err := detailsResolver.ImageName(ctx) if err != nil { return nil, fmt.Errorf("failed to get image name: %w", err) } @@ -246,7 +128,7 @@ func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts * if err != nil { return nil, fmt.Errorf("failed to parse image name: %w", err) } - localMappings, err := LoadLocalMappings(opts) + localMappings, err := config.LoadLocalMappings(opts.LocalPolicyDir) if err != nil { return nil, fmt.Errorf("failed to load local policy mappings: %w", err) } @@ -255,16 +137,16 @@ func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts * return resolveLocalPolicy(opts, mapping) } // must check tuf - tufMappings, err := loadTufMappings(opts.TufClient, opts.LocalTargetsDir) + tufMappings, err := config.LoadTufMappings(opts.TufClient, opts.LocalTargetsDir) if err != nil { - return nil, fmt.Errorf("failed to load tuf policy mappings: %w", err) + return nil, fmt.Errorf("failed to load tuf policy mappings as fallback: %w", err) } // it's a mirror of a tuf policy if mirror != nil { for _, mapping := range tufMappings.Policies { if mapping.Id == mirror.PolicyId { - return resolveTufPolicy(opts, &mapping) + return resolveTufPolicy(opts, mapping) } } } @@ -276,3 +158,32 @@ func ResolvePolicy(ctx context.Context, resolver oci.AttestationResolver, opts * } return resolveTufPolicy(opts, mapping) } + +func CreateImageDetailsResolver(imageSource *oci.ImageSpec) (oci.ImageDetailsResolver, error) { + switch imageSource.Type { + case oci.OCI: + return oci.NewOCILayoutAttestationResolver(imageSource) + case oci.Docker: + return oci.NewRegistryImageDetailsResolver(imageSource) + } + return nil, fmt.Errorf("unsupported image source type: %s", imageSource.Type) +} + +func CreateAttestationResolver(resolver oci.ImageDetailsResolver, mapping *config.PolicyMapping) (oci.AttestationResolver, error) { + switch resolver := resolver.(type) { + case *oci.RegistryImageDetailsResolver: + if mapping.Attestations != nil && mapping.Attestations.Style == config.AttestationStyleAttached { + return oci.NewRegistryAttestationResolver(resolver) + } else { + if mapping.Attestations != nil && mapping.Attestations.Repo != "" { + return oci.NewReferrersAttestationResolver(resolver, oci.WithReferrersRepo(mapping.Attestations.Repo)) + } else { + return oci.NewReferrersAttestationResolver(resolver) + } + } + case *oci.OCILayoutResolver: + return resolver, nil + default: + return nil, fmt.Errorf("unsupported image details resolver type: %T", resolver) + } +} diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index b4211d1..65166b0 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -8,6 +8,7 @@ import ( "github.com/docker/attest/internal/test" "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/config" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/policy" "github.com/docker/attest/pkg/tuf" @@ -76,8 +77,14 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { PolicyId: tc.policyId, } } - - policy, err := policy.ResolvePolicy(ctx, tc.resolver, tc.policy) + imageName, err := tc.resolver.ImageName(ctx) + require.NoError(t, err) + platform, err := tc.resolver.ImagePlatform(ctx) + require.NoError(t, err) + src, err := oci.ParseImageSpec(imageName, oci.WithPlatform(platform.String())) + require.NoError(t, err) + resolver, err := policy.CreateImageDetailsResolver(src) + policy, err := policy.ResolvePolicy(ctx, resolver, tc.policy) if tc.errorStr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.errorStr) @@ -98,10 +105,7 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { } func TestLoadingMappings(t *testing.T) { - opts := &policy.PolicyOptions{ - LocalPolicyDir: filepath.Join("testdata", "mock-tuf-allow"), - } - policyMappings, err := policy.LoadLocalMappings(opts) + 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 { diff --git a/pkg/policy/testdata/mock-tuf-allow/mapping.yaml b/pkg/policy/testdata/mock-tuf-allow/mapping.yaml index c324a86..c6064b5 100644 --- a/pkg/policy/testdata/mock-tuf-allow/mapping.yaml +++ b/pkg/policy/testdata/mock-tuf-allow/mapping.yaml @@ -7,6 +7,8 @@ policies: prefix: library/ id: docker-official-images description: Docker Official Images + attestations: + repo: "localhost:5001/library-refs" files: - path: doi/policy.rego mirrors: diff --git a/pkg/policy/types.go b/pkg/policy/types.go new file mode 100644 index 0000000..d44665a --- /dev/null +++ b/pkg/policy/types.go @@ -0,0 +1,53 @@ +package policy + +import ( + "github.com/docker/attest/pkg/config" + "github.com/docker/attest/pkg/tuf" + intoto "github.com/in-toto/in-toto-golang/in_toto" +) + +type Summary struct { + Subjects []intoto.Subject `json:"subjects"` + SLSALevels []string `json:"slsa_levels"` + Verifier string `json:"verifier"` + PolicyURI string `json:"policy_uri"` +} + +type Violation struct { + Type string `json:"type"` + Description string `json:"description"` + Attestation *intoto.Statement `json:"attestation"` + Details map[string]any `json:"details"` +} + +type Result struct { + Success bool `json:"success"` + Violations []Violation `json:"violations"` + Summary Summary `json:"summary"` +} + +type PolicyOptions struct { + TufClient tuf.TUFClient + LocalTargetsDir string + LocalPolicyDir string + PolicyId string + ReferrersRepo string + AttestationStyle config.AttestationStyle +} + +type Policy struct { + InputFiles []*PolicyFile + Query string + Mapping *config.PolicyMapping +} + +type PolicyInput struct { + Digest string `json:"digest"` + Purl string `json:"purl"` + IsCanonical bool `json:"isCanonical"` +} + +type PolicyFile struct { + Path string + Content []byte +} diff --git a/test/testdata/local-policy/doi/policy.rego b/test/testdata/local-policy/doi/policy.rego new file mode 100644 index 0000000..aadb8cd --- /dev/null +++ b/test/testdata/local-policy/doi/policy.rego @@ -0,0 +1,49 @@ +package attest + +import rego.v1 + +keys := [{ + "id": "a0c296026645799b2a297913878e81b0aefff2a0c301e97232f717e14402f3e4", + "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgH23D1i2+ZIOtVjmfB7iFvX8AhVN\n9CPJ4ie9axw+WRHozGnRy99U2dRge3zueBBg2MweF0zrToXGig2v3YOrdw==\n-----END PUBLIC KEY-----", + "from": "2023-12-15T14:00:00Z", + "to": null, + "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 +} + +result := { + "success": count(atts) > 0, + "violations": set(), + "attestations": statements, + "summary": { + "subjects": subjects, + "slsa_level": "SLSA_BUILD_LEVEL_3", + "verifier": "docker-official-images", + "policy_uri": "https://docker.com/official/policy/v0.1", + }, +} diff --git a/test/testdata/local-policy/mapping.yaml b/test/testdata/local-policy/mapping.yaml new file mode 100644 index 0000000..557c8d5 --- /dev/null +++ b/test/testdata/local-policy/mapping.yaml @@ -0,0 +1,29 @@ +# map repos to policies +version: v1 +kind: policy-mapping +policies: + - origin: + domain: docker.io + prefix: library/ + id: test-images + description: Local test 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"