From b16511d6e4c27b33ab772b208e2e9c7f5b505127 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Mon, 29 Apr 2024 12:52:39 -0500 Subject: [PATCH 01/11] feat: add attest sign/verify --- pkg/attest/sign.go | 2 ++ pkg/attest/verify.go | 40 ++++++++++++++++++++++++++ pkg/attest/verify_test.go | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 pkg/attest/sign.go create mode 100644 pkg/attest/verify.go create mode 100644 pkg/attest/verify_test.go diff --git a/pkg/attest/sign.go b/pkg/attest/sign.go new file mode 100644 index 0000000..1c255bd --- /dev/null +++ b/pkg/attest/sign.go @@ -0,0 +1,2 @@ +package attest + diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go new file mode 100644 index 0000000..b23c1ca --- /dev/null +++ b/pkg/attest/verify.go @@ -0,0 +1,40 @@ +package attest + +import ( + "context" + "fmt" + + "github.com/docker/attest/pkg/oci" + "github.com/docker/attest/pkg/policy" +) + +func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, files []*policy.PolicyFile) error { + digest, err := resolver.ImageDigest(ctx) + if err != nil { + return fmt.Errorf("failed to get image digest: %w", err) + } + name, err := resolver.ImageName(ctx) + if err != nil { + return fmt.Errorf("failed to get image name: %w", err) + } + purl, canonical, err := oci.RefToPURL(name, resolver.ImagePlatformStr()) + if err != nil { + return fmt.Errorf("failed to convert ref to purl: %w", err) + } + input := &policy.PolicyInput{ + Digest: digest, + Purl: purl, + IsCanonical: canonical, + } + + evaluator, err := policy.GetPolicyEvaluator(ctx) + if err != nil { + return err + } + err = evaluator.Evaluate(ctx, resolver, files, input) + if err != nil { + return fmt.Errorf("policy evaluation failed: %w", err) + } + + return nil +} diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go new file mode 100644 index 0000000..42ea7b7 --- /dev/null +++ b/pkg/attest/verify_test.go @@ -0,0 +1,60 @@ +package attest + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/oci" + "github.com/docker/attest/pkg/policy" + "github.com/stretchr/testify/assert" +) + +var ( + ExampleAttestation = filepath.Join("..", "..", "test", "testdata", "example_attestation.json") +) + +func TestVerifyAttestations(t *testing.T) { + ex, err := os.ReadFile(ExampleAttestation) + assert.NoError(t, err) + + var env = new(attestation.Envelope) + err = json.Unmarshal(ex, env) + assert.NoError(t, err) + resolver := &oci.MockResolver{ + Envs: []*attestation.Envelope{env}, + } + + testCases := []struct { + name string + policyEvaluationError error + expectedError error + }{ + {"policy ok", nil, nil}, + {"policy error", fmt.Errorf("policy error"), fmt.Errorf("policy evaluation failed: policy error")}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + mockPE := policy.MockPolicyEvaluator{ + EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) error { + return tc.policyEvaluationError + }, + } + + ctx := policy.WithPolicyEvaluator(context.Background(), &mockPE) + err = VerifyAttestations(ctx, resolver, nil) + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} From 20fc3729888c7b8d6283fa45b217bc94c384bad7 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Mon, 29 Apr 2024 13:43:50 -0500 Subject: [PATCH 02/11] docs: update README.md --- README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++ pkg/attest/verify.go | 15 +++++++++++ 2 files changed, 77 insertions(+) diff --git a/README.md b/README.md index 792a1e8..797896e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ # attest Library to create, verify, and evaluate policy for attestations on container images + +# usage +## signing attestations + + +## verifying attestations +1. Create a TUF client + * using OCI registry for TUF + ```go + tufClient, err := tuf.NewTufClient(embed.DefaultRoot, "/.docker/tuf", "docker/tuf-metadata:latest", "docker/tuf-targets") + ``` + * using HTTPS for TUF + ```go + tufClient, err := tuf.NewTufClient(embed.DefaultRoot, "/.docker/tuf", "https://docker.github.io/tuf/metadata", "https://docker.github.io/tuf/targets") + ``` + +1. Configure an attestation resolver + * using OCI registry + ```go + var resolver oci.AttestationResolver + resolver = &oci.RegistryResolver{ + Image: image, // path to image index in OCI registry containing image attestations (e.g. docker/nginx:latest) + Platform: platform, // platform of subject image (image that attestations are being verified against) + } + ``` + * using local OCI layout + ```go + var resolver oci.AttestationResolver + resolver = &oci.OCILayoutResolver{ + Path: path, // file path to OCI layout containing image attestations (e.g. /myimage) + Platform: platform, // platform of subject image (image that attestations are being verified against) + } + ``` + +2. Configure policy options + ```go + opts := &policy.PolicyOptions{ + TufClient: tufClient, + LocalTargetsDir: "/.docker/policy", // location to store policy files downloaded from TUF + LocalPolicyDir: "", // overrides TUF policy for local policy files + } + ``` + +3. Verify attestations + ```go + policy, err := attest.Verify(ctx, opts, resolver) + if err != nil { + return false // failed policy or attestation signature verification + } + if policy { + return true // passed policy + } + return true // no policy for image + ``` + +## mirroring TUF repositories +TODO: write content for this outline +### mirroring TUF metadata to OCI +#### delegated metadata +### mirroring TUF targets to OCI +#### delegated targets +### using `go-tuf` OCI registry client diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index b23c1ca..52f05c6 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -38,3 +38,18 @@ func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, f return nil } + +func Verify(ctx context.Context, opts *policy.PolicyOptions, resolver oci.AttestationResolver) (policyFound bool, err error) { + policyFiles, err := policy.ResolvePolicy(ctx, resolver, opts) + if err != nil { + return false, fmt.Errorf("failed to resolve policy: %w", err) + } + + // no policy for image -> success + if policyFiles == nil { + return false, nil + } + + // policy found -> verify + return true, VerifyAttestations(ctx, resolver, policyFiles) +} From fb1a43acfd92173d99c02d0192b2db977847ebaf Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Mon, 29 Apr 2024 15:02:21 -0500 Subject: [PATCH 03/11] feat: add attest sign/verify --- internal/test/test.go | 109 +++++++ internal/test/test_test.go | 23 ++ pkg/attest/sign.go | 285 ++++++++++++++++++ pkg/attest/sign_test.go | 176 +++++++++++ pkg/attest/types.go | 33 ++ pkg/attestation/types.go | 31 +- pkg/attestation/vsa.go | 55 ++++ pkg/oci/types.go | 61 ++++ scripts/gen-testdata.sh | 45 +++ test/Dockerfile | 5 + ...27aa6c918c390c373294ec4fc48f2c6fe703fcc6fe | 1 + ...4553dbbe762e4457a099ab8b706e67f5f9fc934701 | 50 +++ ...ddbb054b2b4c01f717d408efba753da2bf6e8905da | 19 ++ ...b6e13bcbafb8e0dc0adc0443f1a25666f9518c5071 | 1 + ...976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e | 16 + ...dd621f59e0d43a3b346f34c34eb58da31f00a9b0ad | Bin 0 -> 116 bytes ...f919823446286a674ad669d0baa8ab2c358aeb3a82 | 19 ++ ...86ac5c2354a573ea041b8846409c4fc0f8c4a70850 | 1 + ...9fbabb4464fabd6dd32e82c67ea2b2a3c4e8bacdf5 | 1 + ...cd975b5752cf0acaedd668bb525fcd40c3587cc460 | 1 + ...f317fbeba28f5e06f1ce4d3895b3b8770140280a2e | 1 + ...273e2a9c96bfe291662f08e2860e868eef69c34620 | 16 + test/testdata/no-provenance-image/index.json | 1 + test/testdata/no-provenance-image/oci-layout | 1 + ...383582e5ef981b8a6bb7415d07d8d70c90d8cfd326 | 50 +++ ...27aa6c918c390c373294ec4fc48f2c6fe703fcc6fe | 1 + ...08db8fcbbd5d8ec68cf0047f954133e76d8e73d71c | 27 ++ ...8f9fb2003318350a8026ea082b63a249cfa60918a3 | 1 + ...16d96cbbdb6493e32d4f6abd8f7a191990e8efb289 | 1 + ...d70a787aac618a41d4c8ec8d6e12bd12d0cc601706 | 1 + ...d17ec2ce917fc9a500aab72f813d26fed8404e7162 | 27 ++ ...6b5853ea559248fdb4ab711bcea34b65c62f0e026b | 1 + ...435830fbf13cf3ab1ae27ec91246b280514e6a7b33 | 1 + ...95083e64d3764c798507596ceded776c4ab038c224 | 1 + ...0cd7813f814e2baad70141a3e315b7c3476b0f476f | 27 ++ ...eb1f2c06af73879adba0fcb4743339c9a54b377635 | 50 +++ ...976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e | 16 + ...d02b74310803129c3eb1e22d2e62279f8c72340b55 | 27 ++ ...20844157f4b0c8a6445d220af741e9fee8099bf532 | 1 + ...d73212578bb3a02e8c0da7fc175c79045e73519375 | 1 + ...c8e269e78c3c95df611b44580426c384d3f5057776 | 1 + ...461575c51e4a626a091dc2842b35cac18c787ff80f | 27 ++ ...dd621f59e0d43a3b346f34c34eb58da31f00a9b0ad | Bin 0 -> 116 bytes ...4c5538aec56315874a6db860fbf6874cd7a830e3c8 | 50 +++ ...c7db8e7affc05fd9bf98eb027038b7daf176861e85 | 1 + ...fd4ef5ce285f0aa928d2651f7ec3d5a78276249dec | 1 + ...86ac5c2354a573ea041b8846409c4fc0f8c4a70850 | 1 + ...99438518217417e01414d18189a3cf71c07f2a02c3 | 27 ++ ...162abec6f8bee4c463103161ab772c774e7ae9dd6d | 1 + ...6b61fbf88d9287a936b285a8b4dde8893a1f4ffedf | 1 + ...06b1340c501e59376a658b14b53c1828924c0ac668 | 1 + ...cd975b5752cf0acaedd668bb525fcd40c3587cc460 | 1 + ...f317fbeba28f5e06f1ce4d3895b3b8770140280a2e | 1 + ...273e2a9c96bfe291662f08e2860e868eef69c34620 | 16 + ...73269ecfac61e8dcdad3a4a643dcb577522492f898 | 50 +++ ...f217f22bb1e106efd5ee791640411764e1cf39ea2c | 27 ++ ...fac8f20e3645a45370e52abf9581dd4eedd152fce0 | 27 ++ ...1165631fda0cfe691a383e7b333269a53bf9a79c34 | 1 + ...c49c6440fcd6d565a8658141914a8a07c127e00d7e | 1 + test/testdata/unsigned-test-image/index.json | 1 + test/testdata/unsigned-test-image/oci-layout | 1 + 61 files changed, 1418 insertions(+), 3 deletions(-) create mode 100644 internal/test/test_test.go create mode 100644 pkg/attest/sign_test.go create mode 100644 pkg/attest/types.go create mode 100644 pkg/attestation/vsa.go create mode 100755 scripts/gen-testdata.sh create mode 100644 test/Dockerfile create mode 100644 test/testdata/no-provenance-image/blobs/sha256/1c70b3e7c3a57801501ec127aa6c918c390c373294ec4fc48f2c6fe703fcc6fe create mode 100644 test/testdata/no-provenance-image/blobs/sha256/1effe3a77c594e579388dc4553dbbe762e4457a099ab8b706e67f5f9fc934701 create mode 100644 test/testdata/no-provenance-image/blobs/sha256/2aaebbb079957470e7c0adddbb054b2b4c01f717d408efba753da2bf6e8905da create mode 100644 test/testdata/no-provenance-image/blobs/sha256/2e82727457f04f320b643cb6e13bcbafb8e0dc0adc0443f1a25666f9518c5071 create mode 100644 test/testdata/no-provenance-image/blobs/sha256/7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e create mode 100644 test/testdata/no-provenance-image/blobs/sha256/97a548f8d65d9ab617f608dd621f59e0d43a3b346f34c34eb58da31f00a9b0ad create mode 100644 test/testdata/no-provenance-image/blobs/sha256/9b009d6b84b1ed941070b3f919823446286a674ad669d0baa8ab2c358aeb3a82 create mode 100644 test/testdata/no-provenance-image/blobs/sha256/a9646604f9522bf59d203a86ac5c2354a573ea041b8846409c4fc0f8c4a70850 create mode 100644 test/testdata/no-provenance-image/blobs/sha256/b6ef78de3633e45d1c08019fbabb4464fabd6dd32e82c67ea2b2a3c4e8bacdf5 create mode 100644 test/testdata/no-provenance-image/blobs/sha256/d85d624a324422194b43cccd975b5752cf0acaedd668bb525fcd40c3587cc460 create mode 100644 test/testdata/no-provenance-image/blobs/sha256/da5651e8877b960aa30f32f317fbeba28f5e06f1ce4d3895b3b8770140280a2e create mode 100644 test/testdata/no-provenance-image/blobs/sha256/da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620 create mode 100644 test/testdata/no-provenance-image/index.json create mode 100644 test/testdata/no-provenance-image/oci-layout create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/0f2ee9a338149a5a1435a7383582e5ef981b8a6bb7415d07d8d70c90d8cfd326 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/1c70b3e7c3a57801501ec127aa6c918c390c373294ec4fc48f2c6fe703fcc6fe create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/26da286bbc886aa14d191808db8fcbbd5d8ec68cf0047f954133e76d8e73d71c create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/2953164d6cc6c8bb8271f78f9fb2003318350a8026ea082b63a249cfa60918a3 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/2a9b671f3fc9bc5ca967b616d96cbbdb6493e32d4f6abd8f7a191990e8efb289 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/371954672cfaa92735d6fbd70a787aac618a41d4c8ec8d6e12bd12d0cc601706 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/3883faf6acc3cae029364ed17ec2ce917fc9a500aab72f813d26fed8404e7162 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/3e64f9d2888ed9211fbf2c6b5853ea559248fdb4ab711bcea34b65c62f0e026b create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/4e5988d06eee647cb901d4435830fbf13cf3ab1ae27ec91246b280514e6a7b33 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/5171425b78a2aedb43eb4e95083e64d3764c798507596ceded776c4ab038c224 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/6658b8ba1e1221a6288bf50cd7813f814e2baad70141a3e315b7c3476b0f476f create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/6c3da8eeaba64ce5acfcbaeb1f2c06af73879adba0fcb4743339c9a54b377635 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/8049aa9ad3479085066b31d02b74310803129c3eb1e22d2e62279f8c72340b55 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/8f2f55fc493890c2482a1220844157f4b0c8a6445d220af741e9fee8099bf532 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/8f94b6e2a8be82e2e5b562d73212578bb3a02e8c0da7fc175c79045e73519375 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/92d3311aa91737ff81e2a4c8e269e78c3c95df611b44580426c384d3f5057776 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/9638ca53d2795806cf51b7461575c51e4a626a091dc2842b35cac18c787ff80f create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/97a548f8d65d9ab617f608dd621f59e0d43a3b346f34c34eb58da31f00a9b0ad create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/98e06f6b48edd74e21e8504c5538aec56315874a6db860fbf6874cd7a830e3c8 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/9fe102c03d71d47a24cd7fc7db8e7affc05fd9bf98eb027038b7daf176861e85 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/a4cf4b24f3fa8cd49a59e8fd4ef5ce285f0aa928d2651f7ec3d5a78276249dec create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/a9646604f9522bf59d203a86ac5c2354a573ea041b8846409c4fc0f8c4a70850 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/aeca14119e3242c51633a899438518217417e01414d18189a3cf71c07f2a02c3 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/c01e5307ec84299048d76f162abec6f8bee4c463103161ab772c774e7ae9dd6d create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/c6dd08ccc92ab60a87648a6b61fbf88d9287a936b285a8b4dde8893a1f4ffedf create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/c9f436179969b60ec0bbd406b1340c501e59376a658b14b53c1828924c0ac668 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/d85d624a324422194b43cccd975b5752cf0acaedd668bb525fcd40c3587cc460 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/da5651e8877b960aa30f32f317fbeba28f5e06f1ce4d3895b3b8770140280a2e create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/db8f2a6e112ea6396f57d073269ecfac61e8dcdad3a4a643dcb577522492f898 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/e0a9b9404ac2691b9b1c9ef217f22bb1e106efd5ee791640411764e1cf39ea2c create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/f2b95cecafef9c22a5d059fac8f20e3645a45370e52abf9581dd4eedd152fce0 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/f634c4c53b03bf8ff917b61165631fda0cfe691a383e7b333269a53bf9a79c34 create mode 100644 test/testdata/unsigned-test-image/blobs/sha256/fed2c8841731e2cf1ceb53c49c6440fcd6d565a8658141914a8a07c127e00d7e create mode 100644 test/testdata/unsigned-test-image/index.json create mode 100644 test/testdata/unsigned-test-image/oci-layout diff --git a/internal/test/test.go b/internal/test/test.go index 87533d7..695dd1c 100644 --- a/internal/test/test.go +++ b/internal/test/test.go @@ -2,13 +2,22 @@ package test import ( "context" + "encoding/base64" + "encoding/json" + "fmt" "os" + "strings" "testing" + "github.com/docker/attest/pkg/attestation" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/policy" "github.com/docker/attest/pkg/signerverifier" "github.com/docker/attest/pkg/tlog" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/partial" + intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/secure-systems-lab/go-securesystemslib/dsse" ) @@ -84,3 +93,103 @@ func GetMockPolicy() policy.PolicyEvaluator { }, } } + +type AnnotatedStatement struct { + OCIDescriptor *v1.Descriptor + InTotoStatement *intoto.Statement + Annotations map[string]string +} + +func ExtractAnnotatedStatements(path string, mediaType string) ([]*AnnotatedStatement, 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) + } + idxDigest := idxm.Manifests[0].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 statements []*AnnotatedStatement + + for _, mf := range mfs2.Manifests { + if mf.Annotations["vnd.docker.reference.type"] != "attestation-manifest" { + 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) + } + layers, err := attestationImage.Layers() + if err != nil { + return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err) + } + + for _, layer := range layers { + // parse layer blob as json + mt, err := layer.MediaType() + if err != nil { + return nil, fmt.Errorf("failed to get layer media type: %w", err) + } + + if string(mt) != mediaType { + continue + } + r, err := layer.Uncompressed() + if err != nil { + return nil, fmt.Errorf("failed to get layer contents: %w", err) + } + defer r.Close() + var intotoStatement = new(intoto.Statement) + var desc *v1.Descriptor + if strings.HasSuffix(string(mt), "+dsse") { + var env = new(attestation.Envelope) + err = json.NewDecoder(r).Decode(env) + if err != nil { + return nil, fmt.Errorf("failed to decode env: %w", err) + } + payload, err := base64.StdEncoding.Strict().DecodeString(env.Payload) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + err = json.Unmarshal([]byte(payload), intotoStatement) + if err != nil { + return nil, fmt.Errorf("failed to decode %s statement: %w", mediaType, err) + } + } else { + desc := new(v1.Descriptor) + err = json.NewDecoder(r).Decode(desc) + if err != nil { + return nil, fmt.Errorf("failed to decode statement: %w", err) + } + } + + layerDesc, err := partial.Descriptor(layer) + if err != nil { + return nil, fmt.Errorf("failed to get descriptor for layer: %w", err) + } + annotations := make(map[string]string) + for k, v := range layerDesc.Annotations { + annotations[k] = v + } + statements = append(statements, &AnnotatedStatement{ + OCIDescriptor: desc, + InTotoStatement: intotoStatement, + Annotations: annotations, + }) + } + } + return statements, nil +} diff --git a/internal/test/test_test.go b/internal/test/test_test.go new file mode 100644 index 0000000..812c019 --- /dev/null +++ b/internal/test/test_test.go @@ -0,0 +1,23 @@ +package test + +import ( + "path/filepath" + "testing" + + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/stretchr/testify/assert" +) + +var ( + UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image") +) + +const ( + ExpectedStatements = 4 +) + +func TestExtractAnnotatedStatements(t *testing.T) { + statements, err := ExtractAnnotatedStatements(UnsignedTestImage, intoto.PayloadType) + assert.NoError(t, err) + assert.Equalf(t, len(statements), ExpectedStatements, "expected %d statement, got %d", ExpectedStatements, len(statements)) +} diff --git a/pkg/attest/sign.go b/pkg/attest/sign.go index 1c255bd..7641227 100644 --- a/pkg/attest/sign.go +++ b/pkg/attest/sign.go @@ -1,2 +1,287 @@ package attest +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/oci" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/match" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + intoto "github.com/in-toto/in-toto-golang/in_toto" + ociv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/secure-systems-lab/go-securesystemslib/dsse" +) + +func SignIndexAttestations(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, opts *SigningOptions) (v1.ImageIndex, error) { + indexManifest, err := idx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err) + } + + var originalManifestDigests []v1.Hash + var muts []mutate.IndexAddendum + for _, manifest := range indexManifest.Manifests { + if manifest.Annotations[oci.DockerReferenceType] != oci.AttestationManifestType { + continue + } + + originalManifestDigests = append(originalManifestDigests, manifest.Digest) + + attestationImage, err := idx.Image(manifest.Digest) + if err != nil { + return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", manifest.Digest.String(), err) + } + layers, err := attestationImage.Layers() + if err != nil { + return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err) + } + + var signedLayers []mutate.Addendum + var originalLayers []v1.Layer + var statements []*intoto.Statement + + for _, layer := range layers { + // 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() + mt, err := layer.MediaType() + if err != nil { + return nil, fmt.Errorf("failed to get layer media type: %w", err) + } + + if mt != types.MediaType(intoto.PayloadType) { + originalLayers = append(originalLayers, layer) + continue + } + var stmt = new(intoto.Statement) + err = json.NewDecoder(r).Decode(&stmt) + if err != nil { + return nil, fmt.Errorf("failed to decode statement layer contents: %w", err) + } + + statements = append(statements, stmt) + layerDesc, err := partial.Descriptor(layer) + if err != nil { + return nil, fmt.Errorf("failed to get descriptor for layer: %w", err) + } + // copy original annotations and add new ones + ann := make(map[string]string) + for k, v := range layerDesc.Annotations { + ann[k] = v + } + ann[InTotoReferenceLifecycleStage] = LifecycleStageExperimental + + var env *attestation.Envelope + var mediaType string + switch opts.EnvelopeStyle { + case OCIContentDescriptor: + // Ensure we sign just the digest, size, and media type + payloadDesc := v1.Descriptor{ + Digest: layerDesc.Digest, + Size: layerDesc.Size, + MediaType: layerDesc.MediaType, + } + payload, err := json.Marshal(payloadDesc) + if err != nil { + return nil, fmt.Errorf("failed to marshal descriptor: %w", err) + } + env, err = attestation.SignDSSE(ctx, payload, ociv1.MediaTypeDescriptor, signer) + if err != nil { + return nil, fmt.Errorf("failed to sign statement: %w", err) + } + ann[oci.DockerReferenceDigest] = layerDesc.Digest.String() + // this is a reference type + opts.Replace = false + mediaType = attestation.OCIDescriptorDSSEMediaType + case EmbeddedDSSE: + payload, err := json.Marshal(stmt) + if err != nil { + return nil, fmt.Errorf("failed to marshal statement: %w", err) + } + env, err = attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer) + if err != nil { + return nil, fmt.Errorf("failed to sign statement: %w", err) + } + mediaType, err = attestation.DSSEMediaType(stmt.PredicateType) + if err != nil { + return nil, fmt.Errorf("failed to get DSSE media type: %w", err) + } + + default: + return nil, fmt.Errorf("unknown envelope style %q", opts.EnvelopeStyle) + } + + data, err := json.Marshal(env) + if err != nil { + return nil, fmt.Errorf("failed to marshal envelope: %w", err) + } + newLayer := static.NewLayer(data, types.MediaType(mediaType)) + + withAnnotations := mutate.Addendum{ + Layer: newLayer, + Annotations: ann, + } + signedLayers = append(signedLayers, withAnnotations) + } + + newImg, err := addSignedLayers(signedLayers, originalLayers, manifest.MediaType, attestationImage, opts) + if err != nil { + return nil, fmt.Errorf("failed to add signed layers: %w", err) + } + + if opts.VSAOptions != nil { + newImg, err = addVSA(ctx, newImg, statements, manifest.MediaType, signer, opts) + if err != nil { + return nil, fmt.Errorf("failed to add VSA: %w", err) + } + } + newDesc, err := partial.Descriptor(newImg) + if err != nil { + return nil, fmt.Errorf("failed to get descriptor: %w", err) + } + cf, err := attestationImage.ConfigFile() + if err != nil { + return nil, fmt.Errorf("failed to get config file: %w", err) + } + newDesc.Platform = cf.Platform() + newDesc.MediaType = manifest.MediaType + newDesc.Annotations = manifest.Annotations + + muts = append(muts, mutate.IndexAddendum{ + Add: newImg, + Descriptor: *newDesc, + }) + } + // create new index with signed images + newIndex := mutate.RemoveManifests(idx, match.Digests(originalManifestDigests...)) + newIndex = mutate.AppendManifests(newIndex, muts...) + + return newIndex, nil +} + +func addVSA(ctx context.Context, image v1.Image, stmt []*intoto.Statement, outerMediaType types.MediaType, signer dsse.SignerVerifier, opts *SigningOptions) (v1.Image, error) { + if len(stmt) == 0 { + return nil, fmt.Errorf("no attestations found to generate VSA from") + } + sub := stmt[0].Subject[0] + stype := stmt[0].Type + + uri, err := attestation.ToVSAResourceURI(sub) + if err != nil { + return nil, fmt.Errorf("failed to generate VSA resource URI: %w", err) + } + + inputs := make([]attestation.VSAInputAttestation, 0, len(stmt)) + layers, err := image.Layers() + if err != nil { + return nil, fmt.Errorf("failed to get layers: %w", err) + } + for _, layer := range layers { + mt, err := layer.MediaType() + if err != nil { + return nil, fmt.Errorf("failed to get layer media type: %w", err) + } + mediaType := string(mt) + if !strings.HasPrefix(mediaType, "application/vnd.in-toto.") || + !strings.HasSuffix(mediaType, "+dsse") { + continue + } + + dgst, err := layer.Digest() + if err != nil { + return nil, fmt.Errorf("failed to get layer digest: %w", err) + } + inputs = append(inputs, attestation.VSAInputAttestation{ + Digest: map[string]string{"sha256": dgst.Hex}, + MediaType: string(mt), + }) + } + vsaStatement := &intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + PredicateType: attestation.VSAPredicateType, + Type: stype, + Subject: stmt[0].Subject, + }, + Predicate: attestation.VSAPredicate{ + Verifier: attestation.VSAVerifier{ + ID: opts.VSAOptions.VerifierID, + }, + TimeVerified: time.Now().UTC().Format(time.RFC3339), + ResourceUri: uri, + Policy: attestation.VSAPolicy{URI: opts.VSAOptions.PolicyURI}, + VerificationResult: "PASSED", + VerifiedLevels: []string{opts.VSAOptions.BuildLevel}, + InputAttestations: inputs, + }, + } + payload, err := json.Marshal(vsaStatement) + if err != nil { + return nil, fmt.Errorf("failed to marshal statement: %w", err) + } + env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer) + if err != nil { + return nil, fmt.Errorf("failed to sign statement: %w", err) + } + mediaType, err := attestation.DSSEMediaType(vsaStatement.PredicateType) + if err != nil { + return nil, fmt.Errorf("failed to get DSSE media type: %w", err) + } + + data, err := json.Marshal(env) + if err != nil { + return nil, fmt.Errorf("failed to marshal envelope: %w", err) + } + mt := types.MediaType(mediaType) + newLayer := static.NewLayer(data, mt) + ann := make(map[string]string) + ann[InTotoReferenceLifecycleStage] = LifecycleStageExperimental + ann[oci.InTotoPredicateType] = attestation.VSAPredicateType + withAnnotations := mutate.Addendum{ + Layer: newLayer, + Annotations: ann, + } + opts = &SigningOptions{ + Replace: false, + } + return addSignedLayers([]mutate.Addendum{withAnnotations}, layers, outerMediaType, image, opts) +} + +func addSignedLayers(signedLayers []mutate.Addendum, originalLayers []v1.Layer, mediaType types.MediaType, attestationImage v1.Image, opts *SigningOptions) (v1.Image, error) { + var err error + if opts.Replace { + newImg := empty.Image + newImg = mutate.MediaType(newImg, mediaType) + newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.image.config.v1+json") + for _, layer := range signedLayers { + newImg, err = mutate.Append(newImg, layer) + if err != nil { + return nil, fmt.Errorf("failed to append layer: %w", err) + } + } + newImg, err = mutate.AppendLayers(newImg, originalLayers...) + if err != nil { + return nil, fmt.Errorf("failed to append original layers: %w", err) + } + return newImg, nil + + } + for _, layer := range signedLayers { + attestationImage, err = mutate.Append(attestationImage, layer) + if err != nil { + return nil, fmt.Errorf("failed to append layer: %w", err) + } + } + return attestationImage, nil +} diff --git a/pkg/attest/sign_test.go b/pkg/attest/sign_test.go new file mode 100644 index 0000000..3b7e39a --- /dev/null +++ b/pkg/attest/sign_test.go @@ -0,0 +1,176 @@ +package attest + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/docker/attest/internal/test" + "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/oci" + "github.com/docker/attest/pkg/policy" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + intoto "github.com/in-toto/in-toto-golang/in_toto" + v02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + "github.com/stretchr/testify/assert" +) + +var ( + UnsignedTestImage = filepath.Join("..", "..", "test", "testdata", "unsigned-test-image") + NoProvenanceImage = filepath.Join("..", "..", "test", "testdata", "no-provenance-image") + LocalPolicyDir = filepath.Join("..", "..", "test", "testdata", "local-policy") + TestTempDir = "attest-sign-test" +) + +func TestSignVerifyOCILayout(t *testing.T) { + ctx, signer := test.Setup(t) + + testCases := []struct { + name string + TestImage string + expectedStatements int + expectedAttestations int + envelopeStyle envelopeStyle + replace bool + }{ + + {"signed replaced (does nothing)", UnsignedTestImage, 4, 4, OCIContentDescriptor, true}, + {"without replace", UnsignedTestImage, 4, 4, OCIContentDescriptor, false}, + {"embedded", UnsignedTestImage, 0, 6, EmbeddedDSSE, true}, + {"embedded", UnsignedTestImage, 4, 6, EmbeddedDSSE, false}, + + // image without provenance doesn't fail + {"no provenance (replace)", NoProvenanceImage, 0, 4, EmbeddedDSSE, true}, + {"no provenance (no replace)", NoProvenanceImage, 2, 4, EmbeddedDSSE, false}, + } + policyResolver := &policy.PolicyOptions{ + LocalPolicyDir: LocalPolicyDir, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := test.CreateTempDir(t, "", TestTempDir) + outputLayout := tempDir + opts := &SigningOptions{ + Replace: tc.replace, + EnvelopeStyle: tc.envelopeStyle, + VSAOptions: &attestation.VSAOptions{ + BuildLevel: "SLSA_BUILD_LEVEL_3", + PolicyURI: "https://docker.com/attest/policy", + VerifierID: "https://docker.com", + }, + } + attIdx, err := oci.AttestationIndexFromPath(tc.TestImage) + assert.NoError(t, err) + signedIndex, err := SignIndexAttestations(ctx, attIdx.Index, signer, opts) + assert.NoError(t, err) + + // output signed attestations + idx := v1.ImageIndex(empty.Index) + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ + Add: signedIndex, + Descriptor: v1.Descriptor{ + Annotations: map[string]string{ + oci.OciReferenceTarget: attIdx.Name, + }, + }, + }) + _, err = layout.Write(outputLayout, idx) + assert.NoError(t, err) + + resolver := &oci.OCILayoutResolver{ + Path: outputLayout, + Platform: "", + } + policy, err := Verify(ctx, policyResolver, resolver) + assert.NoError(t, err) + assert.Truef(t, policy, "Policy should have been found") + + mt, _ := attestation.DSSEMediaType(attestation.VSAPredicateType) + vsas, err := test.ExtractAnnotatedStatements(tempDir, mt) + assert.NoError(t, err) + assert.Equalf(t, len(vsas), 2, "expected %d vsa statement, got %d", 2, len(vsas)) + + switch tc.envelopeStyle { + case OCIContentDescriptor: + { + statements, err := test.ExtractAnnotatedStatements(tempDir, intoto.PayloadType) + assert.NoError(t, err) + assert.Equalf(t, tc.expectedStatements, len(statements), "expected %d statement, got %d", tc.expectedStatements, len(statements)) + + statements, err = test.ExtractAnnotatedStatements(tempDir, attestation.OCIDescriptorDSSEMediaType) + assert.NoError(t, err) + + assert.Equalf(t, tc.expectedAttestations, len(statements), "expected %d attestations, got %d", tc.expectedAttestations, len(statements)) + } + case EmbeddedDSSE: + { + var allEnvelopes []*test.AnnotatedStatement + for _, predicate := range []string{intoto.PredicateSPDX, v02.PredicateSLSAProvenance, attestation.VSAPredicateType} { + mt, _ := attestation.DSSEMediaType(predicate) + statements, err := test.ExtractAnnotatedStatements(tempDir, mt) + assert.NoError(t, err) + allEnvelopes = append(allEnvelopes, statements...) + + for _, stmt := range statements { + assert.Equalf(t, predicate, stmt.Annotations[oci.InTotoPredicateType], "expected predicate-type annotation to be set to %s, got %s", predicate, stmt.Annotations[oci.InTotoPredicateType]) + assert.Equalf(t, LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage], "expected reference lifecycle stage annotation to be set to %s, got %s", LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage]) + } + } + assert.Equalf(t, tc.expectedAttestations, len(allEnvelopes), "expected %d attestations, got %d", tc.expectedAttestations, len(allEnvelopes)) + statements, err := test.ExtractAnnotatedStatements(tempDir, intoto.PayloadType) + assert.NoError(t, err) + assert.Equalf(t, tc.expectedStatements, len(statements), "expected %d statement, got %d", tc.expectedStatements, len(statements)) + } + } + }) + } +} + +func TestAddSignedLayerAnnotations(t *testing.T) { + testCases := []struct { + name string + replace bool + }{ + {"replaced", true}, + {"not replaced", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + data := []byte("signed") + signedLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType)) + signedLayers := []mutate.Addendum{ + { + Layer: signedLayer, + Annotations: map[string]string{"test": "test"}, + }, + } + data = []byte("test") + testLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType)) + mediaType := types.OCIManifestSchema1 + img := empty.Image + opts := &SigningOptions{ + Replace: tc.replace, + } + newImg, err := addSignedLayers(signedLayers, []v1.Layer{testLayer}, mediaType, img, opts) + assert.NoError(t, err) + mf, _ := newImg.RawManifest() + type Annotations struct { + Annotations map[string]string `json:"annotations"` + } + type Layers struct { + Layers []Annotations `json:"layers"` + } + l := &Layers{} + err = json.Unmarshal(mf, l) + assert.NoError(t, err) + _, ok := l.Layers[0].Annotations["test"] + assert.Truef(t, ok, "missing annotations") + }) + } +} diff --git a/pkg/attest/types.go b/pkg/attest/types.go new file mode 100644 index 0000000..e7e1963 --- /dev/null +++ b/pkg/attest/types.go @@ -0,0 +1,33 @@ +package attest + +import ( + "fmt" + + "github.com/docker/attest/pkg/attestation" +) + +type envelopeStyle string + +const ( + OCIContentDescriptor envelopeStyle = "oci-content-descriptor" + EmbeddedDSSE envelopeStyle = "embedded-dsse" + InTotoReferenceLifecycleStage = "vnd.docker.lifecycle-stage" + LifecycleStageExperimental = "experimental" +) + +type SigningOptions struct { + Replace bool + EnvelopeStyle envelopeStyle + VSAOptions *attestation.VSAOptions +} + +func EnvelopeStyle(style string) (envelopeStyle, error) { + switch style { + case string(OCIContentDescriptor): + return OCIContentDescriptor, nil + case string(EmbeddedDSSE): + return EmbeddedDSSE, nil + default: + return "", fmt.Errorf("unknown envelope style %q", style) + } +} diff --git a/pkg/attestation/types.go b/pkg/attestation/types.go index 7292e8d..609ff7e 100644 --- a/pkg/attestation/types.go +++ b/pkg/attestation/types.go @@ -1,10 +1,18 @@ package attestation -import "encoding/base64" +import ( + "encoding/base64" + "fmt" + + intoto "github.com/in-toto/in-toto-golang/in_toto" + v02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + ociv1 "github.com/opencontainers/image-spec/specs-go/v1" +) const ( - DockerDsseExtKind = "application/vnd.docker.attestation-verification.v1+json" - RekorTlExtKind = "Rekor" + DockerDsseExtKind = "application/vnd.docker.attestation-verification.v1+json" + RekorTlExtKind = "Rekor" + OCIDescriptorDSSEMediaType = ociv1.MediaTypeDescriptor + "+dsse" ) var base64Encoding = base64.StdEncoding.Strict() @@ -33,3 +41,20 @@ type DockerTlExtension struct { Kind string `json:"kind"` Data any `json:"data"` } + +func DSSEMediaType(predicateType string) (string, error) { + var predicateName string + switch predicateType { + case v02.PredicateSLSAProvenance: + predicateName = "provenance" + case intoto.PredicateSPDX: + predicateName = "spdx" + case VSAPredicateType: + predicateName = "verification_summary" + + default: + return "", fmt.Errorf("unknown predicate type %q", predicateType) + } + + return fmt.Sprintf("application/vnd.in-toto.%s+dsse", predicateName), nil +} diff --git a/pkg/attestation/vsa.go b/pkg/attestation/vsa.go new file mode 100644 index 0000000..4e0c59d --- /dev/null +++ b/pkg/attestation/vsa.go @@ -0,0 +1,55 @@ +package attestation + +import ( + "fmt" + + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/package-url/packageurl-go" +) + +const ( + VSAPredicateType = "https://slsa.dev/verification_summary/v1" +) + +type VSAPredicate struct { + Verifier VSAVerifier `json:"verifier"` + TimeVerified string `json:"timeVerified"` + ResourceUri string `json:"resourceUri"` + Policy VSAPolicy `json:"policy"` + InputAttestations []VSAInputAttestation `json:"inputAttestations"` + VerificationResult string `json:"verificationResult"` + VerifiedLevels []string `json:"verifiedLevels"` +} + +type VSAVerifier struct { + ID string `json:"id"` +} + +type VSAPolicy struct { + URI string `json:"uri"` +} + +type VSAInputAttestation struct { + Digest map[string]string `json:"digest"` + MediaType string `json:"mediaType"` +} + +type VSAOptions struct { + BuildLevel string + PolicyURI string + VerifierID string +} + +func ToVSAResourceURI(sub intoto.Subject) (string, error) { + //parse purl + purl, err := packageurl.FromString(sub.Name) + if err != nil { + return "", fmt.Errorf("failed to parse package url: %w", err) + } + quals := purl.Qualifiers.Map() + if quals["digest"] == "" { + quals["digest"] = "sha256:" + sub.Digest["sha256"] + } + purl.Qualifiers = packageurl.QualifiersFromMap(quals) + return purl.String(), nil +} diff --git a/pkg/oci/types.go b/pkg/oci/types.go index 5af15eb..268ba1a 100644 --- a/pkg/oci/types.go +++ b/pkg/oci/types.go @@ -1,8 +1,69 @@ package oci +import ( + "fmt" + "log" + + "github.com/google/go-containerregistry/pkg/authn" + "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" +) + const ( DockerReferenceType = "vnd.docker.reference.type" DockerReferenceDigest = "vnd.docker.reference.digest" AttestationManifestType = "attestation-manifest" InTotoPredicateType = "in-toto.io/predicate-type" + OciReferenceTarget = "org.opencontainers.image.ref.name" ) + +type AttestationIndex struct { + Index v1.ImageIndex + Name string +} + +func AttestationIndexFromPath(path string) (*AttestationIndex, error) { + wrapperIdx, err := layout.ImageIndexFromPath(path) + if err != nil { + return nil, fmt.Errorf("failed to load image index: %w", err) + } + + idxm, err := wrapperIdx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to get digest: %w", err) + } + imageName := idxm.Manifests[0].Annotations[OciReferenceTarget] + idxDigest := idxm.Manifests[0].Digest + + idx, err := wrapperIdx.ImageIndex(idxDigest) + if err != nil { + return nil, fmt.Errorf("failed to extract ImageIndex for digest %s: %w", idxDigest.String(), err) + } + return &AttestationIndex{ + Index: idx, + Name: imageName, + }, nil +} + +func AttestationIndexFromRemote(image string) (*AttestationIndex, error) { + ref, err := name.ParseReference(image) + if err != nil { + log.Fatalf("Failed to parse image name: %v", err) + } + // Get the authenticator from the default Docker keychain + auth, err := authn.DefaultKeychain.Resolve(ref.Context()) + if err != nil { + log.Fatalf("Failed to get authenticator: %v", err) + } + // Pull the image from the registry + idx, err := remote.Index(ref, remote.WithAuth(auth)) + if err != nil { + return nil, fmt.Errorf("failed to pull image %s: %w", image, err) + } + return &AttestationIndex{ + Index: idx, + Name: image, + }, nil +} diff --git a/scripts/gen-testdata.sh b/scripts/gen-testdata.sh new file mode 100755 index 0000000..815f1c1 --- /dev/null +++ b/scripts/gen-testdata.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -eo pipefail + +echo "Starting the process to generate testdata..." + +# Define functions +function check_command () { + command -v "$1" >/dev/null 2>&1 || { echo >&2 "This script requires $1 but it's not installed. Aborting."; exit 1; } +} + +function cleanup_testdata () { + echo "Cleaning up existing testdata..." + rm -rf "${TESTDATA_PATH:?}/${UNSIGNED_IMAGE_DIR:?}" + rm -rf "${TESTDATA_PATH:?}/${NO_PROVENANCE_IMAGE_DIR:?}" +} + +function build_unsigned_image () { + echo "Building $UNSIGNED_IMAGE_DIR..." + docker buildx build "$TEST_IMAGE_DOCKERFILE_PATH" --sbom true --provenance true --platform linux/amd64,linux/arm64 \ + --output type=oci,tar=false,name="$TEST_IMAGE_REPO:$TEST_IMAGE_TAG",dest="$TESTDATA_PATH/$UNSIGNED_IMAGE_DIR" +} + +function build_no_provenance_image () { + echo "Building unsigned $NO_PROVENANCE_IMAGE_DIR..." + docker buildx build "$TEST_IMAGE_DOCKERFILE_PATH" --sbom true --provenance false --platform linux/amd64,linux/arm64 \ + --output type=oci,tar=false,name="$TEST_IMAGE_REPO:$TEST_IMAGE_TAG",dest="$TESTDATA_PATH/$NO_PROVENANCE_IMAGE_DIR" +} + +# Check required commands +check_command docker + +TESTDATA_PATH="../test/testdata" +TEST_IMAGE_DOCKERFILE_PATH="../test" +TEST_IMAGE_REPO="test-image" +TEST_IMAGE_TAG="test" +UNSIGNED_IMAGE_DIR="unsigned-test-image" +NO_PROVENANCE_IMAGE_DIR="no-provenance-image" +ATTESTATION_PAYLOADTYPE="application/vnd.in-toto+json" + +# Run steps +cleanup_testdata +build_unsigned_image +build_no_provenance_image + +echo "Process completed successfully." diff --git a/test/Dockerfile b/test/Dockerfile new file mode 100644 index 0000000..274a7c6 --- /dev/null +++ b/test/Dockerfile @@ -0,0 +1,5 @@ +FROM alpine AS build +RUN echo "hello world" > /tmp/hello.txt + +FROM scratch +COPY --from=build /tmp/hello.txt / diff --git a/test/testdata/no-provenance-image/blobs/sha256/1c70b3e7c3a57801501ec127aa6c918c390c373294ec4fc48f2c6fe703fcc6fe b/test/testdata/no-provenance-image/blobs/sha256/1c70b3e7c3a57801501ec127aa6c918c390c373294ec4fc48f2c6fe703fcc6fe new file mode 100644 index 0000000..d2651b3 --- /dev/null +++ b/test/testdata/no-provenance-image/blobs/sha256/1c70b3e7c3a57801501ec127aa6c918c390c373294ec4fc48f2c6fe703fcc6fe @@ -0,0 +1 @@ +{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"WorkingDir":"/","OnBuild":null},"created":"2024-03-08T16:42:30.065465358Z","history":[{"created":"2024-03-08T16:42:30.065465358Z","created_by":"COPY /tmp/hello.txt / # buildkit","comment":"buildkit.dockerfile.v0"}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:b842af8c2f1451ffc802ae4139819eaea8441223357642548d8a25ab5c52cff7"]}} \ No newline at end of file diff --git a/test/testdata/no-provenance-image/blobs/sha256/1effe3a77c594e579388dc4553dbbe762e4457a099ab8b706e67f5f9fc934701 b/test/testdata/no-provenance-image/blobs/sha256/1effe3a77c594e579388dc4553dbbe762e4457a099ab8b706e67f5f9fc934701 new file mode 100644 index 0000000..19c50eb --- /dev/null +++ b/test/testdata/no-provenance-image/blobs/sha256/1effe3a77c594e579388dc4553dbbe762e4457a099ab8b706e67f5f9fc934701 @@ -0,0 +1,50 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620", + "size": 476, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e", + "size": 476, + "platform": { + "architecture": "arm64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:2aaebbb079957470e7c0adddbb054b2b4c01f717d408efba753da2bf6e8905da", + "size": 558, + "annotations": { + "vnd.docker.reference.digest": "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:9b009d6b84b1ed941070b3f919823446286a674ad669d0baa8ab2c358aeb3a82", + "size": 558, + "annotations": { + "vnd.docker.reference.digest": "sha256:7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e", + "vnd.docker.reference.type": "attestation-manifest" + }, + "platform": { + "architecture": "unknown", + "os": "unknown" + } + } + ] +} \ No newline at end of file diff --git a/test/testdata/no-provenance-image/blobs/sha256/2aaebbb079957470e7c0adddbb054b2b4c01f717d408efba753da2bf6e8905da b/test/testdata/no-provenance-image/blobs/sha256/2aaebbb079957470e7c0adddbb054b2b4c01f717d408efba753da2bf6e8905da new file mode 100644 index 0000000..da3a9d6 --- /dev/null +++ b/test/testdata/no-provenance-image/blobs/sha256/2aaebbb079957470e7c0adddbb054b2b4c01f717d408efba753da2bf6e8905da @@ -0,0 +1,19 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:b6ef78de3633e45d1c08019fbabb4464fabd6dd32e82c67ea2b2a3c4e8bacdf5", + "size": 167 + }, + "layers": [ + { + "mediaType": "application/vnd.in-toto+json", + "digest": "sha256:a9646604f9522bf59d203a86ac5c2354a573ea041b8846409c4fc0f8c4a70850", + "size": 946, + "annotations": { + "in-toto.io/predicate-type": "https://spdx.dev/Document" + } + } + ] +} \ No newline at end of file diff --git a/test/testdata/no-provenance-image/blobs/sha256/2e82727457f04f320b643cb6e13bcbafb8e0dc0adc0443f1a25666f9518c5071 b/test/testdata/no-provenance-image/blobs/sha256/2e82727457f04f320b643cb6e13bcbafb8e0dc0adc0443f1a25666f9518c5071 new file mode 100644 index 0000000..49c9cbe --- /dev/null +++ b/test/testdata/no-provenance-image/blobs/sha256/2e82727457f04f320b643cb6e13bcbafb8e0dc0adc0443f1a25666f9518c5071 @@ -0,0 +1 @@ +{"architecture":"unknown","os":"unknown","config":{},"rootfs":{"type":"layers","diff_ids":["sha256:da5651e8877b960aa30f32f317fbeba28f5e06f1ce4d3895b3b8770140280a2e"]}} \ No newline at end of file diff --git a/test/testdata/no-provenance-image/blobs/sha256/7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e b/test/testdata/no-provenance-image/blobs/sha256/7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e new file mode 100644 index 0000000..1e9ebfd --- /dev/null +++ b/test/testdata/no-provenance-image/blobs/sha256/7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e @@ -0,0 +1,16 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:d85d624a324422194b43cccd975b5752cf0acaedd668bb525fcd40c3587cc460", + "size": 453 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:97a548f8d65d9ab617f608dd621f59e0d43a3b346f34c34eb58da31f00a9b0ad", + "size": 116 + } + ] +} \ No newline at end of file diff --git a/test/testdata/no-provenance-image/blobs/sha256/97a548f8d65d9ab617f608dd621f59e0d43a3b346f34c34eb58da31f00a9b0ad b/test/testdata/no-provenance-image/blobs/sha256/97a548f8d65d9ab617f608dd621f59e0d43a3b346f34c34eb58da31f00a9b0ad new file mode 100644 index 0000000000000000000000000000000000000000..48e62f9199b1211a68948e2ffa86e777f568b0af GIT binary patch literal 116 zcmb2|=3oGW|EE08o;-P7d!g?;H!)lOF)Hj&${?{+FO9r(CDU|#%`$%jlYg?;H!)lOF)Hj&${?{+FO9r(CDU|#%`$%jlY Date: Tue, 30 Apr 2024 15:32:52 +0100 Subject: [PATCH 04/11] Return results from rego evaluation (#14) --- internal/test/test.go | 31 ++++++++++-- pkg/attest/sign.go | 100 ++++--------------------------------- pkg/attest/verify.go | 5 +- pkg/attest/verify_test.go | 8 +-- pkg/attest/vsa.go | 102 ++++++++++++++++++++++++++++++++++++++ pkg/attestation/types.go | 6 +-- pkg/policy/evaluator.go | 14 +----- pkg/policy/policy_test.go | 4 +- pkg/policy/rego.go | 24 ++++----- 9 files changed, 164 insertions(+), 130 deletions(-) create mode 100644 pkg/attest/vsa.go diff --git a/internal/test/test.go b/internal/test/test.go index 695dd1c..220d18b 100644 --- a/internal/test/test.go +++ b/internal/test/test.go @@ -18,6 +18,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/partial" intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/open-policy-agent/opa/rego" "github.com/secure-systems-lab/go-securesystemslib/dsse" ) @@ -86,10 +87,34 @@ func GetMockSigner(ctx context.Context) (dsse.SignerVerifier, error) { return signerverifier.GenKeyPair() } +type MockPolicyEvaluator struct { + EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) +} + +func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) { + if pe.EvaluateFunc != nil { + return pe.EvaluateFunc(ctx, resolver, policy, input) + } + return AllowedResult(), nil +} + func GetMockPolicy() policy.PolicyEvaluator { - return &policy.MockPolicyEvaluator{ - EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) error { - return nil + return &MockPolicyEvaluator{ + EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pfs []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) { + return AllowedResult(), nil + }, + } +} + +func AllowedResult() *rego.ResultSet { + return ®o.ResultSet{ + { + Bindings: rego.Vars{}, + Expressions: []*rego.ExpressionValue{ + { + Value: true, + }, + }, }, } } diff --git a/pkg/attest/sign.go b/pkg/attest/sign.go index 7641227..ec1c9ca 100644 --- a/pkg/attest/sign.go +++ b/pkg/attest/sign.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strings" - "time" "github.com/docker/attest/pkg/attestation" "github.com/docker/attest/pkg/oci" @@ -142,9 +140,16 @@ func SignIndexAttestations(ctx context.Context, idx v1.ImageIndex, signer dsse.S } if opts.VSAOptions != nil { - newImg, err = addVSA(ctx, newImg, statements, manifest.MediaType, signer, opts) + newLayer, err := generateVSA(ctx, newImg, statements, signer, opts) if err != nil { - return nil, fmt.Errorf("failed to add VSA: %w", err) + return nil, fmt.Errorf("failed to generate VSA: %w", err) + } + vsaReplace := &SigningOptions{ + Replace: false, + } + newImg, err = addSignedLayers([]mutate.Addendum{*newLayer}, layers, manifest.MediaType, newImg, vsaReplace) + if err != nil { + return nil, fmt.Errorf("failed to add VSA layer: %w", err) } } newDesc, err := partial.Descriptor(newImg) @@ -171,93 +176,6 @@ func SignIndexAttestations(ctx context.Context, idx v1.ImageIndex, signer dsse.S return newIndex, nil } -func addVSA(ctx context.Context, image v1.Image, stmt []*intoto.Statement, outerMediaType types.MediaType, signer dsse.SignerVerifier, opts *SigningOptions) (v1.Image, error) { - if len(stmt) == 0 { - return nil, fmt.Errorf("no attestations found to generate VSA from") - } - sub := stmt[0].Subject[0] - stype := stmt[0].Type - - uri, err := attestation.ToVSAResourceURI(sub) - if err != nil { - return nil, fmt.Errorf("failed to generate VSA resource URI: %w", err) - } - - inputs := make([]attestation.VSAInputAttestation, 0, len(stmt)) - layers, err := image.Layers() - if err != nil { - return nil, fmt.Errorf("failed to get layers: %w", err) - } - for _, layer := range layers { - mt, err := layer.MediaType() - if err != nil { - return nil, fmt.Errorf("failed to get layer media type: %w", err) - } - mediaType := string(mt) - if !strings.HasPrefix(mediaType, "application/vnd.in-toto.") || - !strings.HasSuffix(mediaType, "+dsse") { - continue - } - - dgst, err := layer.Digest() - if err != nil { - return nil, fmt.Errorf("failed to get layer digest: %w", err) - } - inputs = append(inputs, attestation.VSAInputAttestation{ - Digest: map[string]string{"sha256": dgst.Hex}, - MediaType: string(mt), - }) - } - vsaStatement := &intoto.Statement{ - StatementHeader: intoto.StatementHeader{ - PredicateType: attestation.VSAPredicateType, - Type: stype, - Subject: stmt[0].Subject, - }, - Predicate: attestation.VSAPredicate{ - Verifier: attestation.VSAVerifier{ - ID: opts.VSAOptions.VerifierID, - }, - TimeVerified: time.Now().UTC().Format(time.RFC3339), - ResourceUri: uri, - Policy: attestation.VSAPolicy{URI: opts.VSAOptions.PolicyURI}, - VerificationResult: "PASSED", - VerifiedLevels: []string{opts.VSAOptions.BuildLevel}, - InputAttestations: inputs, - }, - } - payload, err := json.Marshal(vsaStatement) - if err != nil { - return nil, fmt.Errorf("failed to marshal statement: %w", err) - } - env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer) - if err != nil { - return nil, fmt.Errorf("failed to sign statement: %w", err) - } - mediaType, err := attestation.DSSEMediaType(vsaStatement.PredicateType) - if err != nil { - return nil, fmt.Errorf("failed to get DSSE media type: %w", err) - } - - data, err := json.Marshal(env) - if err != nil { - return nil, fmt.Errorf("failed to marshal envelope: %w", err) - } - mt := types.MediaType(mediaType) - newLayer := static.NewLayer(data, mt) - ann := make(map[string]string) - ann[InTotoReferenceLifecycleStage] = LifecycleStageExperimental - ann[oci.InTotoPredicateType] = attestation.VSAPredicateType - withAnnotations := mutate.Addendum{ - Layer: newLayer, - Annotations: ann, - } - opts = &SigningOptions{ - Replace: false, - } - return addSignedLayers([]mutate.Addendum{withAnnotations}, layers, outerMediaType, image, opts) -} - func addSignedLayers(signedLayers []mutate.Addendum, originalLayers []v1.Layer, mediaType types.MediaType, attestationImage v1.Image, opts *SigningOptions) (v1.Image, error) { var err error if opts.Replace { diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index 52f05c6..0a963d1 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -31,10 +31,13 @@ func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, f if err != nil { return err } - err = evaluator.Evaluate(ctx, resolver, files, input) + rs, err := evaluator.Evaluate(ctx, resolver, files, input) if err != nil { return fmt.Errorf("policy evaluation failed: %w", err) } + if !rs.Allowed() { + return fmt.Errorf("policy evaluation failed: %s", fmt.Sprint(rs)) + } return nil } diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go index 42ea7b7..19e55ee 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -8,9 +8,11 @@ import ( "path/filepath" "testing" + "github.com/docker/attest/internal/test" "github.com/docker/attest/pkg/attestation" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/policy" + "github.com/open-policy-agent/opa/rego" "github.com/stretchr/testify/assert" ) @@ -41,9 +43,9 @@ func TestVerifyAttestations(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - mockPE := policy.MockPolicyEvaluator{ - EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) error { - return tc.policyEvaluationError + mockPE := test.MockPolicyEvaluator{ + EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pfs []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) { + return test.AllowedResult(), tc.policyEvaluationError }, } diff --git a/pkg/attest/vsa.go b/pkg/attest/vsa.go new file mode 100644 index 0000000..8343002 --- /dev/null +++ b/pkg/attest/vsa.go @@ -0,0 +1,102 @@ +package attest + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/oci" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/secure-systems-lab/go-securesystemslib/dsse" +) + +func generateVSA(ctx context.Context, image v1.Image, stmt []*intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*mutate.Addendum, error) { + if len(stmt) == 0 { + return nil, fmt.Errorf("no attestations found to generate VSA from") + } + sub := stmt[0].Subject[0] + stype := stmt[0].Type + + uri, err := attestation.ToVSAResourceURI(sub) + if err != nil { + return nil, fmt.Errorf("failed to generate VSA resource URI: %w", err) + } + + inputs := make([]attestation.VSAInputAttestation, 0, len(stmt)) + layers, err := image.Layers() + if err != nil { + return nil, fmt.Errorf("failed to get layers: %w", err) + } + for _, layer := range layers { + mt, err := layer.MediaType() + if err != nil { + return nil, fmt.Errorf("failed to get layer media type: %w", err) + } + mediaType := string(mt) + if !strings.HasPrefix(mediaType, "application/vnd.in-toto.") || + !strings.HasSuffix(mediaType, "+dsse") { + continue + } + + dgst, err := layer.Digest() + if err != nil { + return nil, fmt.Errorf("failed to get layer digest: %w", err) + } + inputs = append(inputs, attestation.VSAInputAttestation{ + Digest: map[string]string{"sha256": dgst.Hex}, + MediaType: string(mt), + }) + } + vsaStatement := &intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + PredicateType: attestation.VSAPredicateType, + Type: stype, + Subject: stmt[0].Subject, + }, + Predicate: attestation.VSAPredicate{ + Verifier: attestation.VSAVerifier{ + ID: opts.VSAOptions.VerifierID, + }, + TimeVerified: time.Now().UTC().Format(time.RFC3339), + ResourceUri: uri, + Policy: attestation.VSAPolicy{URI: opts.VSAOptions.PolicyURI}, + VerificationResult: "PASSED", + VerifiedLevels: []string{opts.VSAOptions.BuildLevel}, + InputAttestations: inputs, + }, + } + payload, err := json.Marshal(vsaStatement) + if err != nil { + return nil, fmt.Errorf("failed to marshal statement: %w", err) + } + env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer) + if err != nil { + return nil, fmt.Errorf("failed to sign statement: %w", err) + } + mediaType, err := attestation.DSSEMediaType(vsaStatement.PredicateType) + if err != nil { + return nil, fmt.Errorf("failed to get DSSE media type: %w", err) + } + + data, err := json.Marshal(env) + if err != nil { + return nil, fmt.Errorf("failed to marshal envelope: %w", err) + } + mt := types.MediaType(mediaType) + newLayer := static.NewLayer(data, mt) + ann := make(map[string]string) + ann[InTotoReferenceLifecycleStage] = LifecycleStageExperimental + ann[oci.InTotoPredicateType] = attestation.VSAPredicateType + withAnnotations := mutate.Addendum{ + Layer: newLayer, + Annotations: ann, + } + return &withAnnotations, nil +} diff --git a/pkg/attestation/types.go b/pkg/attestation/types.go index 609ff7e..54068b5 100644 --- a/pkg/attestation/types.go +++ b/pkg/attestation/types.go @@ -10,9 +10,9 @@ import ( ) const ( - DockerDsseExtKind = "application/vnd.docker.attestation-verification.v1+json" - RekorTlExtKind = "Rekor" - OCIDescriptorDSSEMediaType = ociv1.MediaTypeDescriptor + "+dsse" + DockerDsseExtKind = "application/vnd.docker.attestation-verification.v1+json" + RekorTlExtKind = "Rekor" + OCIDescriptorDSSEMediaType = ociv1.MediaTypeDescriptor + "+dsse" ) var base64Encoding = base64.StdEncoding.Strict() diff --git a/pkg/policy/evaluator.go b/pkg/policy/evaluator.go index 42cce24..d512e56 100644 --- a/pkg/policy/evaluator.go +++ b/pkg/policy/evaluator.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/docker/attest/pkg/oci" + "github.com/open-policy-agent/opa/rego" ) type policyEvaluatorCtxKeyType struct{} @@ -26,16 +27,5 @@ func GetPolicyEvaluator(ctx context.Context) (PolicyEvaluator, error) { } type PolicyEvaluator interface { - Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) error -} - -type MockPolicyEvaluator struct { - EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) error -} - -func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) error { - if pe.EvaluateFunc != nil { - return pe.EvaluateFunc(ctx, resolver, policy, input) - } - return nil + Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) } diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index 9e404b8..3740d36 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -97,12 +97,12 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { policyFiles, err := policy.ResolvePolicy(ctx, tc.resolver, tc.policy) assert.NoErrorf(t, err, "failed to resolve policy") - err = re.Evaluate(ctx, tc.resolver, policyFiles, tc.input) + rs, err := re.Evaluate(ctx, tc.resolver, policyFiles, tc.input) if tc.expectSuccess { assert.NoErrorf(t, err, "Evaluate failed") } else { - assert.Errorf(t, err, "Evaluate should have failed") + assert.False(t, rs.Allowed(), "Evaluate should have failed") } }) } diff --git a/pkg/policy/rego.go b/pkg/policy/rego.go index fc9141a..48ca0f3 100644 --- a/pkg/policy/rego.go +++ b/pkg/policy/rego.go @@ -23,15 +23,17 @@ import ( type regoEvaluator struct { debug bool + query string } func NewRegoEvaluator(debug bool) PolicyEvaluator { return ®oEvaluator{ debug: debug, + query: "data.attestations.allow", } } -func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, files []*PolicyFile, input *PolicyInput) error { +func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, files []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) { var regoOpts []func(*rego.Rego) // Create a new in-memory store @@ -40,7 +42,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR params.Write = true txn, err := store.NewTransaction(ctx, params) if err != nil { - return err + return nil, err } for _, target := range files { @@ -48,11 +50,11 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR if filepath.Ext(target.Path) == ".yaml" { yamlData, err := loadYAML(target.Path, target.Content) if err != nil { - return err + return nil, err } err = store.Write(ctx, txn, storage.AddOp, storage.Path{}, yamlData) if err != nil { - return err + return nil, err } } else { regoOpts = append(regoOpts, rego.Module(target.Path, string(target.Content))) @@ -62,7 +64,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR err = store.Commit(ctx, txn) if err != nil { store.Abort(ctx, txn) - return err + return nil, err } if re.debug { @@ -74,7 +76,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR } regoOpts = append(regoOpts, - rego.Query("data.docker.allow"), + rego.Query(re.query), rego.StrictBuiltinErrors(true), rego.Input(input), rego.Store(store), @@ -85,15 +87,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR r := rego.New(regoOpts...) rs, err := r.Eval(ctx) - if err != nil { - return fmt.Errorf("error from Eval: %w", err) - } - - if !rs.Allowed() { - return fmt.Errorf("policy evaluation failed") - } - - return nil + return &rs, err } var dynamicObj = types.NewObject(nil, types.NewDynamicProperty(types.S, types.A)) From 94d7f99c3c272cbf6bcc03acd8ba89524491329e Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Tue, 30 Apr 2024 09:34:17 -0500 Subject: [PATCH 05/11] refactor: remove evelopeStyle --- pkg/attest/sign.go | 50 ++++++++--------------------------- pkg/attest/sign_test.go | 58 +++++++++++++---------------------------- pkg/attest/types.go | 26 +++--------------- 3 files changed, 33 insertions(+), 101 deletions(-) diff --git a/pkg/attest/sign.go b/pkg/attest/sign.go index ec1c9ca..ea8846a 100644 --- a/pkg/attest/sign.go +++ b/pkg/attest/sign.go @@ -15,7 +15,6 @@ import ( "github.com/google/go-containerregistry/pkg/v1/static" "github.com/google/go-containerregistry/pkg/v1/types" intoto "github.com/in-toto/in-toto-golang/in_toto" - ociv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/secure-systems-lab/go-securesystemslib/dsse" ) @@ -81,44 +80,17 @@ func SignIndexAttestations(ctx context.Context, idx v1.ImageIndex, signer dsse.S } ann[InTotoReferenceLifecycleStage] = LifecycleStageExperimental - var env *attestation.Envelope - var mediaType string - switch opts.EnvelopeStyle { - case OCIContentDescriptor: - // Ensure we sign just the digest, size, and media type - payloadDesc := v1.Descriptor{ - Digest: layerDesc.Digest, - Size: layerDesc.Size, - MediaType: layerDesc.MediaType, - } - payload, err := json.Marshal(payloadDesc) - if err != nil { - return nil, fmt.Errorf("failed to marshal descriptor: %w", err) - } - env, err = attestation.SignDSSE(ctx, payload, ociv1.MediaTypeDescriptor, signer) - if err != nil { - return nil, fmt.Errorf("failed to sign statement: %w", err) - } - ann[oci.DockerReferenceDigest] = layerDesc.Digest.String() - // this is a reference type - opts.Replace = false - mediaType = attestation.OCIDescriptorDSSEMediaType - case EmbeddedDSSE: - payload, err := json.Marshal(stmt) - if err != nil { - return nil, fmt.Errorf("failed to marshal statement: %w", err) - } - env, err = attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer) - if err != nil { - return nil, fmt.Errorf("failed to sign statement: %w", err) - } - mediaType, err = attestation.DSSEMediaType(stmt.PredicateType) - if err != nil { - return nil, fmt.Errorf("failed to get DSSE media type: %w", err) - } - - default: - return nil, fmt.Errorf("unknown envelope style %q", opts.EnvelopeStyle) + payload, err := json.Marshal(stmt) + if err != nil { + return nil, fmt.Errorf("failed to marshal statement: %w", err) + } + env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer) + if err != nil { + return nil, fmt.Errorf("failed to sign statement: %w", err) + } + mediaType, err := attestation.DSSEMediaType(stmt.PredicateType) + if err != nil { + return nil, fmt.Errorf("failed to get DSSE media type: %w", err) } data, err := json.Marshal(env) diff --git a/pkg/attest/sign_test.go b/pkg/attest/sign_test.go index 3b7e39a..c8b726d 100644 --- a/pkg/attest/sign_test.go +++ b/pkg/attest/sign_test.go @@ -35,18 +35,14 @@ func TestSignVerifyOCILayout(t *testing.T) { TestImage string expectedStatements int expectedAttestations int - envelopeStyle envelopeStyle replace bool }{ - {"signed replaced (does nothing)", UnsignedTestImage, 4, 4, OCIContentDescriptor, true}, - {"without replace", UnsignedTestImage, 4, 4, OCIContentDescriptor, false}, - {"embedded", UnsignedTestImage, 0, 6, EmbeddedDSSE, true}, - {"embedded", UnsignedTestImage, 4, 6, EmbeddedDSSE, false}, - + {"signed replaced (does nothing)", UnsignedTestImage, 0, 6, true}, + {"without replace", UnsignedTestImage, 4, 6, false}, // image without provenance doesn't fail - {"no provenance (replace)", NoProvenanceImage, 0, 4, EmbeddedDSSE, true}, - {"no provenance (no replace)", NoProvenanceImage, 2, 4, EmbeddedDSSE, false}, + {"no provenance (replace)", NoProvenanceImage, 0, 4, true}, + {"no provenance (no replace)", NoProvenanceImage, 2, 4, false}, } policyResolver := &policy.PolicyOptions{ LocalPolicyDir: LocalPolicyDir, @@ -56,8 +52,7 @@ func TestSignVerifyOCILayout(t *testing.T) { tempDir := test.CreateTempDir(t, "", TestTempDir) outputLayout := tempDir opts := &SigningOptions{ - Replace: tc.replace, - EnvelopeStyle: tc.envelopeStyle, + Replace: tc.replace, VSAOptions: &attestation.VSAOptions{ BuildLevel: "SLSA_BUILD_LEVEL_3", PolicyURI: "https://docker.com/attest/policy", @@ -94,39 +89,22 @@ func TestSignVerifyOCILayout(t *testing.T) { vsas, err := test.ExtractAnnotatedStatements(tempDir, mt) assert.NoError(t, err) assert.Equalf(t, len(vsas), 2, "expected %d vsa statement, got %d", 2, len(vsas)) + var allEnvelopes []*test.AnnotatedStatement + for _, predicate := range []string{intoto.PredicateSPDX, v02.PredicateSLSAProvenance, attestation.VSAPredicateType} { + mt, _ := attestation.DSSEMediaType(predicate) + statements, err := test.ExtractAnnotatedStatements(tempDir, mt) + assert.NoError(t, err) + allEnvelopes = append(allEnvelopes, statements...) - switch tc.envelopeStyle { - case OCIContentDescriptor: - { - statements, err := test.ExtractAnnotatedStatements(tempDir, intoto.PayloadType) - assert.NoError(t, err) - assert.Equalf(t, tc.expectedStatements, len(statements), "expected %d statement, got %d", tc.expectedStatements, len(statements)) - - statements, err = test.ExtractAnnotatedStatements(tempDir, attestation.OCIDescriptorDSSEMediaType) - assert.NoError(t, err) - - assert.Equalf(t, tc.expectedAttestations, len(statements), "expected %d attestations, got %d", tc.expectedAttestations, len(statements)) - } - case EmbeddedDSSE: - { - var allEnvelopes []*test.AnnotatedStatement - for _, predicate := range []string{intoto.PredicateSPDX, v02.PredicateSLSAProvenance, attestation.VSAPredicateType} { - mt, _ := attestation.DSSEMediaType(predicate) - statements, err := test.ExtractAnnotatedStatements(tempDir, mt) - assert.NoError(t, err) - allEnvelopes = append(allEnvelopes, statements...) - - for _, stmt := range statements { - assert.Equalf(t, predicate, stmt.Annotations[oci.InTotoPredicateType], "expected predicate-type annotation to be set to %s, got %s", predicate, stmt.Annotations[oci.InTotoPredicateType]) - assert.Equalf(t, LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage], "expected reference lifecycle stage annotation to be set to %s, got %s", LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage]) - } - } - assert.Equalf(t, tc.expectedAttestations, len(allEnvelopes), "expected %d attestations, got %d", tc.expectedAttestations, len(allEnvelopes)) - statements, err := test.ExtractAnnotatedStatements(tempDir, intoto.PayloadType) - assert.NoError(t, err) - assert.Equalf(t, tc.expectedStatements, len(statements), "expected %d statement, got %d", tc.expectedStatements, len(statements)) + for _, stmt := range statements { + assert.Equalf(t, predicate, stmt.Annotations[oci.InTotoPredicateType], "expected predicate-type annotation to be set to %s, got %s", predicate, stmt.Annotations[oci.InTotoPredicateType]) + assert.Equalf(t, LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage], "expected reference lifecycle stage annotation to be set to %s, got %s", LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage]) } } + assert.Equalf(t, tc.expectedAttestations, len(allEnvelopes), "expected %d attestations, got %d", tc.expectedAttestations, len(allEnvelopes)) + statements, err := test.ExtractAnnotatedStatements(tempDir, intoto.PayloadType) + assert.NoError(t, err) + assert.Equalf(t, tc.expectedStatements, len(statements), "expected %d statement, got %d", tc.expectedStatements, len(statements)) }) } } diff --git a/pkg/attest/types.go b/pkg/attest/types.go index e7e1963..a837e8d 100644 --- a/pkg/attest/types.go +++ b/pkg/attest/types.go @@ -1,33 +1,15 @@ package attest import ( - "fmt" - "github.com/docker/attest/pkg/attestation" ) -type envelopeStyle string - const ( - OCIContentDescriptor envelopeStyle = "oci-content-descriptor" - EmbeddedDSSE envelopeStyle = "embedded-dsse" - InTotoReferenceLifecycleStage = "vnd.docker.lifecycle-stage" - LifecycleStageExperimental = "experimental" + InTotoReferenceLifecycleStage = "vnd.docker.lifecycle-stage" + LifecycleStageExperimental = "experimental" ) type SigningOptions struct { - Replace bool - EnvelopeStyle envelopeStyle - VSAOptions *attestation.VSAOptions -} - -func EnvelopeStyle(style string) (envelopeStyle, error) { - switch style { - case string(OCIContentDescriptor): - return OCIContentDescriptor, nil - case string(EmbeddedDSSE): - return EmbeddedDSSE, nil - default: - return "", fmt.Errorf("unknown envelope style %q", style) - } + Replace bool + VSAOptions *attestation.VSAOptions } From 80f72a0059a05a6d4a284a9054797b5b1eb9193b Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Tue, 30 Apr 2024 12:23:07 -0500 Subject: [PATCH 06/11] refactor: SignIndexAttestations --- pkg/attest/sign.go | 184 +++++++++++++-------------------- pkg/attest/sign_test.go | 15 ++- pkg/attest/vsa.go | 30 +++--- pkg/attestation/attestation.go | 82 +++++++++++++++ pkg/attestation/types.go | 24 +++++ 5 files changed, 204 insertions(+), 131 deletions(-) create mode 100644 pkg/attestation/attestation.go diff --git a/pkg/attest/sign.go b/pkg/attest/sign.go index ea8846a..a554e6e 100644 --- a/pkg/attest/sign.go +++ b/pkg/attest/sign.go @@ -6,7 +6,6 @@ import ( "fmt" "github.com/docker/attest/pkg/attestation" - "github.com/docker/attest/pkg/oci" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/match" @@ -19,159 +18,122 @@ import ( ) func SignIndexAttestations(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, opts *SigningOptions) (v1.ImageIndex, error) { - indexManifest, err := idx.IndexManifest() + // extract attestation manifests from index + attestationManifests, err := attestation.GetAttestationManifestsFromIndex(idx) if err != nil { - return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err) + return nil, fmt.Errorf("failed to get attestation manifests: %w", err) } - var originalManifestDigests []v1.Hash - var muts []mutate.IndexAddendum - for _, manifest := range indexManifest.Manifests { - if manifest.Annotations[oci.DockerReferenceType] != oci.AttestationManifestType { - continue - } - - originalManifestDigests = append(originalManifestDigests, manifest.Digest) - - attestationImage, err := idx.Image(manifest.Digest) + // sign every attestation layer in each manifest + for _, manifest := range attestationManifests { + attestationLayers, err := attestation.GetAttestationsFromImage(manifest.Attestation.Image) if err != nil { - return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", manifest.Digest.String(), err) + return nil, fmt.Errorf("failed to get attestations from image: %w", err) } - layers, err := attestationImage.Layers() + signedLayers, err := signLayers(ctx, attestationLayers, signer) if err != nil { - return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err) + return nil, fmt.Errorf("failed to sign attestations: %w", err) } - - var signedLayers []mutate.Addendum - var originalLayers []v1.Layer - var statements []*intoto.Statement - - for _, layer := range layers { - // 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() - mt, err := layer.MediaType() - if err != nil { - return nil, fmt.Errorf("failed to get layer media type: %w", err) - } - - if mt != types.MediaType(intoto.PayloadType) { - originalLayers = append(originalLayers, layer) - continue - } - var stmt = new(intoto.Statement) - err = json.NewDecoder(r).Decode(&stmt) - if err != nil { - return nil, fmt.Errorf("failed to decode statement layer contents: %w", err) - } - - statements = append(statements, stmt) - layerDesc, err := partial.Descriptor(layer) - if err != nil { - return nil, fmt.Errorf("failed to get descriptor for layer: %w", err) - } - // copy original annotations and add new ones - ann := make(map[string]string) - for k, v := range layerDesc.Annotations { - ann[k] = v - } - ann[InTotoReferenceLifecycleStage] = LifecycleStageExperimental - - payload, err := json.Marshal(stmt) - if err != nil { - return nil, fmt.Errorf("failed to marshal statement: %w", err) - } - env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer) - if err != nil { - return nil, fmt.Errorf("failed to sign statement: %w", err) - } - mediaType, err := attestation.DSSEMediaType(stmt.PredicateType) - if err != nil { - return nil, fmt.Errorf("failed to get DSSE media type: %w", err) - } - - data, err := json.Marshal(env) - if err != nil { - return nil, fmt.Errorf("failed to marshal envelope: %w", err) - } - newLayer := static.NewLayer(data, types.MediaType(mediaType)) - - withAnnotations := mutate.Addendum{ - Layer: newLayer, - Annotations: ann, - } - signedLayers = append(signedLayers, withAnnotations) - } - - newImg, err := addSignedLayers(signedLayers, originalLayers, manifest.MediaType, attestationImage, opts) - if err != nil { - return nil, fmt.Errorf("failed to add signed layers: %w", err) - } - if opts.VSAOptions != nil { - newLayer, err := generateVSA(ctx, newImg, statements, signer, opts) + newLayer, err := generateVSA(ctx, manifest, signer, opts) if err != nil { return nil, fmt.Errorf("failed to generate VSA: %w", err) } - vsaReplace := &SigningOptions{ - Replace: false, - } - newImg, err = addSignedLayers([]mutate.Addendum{*newLayer}, layers, manifest.MediaType, newImg, vsaReplace) - if err != nil { - return nil, fmt.Errorf("failed to add VSA layer: %w", err) - } + signedLayers = append(signedLayers, *newLayer) + } + newImg, err := addSignedLayers(signedLayers, manifest, opts) + if err != nil { + return nil, fmt.Errorf("failed to add signed layers: %w", err) } newDesc, err := partial.Descriptor(newImg) if err != nil { return nil, fmt.Errorf("failed to get descriptor: %w", err) } - cf, err := attestationImage.ConfigFile() + cf, err := manifest.Attestation.Image.ConfigFile() if err != nil { return nil, fmt.Errorf("failed to get config file: %w", err) } newDesc.Platform = cf.Platform() newDesc.MediaType = manifest.MediaType newDesc.Annotations = manifest.Annotations - - muts = append(muts, mutate.IndexAddendum{ + idx = mutate.RemoveManifests(idx, match.Digests(manifest.Digest)) + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ Add: newImg, Descriptor: *newDesc, }) } - // create new index with signed images - newIndex := mutate.RemoveManifests(idx, match.Digests(originalManifestDigests...)) - newIndex = mutate.AppendManifests(newIndex, muts...) - - return newIndex, nil + return idx, nil } -func addSignedLayers(signedLayers []mutate.Addendum, originalLayers []v1.Layer, mediaType types.MediaType, attestationImage v1.Image, opts *SigningOptions) (v1.Image, error) { +// signLayers signs each intoto attestation layer with the given signer +func signLayers(ctx context.Context, layers []attestation.AttestationLayer, signer dsse.SignerVerifier) ([]mutate.Addendum, error) { + var signedLayers []mutate.Addendum + for _, layer := range layers { + // only sign intoto layers + if layer.MediaType != types.MediaType(intoto.PayloadType) { + continue + } + // mark attestation as experimental + layer.Annotations[InTotoReferenceLifecycleStage] = LifecycleStageExperimental + + // sign the statement + payload, err := json.Marshal(layer.Statement) + if err != nil { + return nil, fmt.Errorf("failed to marshal statement: %w", err) + } + env, err := attestation.SignDSSE(ctx, payload, intoto.PayloadType, signer) + if err != nil { + return nil, fmt.Errorf("failed to sign statement: %w", err) + } + mediaType, err := attestation.DSSEMediaType(layer.Statement.PredicateType) + if err != nil { + return nil, fmt.Errorf("failed to get DSSE media type: %w", err) + } + data, err := json.Marshal(env) + if err != nil { + return nil, fmt.Errorf("failed to marshal envelope: %w", err) + } + newLayer := static.NewLayer(data, types.MediaType(mediaType)) + withAnnotations := mutate.Addendum{ + Layer: newLayer, + Annotations: layer.Annotations, + } + signedLayers = append(signedLayers, withAnnotations) + } + return signedLayers, nil +} + +// addSignedLayers adds signed layers to a new or existing attestation image +func addSignedLayers(signedLayers []mutate.Addendum, manifest attestation.AttestationManifest, opts *SigningOptions) (v1.Image, error) { var err error if opts.Replace { + // create a new attestation image with only signed layers newImg := empty.Image - newImg = mutate.MediaType(newImg, mediaType) + newImg = mutate.MediaType(newImg, manifest.MediaType) newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.image.config.v1+json") for _, layer := range signedLayers { newImg, err = mutate.Append(newImg, layer) if err != nil { - return nil, fmt.Errorf("failed to append layer: %w", err) + return nil, fmt.Errorf("failed to append signed layer: %w", err) } } - newImg, err = mutate.AppendLayers(newImg, originalLayers...) - if err != nil { - return nil, fmt.Errorf("failed to append original layers: %w", err) + // add any existing unsigned (non-intoto) layers to the new image + for _, layer := range manifest.Attestation.Layers { + if layer.MediaType != types.MediaType(intoto.PayloadType) { + newImg, err = mutate.AppendLayers(newImg, layer.Layer) + if err != nil { + return nil, fmt.Errorf("failed to append unsigned layer: %w", err) + } + } } return newImg, nil - } + // Add signed layers to the existing image for _, layer := range signedLayers { - attestationImage, err = mutate.Append(attestationImage, layer) + manifest.Attestation.Image, err = mutate.Append(manifest.Attestation.Image, layer) if err != nil { return nil, fmt.Errorf("failed to append layer: %w", err) } } - return attestationImage, nil + return manifest.Attestation.Image, nil } diff --git a/pkg/attest/sign_test.go b/pkg/attest/sign_test.go index c8b726d..bff0f57 100644 --- a/pkg/attest/sign_test.go +++ b/pkg/attest/sign_test.go @@ -131,11 +131,22 @@ func TestAddSignedLayerAnnotations(t *testing.T) { data = []byte("test") testLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType)) mediaType := types.OCIManifestSchema1 - img := empty.Image opts := &SigningOptions{ Replace: tc.replace, } - newImg, err := addSignedLayers(signedLayers, []v1.Layer{testLayer}, mediaType, img, opts) + manifest := attestation.AttestationManifest{ + MediaType: mediaType, + Attestation: attestation.AttestationImage{ + Image: empty.Image, + Layers: []attestation.AttestationLayer{ + { + Layer: testLayer, + Statement: &intoto.Statement{}, + }, + }, + }, + } + newImg, err := addSignedLayers(signedLayers, manifest, opts) assert.NoError(t, err) mf, _ := newImg.RawManifest() type Annotations struct { diff --git a/pkg/attest/vsa.go b/pkg/attest/vsa.go index 8343002..015ea0a 100644 --- a/pkg/attest/vsa.go +++ b/pkg/attest/vsa.go @@ -9,7 +9,6 @@ import ( "github.com/docker/attest/pkg/attestation" "github.com/docker/attest/pkg/oci" - v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/static" "github.com/google/go-containerregistry/pkg/v1/types" @@ -17,35 +16,30 @@ import ( "github.com/secure-systems-lab/go-securesystemslib/dsse" ) -func generateVSA(ctx context.Context, image v1.Image, stmt []*intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*mutate.Addendum, error) { - if len(stmt) == 0 { +// generateVSA generates a VSA from the attestation manifest +// TODO: remove signing logic and move generateVSA to attestation/vsa.go +func generateVSA(ctx context.Context, manifest attestation.AttestationManifest, signer dsse.SignerVerifier, opts *SigningOptions) (*mutate.Addendum, error) { + if len(manifest.Attestation.Layers) == 0 { return nil, fmt.Errorf("no attestations found to generate VSA from") } - sub := stmt[0].Subject[0] - stype := stmt[0].Type + sub := manifest.Attestation.Layers[0].Statement.Subject[0] + stype := manifest.Attestation.Layers[0].Statement.Type uri, err := attestation.ToVSAResourceURI(sub) if err != nil { return nil, fmt.Errorf("failed to generate VSA resource URI: %w", err) } - inputs := make([]attestation.VSAInputAttestation, 0, len(stmt)) - layers, err := image.Layers() - if err != nil { - return nil, fmt.Errorf("failed to get layers: %w", err) - } - for _, layer := range layers { - mt, err := layer.MediaType() + inputs := make([]attestation.VSAInputAttestation, 0, len(manifest.Attestation.Layers)) + for _, att := range manifest.Attestation.Layers { + mt, err := att.Layer.MediaType() if err != nil { return nil, fmt.Errorf("failed to get layer media type: %w", err) } - mediaType := string(mt) - if !strings.HasPrefix(mediaType, "application/vnd.in-toto.") || - !strings.HasSuffix(mediaType, "+dsse") { + if !strings.HasSuffix(string(mt), "+dsse") { continue } - - dgst, err := layer.Digest() + dgst, err := att.Layer.Digest() if err != nil { return nil, fmt.Errorf("failed to get layer digest: %w", err) } @@ -58,7 +52,7 @@ func generateVSA(ctx context.Context, image v1.Image, stmt []*intoto.Statement, StatementHeader: intoto.StatementHeader{ PredicateType: attestation.VSAPredicateType, Type: stype, - Subject: stmt[0].Subject, + Subject: manifest.Attestation.Layers[0].Statement.Subject, }, Predicate: attestation.VSAPredicate{ Verifier: attestation.VSAVerifier{ diff --git a/pkg/attestation/attestation.go b/pkg/attestation/attestation.go new file mode 100644 index 0000000..81e3f5e --- /dev/null +++ b/pkg/attestation/attestation.go @@ -0,0 +1,82 @@ +package attestation + +import ( + "encoding/json" + "fmt" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/types" + intoto "github.com/in-toto/in-toto-golang/in_toto" +) + +// GetAttestationManifestsFromIndex extracts all attestation manifests from an index +func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]AttestationManifest, error) { + idx, err := index.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err) + } + var attestationManifests []AttestationManifest + for _, manifest := range idx.Manifests { + if manifest.Annotations[DockerReferenceType] == AttestationManifestType { + attestationImage, err := index.Image(manifest.Digest) + if err != nil { + return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", manifest.Digest.String(), err) + } + attestationLayers, err := GetAttestationsFromImage(attestationImage) + if err != nil { + return nil, fmt.Errorf("failed to get attestations from image: %w", err) + } + attestationManifests = append(attestationManifests, + AttestationManifest{ + Manifest: manifest, + Attestation: AttestationImage{ + Layers: attestationLayers, + Image: attestationImage}, + MediaType: manifest.MediaType, + Annotations: manifest.Annotations, + Digest: manifest.Digest}) + } + } + return attestationManifests, nil +} + +// GetAttestationsFromImage extracts all attestation layers from an image +func GetAttestationsFromImage(image v1.Image) ([]AttestationLayer, error) { + layers, err := image.Layers() + if err != nil { + return nil, fmt.Errorf("failed to extract layers from image: %w", err) + } + var attestationLayers []AttestationLayer + for _, layer := range layers { + // 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() + mt, err := layer.MediaType() + if err != nil { + return nil, fmt.Errorf("failed to get layer media type: %w", err) + } + layerDesc, err := partial.Descriptor(layer) + if err != nil { + return nil, fmt.Errorf("failed to get descriptor for layer: %w", err) + } + // copy original annotations + ann := make(map[string]string) + for k, v := range layerDesc.Annotations { + ann[k] = v + } + // only decode intoto statements + var stmt = new(intoto.Statement) + if mt == types.MediaType(intoto.PayloadType) { + err = json.NewDecoder(r).Decode(&stmt) + if err != nil { + return nil, fmt.Errorf("failed to decode statement layer contents: %w", err) + } + } + attestationLayers = append(attestationLayers, AttestationLayer{Layer: layer, MediaType: mt, Statement: stmt, Annotations: ann}) + } + return attestationLayers, nil +} diff --git a/pkg/attestation/types.go b/pkg/attestation/types.go index 54068b5..5ee6717 100644 --- a/pkg/attestation/types.go +++ b/pkg/attestation/types.go @@ -4,12 +4,16 @@ import ( "encoding/base64" "fmt" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" intoto "github.com/in-toto/in-toto-golang/in_toto" v02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" ociv1 "github.com/opencontainers/image-spec/specs-go/v1" ) const ( + DockerReferenceType = "vnd.docker.reference.type" + AttestationManifestType = "attestation-manifest" DockerDsseExtKind = "application/vnd.docker.attestation-verification.v1+json" RekorTlExtKind = "Rekor" OCIDescriptorDSSEMediaType = ociv1.MediaTypeDescriptor + "+dsse" @@ -17,6 +21,26 @@ const ( var base64Encoding = base64.StdEncoding.Strict() +type AttestationLayer struct { + Statement *intoto.Statement + Layer v1.Layer + MediaType types.MediaType + Annotations map[string]string +} + +type AttestationImage struct { + Layers []AttestationLayer + Image v1.Image +} + +type AttestationManifest struct { + Manifest v1.Descriptor + Attestation AttestationImage + MediaType types.MediaType + Annotations map[string]string + Digest v1.Hash +} + // the following types are needed until https://github.com/secure-systems-lab/dsse/pull/61 is merged type Envelope struct { PayloadType string `json:"payloadType"` From 0126ba9a0bb91b6532d5f43b45e7c47546792ddf Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Tue, 30 Apr 2024 13:13:30 -0500 Subject: [PATCH 07/11] revert: rego evaluator result --- internal/test/test.go | 24 +++++------------------- pkg/attest/verify.go | 5 +---- pkg/attest/verify_test.go | 5 ++--- pkg/policy/evaluator.go | 3 +-- pkg/policy/policy_test.go | 5 ++--- pkg/policy/rego.go | 23 +++++++++++++++-------- 6 files changed, 26 insertions(+), 39 deletions(-) diff --git a/internal/test/test.go b/internal/test/test.go index 220d18b..10baf71 100644 --- a/internal/test/test.go +++ b/internal/test/test.go @@ -18,7 +18,6 @@ import ( "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/google/go-containerregistry/pkg/v1/partial" intoto "github.com/in-toto/in-toto-golang/in_toto" - "github.com/open-policy-agent/opa/rego" "github.com/secure-systems-lab/go-securesystemslib/dsse" ) @@ -88,33 +87,20 @@ func GetMockSigner(ctx context.Context) (dsse.SignerVerifier, error) { } type MockPolicyEvaluator struct { - EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) + EvaluateFunc func(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) error } -func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) { +func (pe *MockPolicyEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) error { if pe.EvaluateFunc != nil { return pe.EvaluateFunc(ctx, resolver, policy, input) } - return AllowedResult(), nil + return nil } func GetMockPolicy() policy.PolicyEvaluator { return &MockPolicyEvaluator{ - EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pfs []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) { - return AllowedResult(), nil - }, - } -} - -func AllowedResult() *rego.ResultSet { - return ®o.ResultSet{ - { - Bindings: rego.Vars{}, - Expressions: []*rego.ExpressionValue{ - { - Value: true, - }, - }, + EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, policy []*policy.PolicyFile, input *policy.PolicyInput) error { + return nil }, } } diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index 0a963d1..52f05c6 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -31,13 +31,10 @@ func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, f if err != nil { return err } - rs, err := evaluator.Evaluate(ctx, resolver, files, input) + err = evaluator.Evaluate(ctx, resolver, files, input) if err != nil { return fmt.Errorf("policy evaluation failed: %w", err) } - if !rs.Allowed() { - return fmt.Errorf("policy evaluation failed: %s", fmt.Sprint(rs)) - } return nil } diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go index 19e55ee..5751da0 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -12,7 +12,6 @@ import ( "github.com/docker/attest/pkg/attestation" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/policy" - "github.com/open-policy-agent/opa/rego" "github.com/stretchr/testify/assert" ) @@ -44,8 +43,8 @@ func TestVerifyAttestations(t *testing.T) { t.Run(tc.name, func(t *testing.T) { mockPE := test.MockPolicyEvaluator{ - EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pfs []*policy.PolicyFile, input *policy.PolicyInput) (*rego.ResultSet, error) { - return test.AllowedResult(), tc.policyEvaluationError + EvaluateFunc: func(ctx context.Context, resolver oci.AttestationResolver, pfs []*policy.PolicyFile, input *policy.PolicyInput) error { + return tc.policyEvaluationError }, } diff --git a/pkg/policy/evaluator.go b/pkg/policy/evaluator.go index d512e56..35a34cb 100644 --- a/pkg/policy/evaluator.go +++ b/pkg/policy/evaluator.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/docker/attest/pkg/oci" - "github.com/open-policy-agent/opa/rego" ) type policyEvaluatorCtxKeyType struct{} @@ -27,5 +26,5 @@ func GetPolicyEvaluator(ctx context.Context) (PolicyEvaluator, error) { } type PolicyEvaluator interface { - Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) + Evaluate(ctx context.Context, resolver oci.AttestationResolver, policy []*PolicyFile, input *PolicyInput) error } diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index 3740d36..2665cf7 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -97,12 +97,11 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { policyFiles, err := policy.ResolvePolicy(ctx, tc.resolver, tc.policy) assert.NoErrorf(t, err, "failed to resolve policy") - rs, err := re.Evaluate(ctx, tc.resolver, policyFiles, tc.input) - + err = re.Evaluate(ctx, tc.resolver, policyFiles, tc.input) if tc.expectSuccess { assert.NoErrorf(t, err, "Evaluate failed") } else { - assert.False(t, rs.Allowed(), "Evaluate should have failed") + assert.Errorf(t, err, "Evaluate should have failed") } }) } diff --git a/pkg/policy/rego.go b/pkg/policy/rego.go index 48ca0f3..78f3de3 100644 --- a/pkg/policy/rego.go +++ b/pkg/policy/rego.go @@ -29,11 +29,10 @@ type regoEvaluator struct { func NewRegoEvaluator(debug bool) PolicyEvaluator { return ®oEvaluator{ debug: debug, - query: "data.attestations.allow", } } -func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, files []*PolicyFile, input *PolicyInput) (*rego.ResultSet, error) { +func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationResolver, files []*PolicyFile, input *PolicyInput) error { var regoOpts []func(*rego.Rego) // Create a new in-memory store @@ -42,7 +41,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR params.Write = true txn, err := store.NewTransaction(ctx, params) if err != nil { - return nil, err + return err } for _, target := range files { @@ -50,11 +49,11 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR if filepath.Ext(target.Path) == ".yaml" { yamlData, err := loadYAML(target.Path, target.Content) if err != nil { - return nil, err + return err } err = store.Write(ctx, txn, storage.AddOp, storage.Path{}, yamlData) if err != nil { - return nil, err + return err } } else { regoOpts = append(regoOpts, rego.Module(target.Path, string(target.Content))) @@ -64,7 +63,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR err = store.Commit(ctx, txn) if err != nil { store.Abort(ctx, txn) - return nil, err + return err } if re.debug { @@ -76,7 +75,7 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR } regoOpts = append(regoOpts, - rego.Query(re.query), + rego.Query("data.docker.allow"), rego.StrictBuiltinErrors(true), rego.Input(input), rego.Store(store), @@ -87,7 +86,15 @@ func (re *regoEvaluator) Evaluate(ctx context.Context, resolver oci.AttestationR r := rego.New(regoOpts...) rs, err := r.Eval(ctx) - return &rs, err + if err != nil { + return fmt.Errorf("error from Eval: %w", err) + } + + if !rs.Allowed() { + return fmt.Errorf("policy evaluation failed") + } + + return nil } var dynamicObj = types.NewObject(nil, types.NewDynamicProperty(types.S, types.A)) From 8cbdf6d4de3325d97d4ab26eea699db7ff5c5056 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Tue, 30 Apr 2024 15:07:37 -0500 Subject: [PATCH 08/11] docs: update README.md --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 797896e..b921e18 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,72 @@ # attest -Library to create, verify, and evaluate policy for attestations on container images +library to create, verify, and evaluate policy for attestations on container images # usage ## signing attestations +1. generate an image with intoto Statements (optional) + ```sh + docker buildx build --sbom true --provenance true --output type=oci,tar=false,name=:,dest= + ``` +1. confgiure a `dsse.SignerVerifier` + ```go + var signer dsse.SignerVerifier + signer, err = signerverifier.GetAWSSigner(cmd.Context(), aws_arn, aws_region) + ``` + +1. configure signing options + ```go + opts := &attest.SigningOptions{ + Replace: true, // replace unsigned intoto statements with signed intoto attestations, otherwise leave in place + } + ``` + * add [Verification Summary Attestation (VSA)](https://slsa.dev/spec/v1.0/verification_summary) for all intoto attestations (optional) + ```go + opts.VSAOptions = &attestation.VSAOptions{ + BuildLevel: "SLSA_BUILD_LEVEL_" + slsaBuildLevel, + PolicyURI: slsaPolicyUri, + VerifierID: slsaVerifierId, + } + ``` +1. load attestations + * oci registry + ```go + ref := "docker/attest:latest" + att, err := oci.AttestationIndexFromRemote(ref) + ``` + * local filepath + ```go + path := "/test-image" + att, err := oci.AttestationIndexFromPath(path) + ``` + +1. sign attestations + ```go + signedImageIndex, err := attest.SignIndexAttestations(ctx, att, signer, opts) + ``` + `attest.SignedIndexAttestations()` iterates over all attestation manifests in the image index and signs all intoto statements (optionally generates a VSA), returning a mutated ImageIndex with all intoto statements signed as attestations. + +1. save output (optional) + * push to oci registry + ```go + err = mirror.PushToRegistry(signedImageIndex, ref) + ``` + * save to local filesystem + ```go + idx := v1.ImageIndex(empty.Index) + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ + Add: signedImageIndex, + Descriptor: v1.Descriptor{ + Annotations: map[string]string{ + oci.OciReferenceTarget: att.Name, + }, + }, + }) + err = mirror.SaveAsOCILayout(idx, path) + ``` ## verifying attestations -1. Create a TUF client +1. create a TUF client * using OCI registry for TUF ```go tufClient, err := tuf.NewTufClient(embed.DefaultRoot, "/.docker/tuf", "docker/tuf-metadata:latest", "docker/tuf-targets") @@ -16,7 +76,7 @@ Library to create, verify, and evaluate policy for attestations on container ima tufClient, err := tuf.NewTufClient(embed.DefaultRoot, "/.docker/tuf", "https://docker.github.io/tuf/metadata", "https://docker.github.io/tuf/targets") ``` -1. Configure an attestation resolver +1. configure an attestation resolver * using OCI registry ```go var resolver oci.AttestationResolver @@ -34,7 +94,7 @@ Library to create, verify, and evaluate policy for attestations on container ima } ``` -2. Configure policy options +1. configure policy options ```go opts := &policy.PolicyOptions{ TufClient: tufClient, @@ -43,7 +103,7 @@ Library to create, verify, and evaluate policy for attestations on container ima } ``` -3. Verify attestations +1. verify attestations ```go policy, err := attest.Verify(ctx, opts, resolver) if err != nil { From 6b8c5b56bc410ada21646eec33ebd247ee0f3958 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Tue, 30 Apr 2024 15:46:55 -0500 Subject: [PATCH 09/11] fix: default to v1.ImageIndex for *mutate.index support --- pkg/mirror/mirror.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/mirror/mirror.go b/pkg/mirror/mirror.go index 04508eb..3abf107 100644 --- a/pkg/mirror/mirror.go +++ b/pkg/mirror/mirror.go @@ -48,7 +48,9 @@ func PushToRegistry(image any, imageName string) error { return fmt.Errorf("failed to push image index %s: %w", imageName, err) } default: - return fmt.Errorf("unknown image type: %T", image) + if err := remote.WriteIndex(ref, image.(v1.ImageIndex), remote.WithAuth(auth)); err != nil { + return fmt.Errorf("failed to push image index %s: %w", imageName, err) + } } return nil } @@ -76,7 +78,10 @@ func SaveAsOCILayout(image any, path string) error { return fmt.Errorf("failed to create index: %w", err) } default: - return fmt.Errorf("unknown image type: %T", image) + _, err := layout.Write(path, image.(v1.ImageIndex)) + if err != nil { + return fmt.Errorf("failed to create index: %w", err) + } } return nil } From 34fcb0ca6ddf46c97cf17c2875ae34dbc2bcd561 Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Tue, 30 Apr 2024 15:55:21 -0500 Subject: [PATCH 10/11] chore: rename SignIndexAttestations to just Sign --- README.md | 4 ++-- pkg/attest/sign.go | 2 +- pkg/attest/sign_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b921e18..49e5d14 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ library to create, verify, and evaluate policy for attestations on container ima 1. sign attestations ```go - signedImageIndex, err := attest.SignIndexAttestations(ctx, att, signer, opts) + signedImageIndex, err := attest.Sign(ctx, att, signer, opts) ``` - `attest.SignedIndexAttestations()` iterates over all attestation manifests in the image index and signs all intoto statements (optionally generates a VSA), returning a mutated ImageIndex with all intoto statements signed as attestations. + `attest.Sign()` iterates over attestation manifests in the image index and signs all intoto statements (optionally generates a VSA), returning a mutated ImageIndex with all intoto statements signed as attestations. 1. save output (optional) * push to oci registry diff --git a/pkg/attest/sign.go b/pkg/attest/sign.go index a554e6e..dd63183 100644 --- a/pkg/attest/sign.go +++ b/pkg/attest/sign.go @@ -17,7 +17,7 @@ import ( "github.com/secure-systems-lab/go-securesystemslib/dsse" ) -func SignIndexAttestations(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, opts *SigningOptions) (v1.ImageIndex, error) { +func Sign(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, opts *SigningOptions) (v1.ImageIndex, error) { // extract attestation manifests from index attestationManifests, err := attestation.GetAttestationManifestsFromIndex(idx) if err != nil { diff --git a/pkg/attest/sign_test.go b/pkg/attest/sign_test.go index bff0f57..f2f6787 100644 --- a/pkg/attest/sign_test.go +++ b/pkg/attest/sign_test.go @@ -61,7 +61,7 @@ func TestSignVerifyOCILayout(t *testing.T) { } attIdx, err := oci.AttestationIndexFromPath(tc.TestImage) assert.NoError(t, err) - signedIndex, err := SignIndexAttestations(ctx, attIdx.Index, signer, opts) + signedIndex, err := Sign(ctx, attIdx.Index, signer, opts) assert.NoError(t, err) // output signed attestations From 3e0086e7e2cb3ca5d7d471d8757d7d69b43727eb Mon Sep 17 00:00:00 2001 From: mrjoelkamp Date: Tue, 30 Apr 2024 16:05:58 -0500 Subject: [PATCH 11/11] docs: prioritize verification over signing --- README.md | 106 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 49e5d14..d8d9b02 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,62 @@ library to create, verify, and evaluate policy for attestations on container images # usage +## verifying attestations +1. create a TUF client + * using OCI registry for TUF + ```go + tufOutputPath = "/.docker/tuf" + metadataURI = "docker/tuf-metadata:latest" + targetsURI = "docker/tuf-targets" + tufClient, err := tuf.NewTufClient(embed.DefaultRoot, tufOutputPath, metadataURI, targetsURI) + ``` + * using HTTPS for TUF + ```go + tufOutputPath = "/.docker/tuf" + metadataURI = "https://docker.github.io/tuf/metadata" + targetsURI = "https://docker.github.io/tuf/targets" + tufClient, err := tuf.NewTufClient(embed.DefaultRoot, tufOutputPath, metadataURI, targetsURI) + ``` + +1. configure an attestation resolver + * using OCI registry + ```go + var resolver oci.AttestationResolver + resolver = &oci.RegistryResolver{ + Image: image, // path to image index in OCI registry containing image attestations (e.g. docker/nginx:latest) + Platform: platform, // platform of subject image (image that attestations are being verified against) + } + ``` + * using local OCI layout + ```go + var resolver oci.AttestationResolver + resolver = &oci.OCILayoutResolver{ + Path: path, // file path to OCI layout containing image attestations (e.g. /myimage) + Platform: platform, // platform of subject image (image that attestations are being verified against) + } + ``` + +1. configure policy options + ```go + opts := &policy.PolicyOptions{ + TufClient: tufClient, + LocalTargetsDir: "/.docker/policy", // location to store policy files downloaded from TUF + LocalPolicyDir: "", // overrides TUF policy for local policy files + } + ``` + +1. verify attestations + ```go + policy, err := attest.Verify(ctx, opts, resolver) + if err != nil { + return false // failed policy or attestation signature verification + } + if policy { + return true // passed policy + } + return true // no policy for image + ``` + ## signing attestations 1. generate an image with intoto Statements (optional) ```sh @@ -65,56 +121,6 @@ library to create, verify, and evaluate policy for attestations on container ima err = mirror.SaveAsOCILayout(idx, path) ``` -## verifying attestations -1. create a TUF client - * using OCI registry for TUF - ```go - tufClient, err := tuf.NewTufClient(embed.DefaultRoot, "/.docker/tuf", "docker/tuf-metadata:latest", "docker/tuf-targets") - ``` - * using HTTPS for TUF - ```go - tufClient, err := tuf.NewTufClient(embed.DefaultRoot, "/.docker/tuf", "https://docker.github.io/tuf/metadata", "https://docker.github.io/tuf/targets") - ``` - -1. configure an attestation resolver - * using OCI registry - ```go - var resolver oci.AttestationResolver - resolver = &oci.RegistryResolver{ - Image: image, // path to image index in OCI registry containing image attestations (e.g. docker/nginx:latest) - Platform: platform, // platform of subject image (image that attestations are being verified against) - } - ``` - * using local OCI layout - ```go - var resolver oci.AttestationResolver - resolver = &oci.OCILayoutResolver{ - Path: path, // file path to OCI layout containing image attestations (e.g. /myimage) - Platform: platform, // platform of subject image (image that attestations are being verified against) - } - ``` - -1. configure policy options - ```go - opts := &policy.PolicyOptions{ - TufClient: tufClient, - LocalTargetsDir: "/.docker/policy", // location to store policy files downloaded from TUF - LocalPolicyDir: "", // overrides TUF policy for local policy files - } - ``` - -1. verify attestations - ```go - policy, err := attest.Verify(ctx, opts, resolver) - if err != nil { - return false // failed policy or attestation signature verification - } - if policy { - return true // passed policy - } - return true // no policy for image - ``` - ## mirroring TUF repositories TODO: write content for this outline ### mirroring TUF metadata to OCI