diff --git a/go.mod b/go.mod index e55febf..ab486ce 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,9 @@ require ( sigs.k8s.io/yaml v1.4.0 ) +// fork of a fork (in case it goes away) with changes to support ArtifactType (https://github.com/google/go-containerregistry/pull/1931) +replace github.com/google/go-containerregistry v0.20.0 => github.com/kipz/go-containerregistry v0.0.0-20240423201245-bf57eace21f2 + require ( cloud.google.com/go v0.115.0 // indirect cloud.google.com/go/auth v0.7.0 // indirect diff --git a/go.sum b/go.sum index 33e2772..b3be56d 100644 --- a/go.sum +++ b/go.sum @@ -344,8 +344,6 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-containerregistry v0.20.0 h1:wRqHpOeVh3DnenOrPy9xDOLdnLatiGuuNRVelR2gSbg= -github.com/google/go-containerregistry v0.20.0/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI= github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -417,6 +415,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kipz/go-containerregistry v0.0.0-20240423201245-bf57eace21f2 h1:Q8a+lW1mDc5ta1kelfIVqXl/DC+KQg6PG/F33kCC9TA= +github.com/kipz/go-containerregistry v0.0.0-20240423201245-bf57eace21f2/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= diff --git a/internal/test/mocks.go b/internal/test/mocks.go new file mode 100644 index 0000000..ade9063 --- /dev/null +++ b/internal/test/mocks.go @@ -0,0 +1,64 @@ +package test + +import ( + "context" + "os" + "path/filepath" + + "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/oci" + "github.com/docker/attest/pkg/signerverifier" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/secure-systems-lab/go-securesystemslib/dsse" +) + +type MockResolver struct { + Envs []*attestation.Envelope +} + +func (r MockResolver) Attestations(ctx context.Context, mediaType string) ([]*attestation.Envelope, error) { + return r.Envs, nil +} + +func (r MockResolver) ImageName(ctx context.Context) (string, error) { + return "library/alpine:latest", nil +} + +func (r MockResolver) ImageDescriptor(ctx context.Context) (*v1.Descriptor, error) { + digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620") + if err != nil { + return nil, err + } + return &v1.Descriptor{ + Digest: digest, + Size: 1234, + MediaType: "application/vnd.oci.image.manifest.v1+json", + }, nil + +} + +func (r MockResolver) ImagePlatform(ctx context.Context) (*v1.Platform, error) { + return oci.ParsePlatform("linux/amd64") +} + +type MockRegistryResolver struct { + Subject *v1.Descriptor + ImageNameStr string + *MockResolver +} + +func (r *MockRegistryResolver) ImageDescriptor(ctx context.Context) (*v1.Descriptor, error) { + return r.Subject, nil +} + +func (r *MockRegistryResolver) ImageName(ctx context.Context) (string, error) { + return r.ImageNameStr, nil +} + +func GetMockSigner(ctx context.Context) (dsse.SignerVerifier, error) { + priv, err := os.ReadFile(filepath.Join("..", "..", "test", "testdata", "test-signing-key.pem")) + if err != nil { + return nil, err + } + return signerverifier.LoadKeyPair(priv) +} diff --git a/internal/test/test.go b/internal/test/test.go index 2da3576..76536b1 100644 --- a/internal/test/test.go +++ b/internal/test/test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "strings" "testing" @@ -82,14 +81,6 @@ func Setup(t *testing.T) (context.Context, dsse.SignerVerifier) { return ctx, signer } -func GetMockSigner(ctx context.Context) (dsse.SignerVerifier, error) { - priv, err := os.ReadFile(filepath.Join("..", "..", "test", "testdata", "test-signing-key.pem")) - if err != nil { - return nil, err - } - return signerverifier.LoadKeyPair(priv) -} - type AnnotatedStatement struct { OCIDescriptor *v1.Descriptor InTotoStatement *intoto.Statement diff --git a/pkg/attest/example_sign_test.go b/pkg/attest/example_sign_test.go index db39cc9..6fa0411 100644 --- a/pkg/attest/example_sign_test.go +++ b/pkg/attest/example_sign_test.go @@ -27,7 +27,7 @@ func ExampleSign_remote() { // configure signing options opts := &attestation.SigningOptions{ - Replace: true, // replace unsigned intoto statements with signed intoto attestations, otherwise leave in place + SkipTL: true, // skip trust logging to a transparency log } // load image index with unsigned attestation-manifests @@ -49,7 +49,7 @@ func ExampleSign_remote() { panic(err) } signedIndex := attIdx.Index - signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests) if err != nil { panic(err) } diff --git a/pkg/attest/sign.go b/pkg/attest/sign.go index 1d10506..15d0b98 100644 --- a/pkg/attest/sign.go +++ b/pkg/attest/sign.go @@ -18,8 +18,11 @@ func SignStatements(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVe } // sign every attestation layer in each manifest for _, manifest := range attestationManifests { - for _, layer := range manifest.Attestation.Layers { - manifest.AddAttestation(ctx, signer, layer.Statement, opts) + for _, layer := range manifest.OriginalLayers { + err = manifest.AddAttestation(ctx, signer, layer.Statement, opts) + if err != nil { + return nil, fmt.Errorf("failed to sign attestation layer %w", err) + } } } return attestationManifests, nil diff --git a/pkg/attest/sign_test.go b/pkg/attest/sign_test.go index 2482b3e..89526a0 100644 --- a/pkg/attest/sign_test.go +++ b/pkg/attest/sign_test.go @@ -2,13 +2,18 @@ package attest import ( "encoding/json" + "fmt" + "net/http/httptest" + "net/url" "path/filepath" "testing" "github.com/docker/attest/internal/test" "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/mirror" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/policy" + "github.com/google/go-containerregistry/pkg/registry" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/layout" @@ -53,15 +58,13 @@ func TestSignVerifyOCILayout(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { outputLayout := test.CreateTempDir(t, "", TestTempDir) - opts := &attestation.SigningOptions{ - Replace: tc.replace, - } + opts := &attestation.SigningOptions{} attIdx, err := oci.IndexFromPath(tc.TestImage) require.NoError(t, err) signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) signedIndex := attIdx.Index - signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests, attestation.WithReplacedLayers(tc.replace)) require.NoError(t, err) // output signed attestations idx := v1.ImageIndex(empty.Index) @@ -102,6 +105,7 @@ func TestSignVerifyOCILayout(t *testing.T) { } func TestAddSignedLayerAnnotations(t *testing.T) { + ctx, signer := test.Setup(t) testCases := []struct { name string replace bool @@ -115,27 +119,30 @@ func TestAddSignedLayerAnnotations(t *testing.T) { data := []byte("signed") testLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType)) mediaType := types.OCIManifestSchema1 - opts := &attestation.SigningOptions{ - Replace: tc.replace, - } + opts := &attestation.SigningOptions{} originalLayer := &attestation.AttestationLayer{ - Layer: testLayer, - Statement: &intoto.Statement{}, + Layer: testLayer, + Statement: &intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + PredicateType: attestation.VSAPredicateType, + }, + }, Annotations: map[string]string{"test": "test"}, } manifest := &attestation.AttestationManifest{ - MediaType: mediaType, - Attestation: &attestation.AttestationImage{ - Image: empty.Image, - Layers: []*attestation.AttestationLayer{ - originalLayer, - }, + OriginalDescriptor: &v1.Descriptor{ + MediaType: mediaType, + }, + OriginalLayers: []*attestation.AttestationLayer{ + originalLayer, }, SubjectDescriptor: &v1.Descriptor{}, } - err := manifest.AddOrReplaceLayer(originalLayer, opts) - newImg := manifest.Attestation.Image + err := manifest.AddAttestation(ctx, signer, originalLayer.Statement, opts) + require.NoError(t, err) + + newImg, err := manifest.BuildAttestationImage(attestation.WithReplacedLayers(tc.replace)) require.NoError(t, err) mf, _ := newImg.RawManifest() type Annotations struct { @@ -152,3 +159,87 @@ func TestAddSignedLayerAnnotations(t *testing.T) { }) } } + +func TestSimpleStatementSigning(t *testing.T) { + ctx, signer := test.Setup(t) + empty := types.MediaType("application/vnd.oci.empty.v1+json") + testCases := []struct { + name string + replace bool + }{ + {"replaced", true}, + {"not replaced", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := &attestation.SigningOptions{} + statement := &intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + PredicateType: attestation.VSAPredicateType, + }, + } + statement2 := &intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + PredicateType: attestation.VSAPredicateType, + }, + } + digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620") + require.NoError(t, err) + subject := &v1.Descriptor{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Digest: digest, + } + manifest, err := NewAttestationManifest(subject) + require.NoError(t, err) + err = manifest.AddAttestation(ctx, signer, statement, opts) + require.NoError(t, err) + + err = manifest.AddAttestation(ctx, signer, statement2, opts) + require.NoError(t, err) + + // fake that the manfifest was loaded from a real image + manifest.OriginalLayers = manifest.SignedLayers + envelopes, err := oci.ExtractEnvelopes(manifest, attestation.VSAPredicateType) + require.NoError(t, err) + assert.Len(t, envelopes, 2) + + newImg, err := manifest.BuildAttestationImage(attestation.WithReplacedLayers(tc.replace)) + require.NoError(t, err) + layers, err := newImg.Layers() + require.NoError(t, err) + if tc.replace { + assert.Len(t, layers, 2) + } else { + assert.Len(t, layers, 4) + } + + newImgs, err := manifest.BuildReferringArtifacts() + require.NoError(t, err) + assert.Len(t, newImgs, 2) + for _, img := range newImgs { + mf, err := img.Manifest() + require.NoError(t, err) + assert.Equal(t, "application/vnd.in-toto+json", mf.ArtifactType) + assert.Equal(t, subject.MediaType, mf.MediaType) + assert.Equal(t, empty, mf.Config.MediaType) + assert.Equal(t, int64(2), mf.Config.Size) + assert.Equal(t, "{}", string(mf.Config.Data)) + layers, err := img.Layers() + require.NoError(t, err) + assert.Len(t, layers, 1) + } + server := httptest.NewServer(registry.New(registry.WithReferrersSupport(true))) + defer server.Close() + + u, err := url.Parse(server.URL) + require.NoError(t, err) + + indexName := fmt.Sprintf("%s/repo:root", u.Host) + output, err := oci.ParseImageSpecs(indexName) + require.NoError(t, err) + err = mirror.SaveReferrers(manifest, output) + require.NoError(t, err) + }) + } +} diff --git a/pkg/attest/types.go b/pkg/attest/types.go index 4b77f2d..ee8b620 100644 --- a/pkg/attest/types.go +++ b/pkg/attest/types.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/docker/attest/pkg/policy" + v1 "github.com/google/go-containerregistry/pkg/v1" intoto "github.com/in-toto/in-toto-golang/in_toto" ) @@ -27,9 +28,10 @@ func (o Outcome) StringForVSA() (string, error) { } type VerificationResult struct { - Outcome Outcome - Policy *policy.Policy - Input *policy.PolicyInput - VSA *intoto.Statement - Violations []policy.Violation + Outcome Outcome + Policy *policy.Policy + Input *policy.PolicyInput + VSA *intoto.Statement + Violations []policy.Violation + SubjectDescriptor *v1.Descriptor } diff --git a/pkg/attest/verify.go b/pkg/attest/verify.go index faba273..db33043 100644 --- a/pkg/attest/verify.go +++ b/pkg/attest/verify.go @@ -11,6 +11,7 @@ import ( "github.com/docker/attest/pkg/config" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/policy" + v1 "github.com/google/go-containerregistry/pkg/v1" intoto "github.com/in-toto/in-toto-golang/in_toto" ) @@ -60,7 +61,7 @@ func Verify(ctx context.Context, src *oci.ImageSpec, opts *policy.PolicyOptions) return result, nil } -func ToPolicyResult(p *policy.Policy, input *policy.PolicyInput, result *policy.Result) (*VerificationResult, error) { +func toVerificationResult(p *policy.Policy, input *policy.PolicyInput, result *policy.Result) (*VerificationResult, error) { dgst, err := oci.SplitDigest(input.Digest) if err != nil { return nil, fmt.Errorf("failed to split digest: %w", err) @@ -112,10 +113,11 @@ func ToPolicyResult(p *policy.Policy, input *policy.PolicyInput, result *policy. } func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, pctx *policy.Policy) (*VerificationResult, error) { - digest, err := resolver.ImageDigest(ctx) + desc, err := resolver.ImageDescriptor(ctx) if err != nil { - return nil, fmt.Errorf("failed to get image digest: %w", err) + return nil, fmt.Errorf("failed to get image descriptor: %w", err) } + digest := desc.Digest.String() name, err := resolver.ImageName(ctx) if err != nil { return nil, fmt.Errorf("failed to get image name: %w", err) @@ -155,5 +157,20 @@ func VerifyAttestations(ctx context.Context, resolver oci.AttestationResolver, p if err != nil { return nil, fmt.Errorf("policy evaluation failed: %w", err) } - return ToPolicyResult(pctx, input, result) + verificationResult, err := toVerificationResult(pctx, input, result) + if err != nil { + return nil, fmt.Errorf("failed to convert to policy result: %w", err) + } + verificationResult.SubjectDescriptor = desc + return verificationResult, nil +} + +func NewAttestationManifest(subject *v1.Descriptor) (*attestation.AttestationManifest, error) { + return &attestation.AttestationManifest{ + OriginalDescriptor: &v1.Descriptor{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + }, + OriginalLayers: []*attestation.AttestationLayer{}, + SubjectDescriptor: subject, + }, nil } diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go index 57c4305..fcbd734 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -36,7 +36,7 @@ func TestVerifyAttestations(t *testing.T) { var env = new(attestation.Envelope) err = json.Unmarshal(ex, env) assert.NoError(t, err) - resolver := &oci.MockResolver{ + resolver := &test.MockResolver{ Envs: []*attestation.Envelope{env}, } @@ -77,15 +77,13 @@ func TestVSA(t *testing.T) { // setup an image with signed attestations outputLayout := test.CreateTempDir(t, "", TestTempDir) - opts := &attestation.SigningOptions{ - Replace: true, - } + opts := &attestation.SigningOptions{} attIdx, err := oci.IndexFromPath(UnsignedTestImage) assert.NoError(t, err) signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) signedIndex := attIdx.Index - signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests) require.NoError(t, err) // output signed attestations @@ -136,15 +134,13 @@ func TestVerificationFailure(t *testing.T) { // setup an image with signed attestations outputLayout := test.CreateTempDir(t, "", TestTempDir) - opts := &attestation.SigningOptions{ - Replace: true, - } + opts := &attestation.SigningOptions{} attIdx, err := oci.IndexFromPath(UnsignedTestImage) assert.NoError(t, err) signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) signedIndex := attIdx.Index - signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests, attestation.WithReplacedLayers(true)) require.NoError(t, err) // output signed attestations @@ -215,14 +211,13 @@ func TestSignVerify(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { opts := &attestation.SigningOptions{ - Replace: true, - SkipTL: tc.signTL, + SkipTL: tc.signTL, } signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) signedIndex := attIdx.Index - signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests, attestation.WithReplacedLayers(true)) require.NoError(t, err) imageName := tc.imageName diff --git a/pkg/attestation/attestation.go b/pkg/attestation/attestation.go index 1b449f2..aa3d9a2 100644 --- a/pkg/attestation/attestation.go +++ b/pkg/attestation/attestation.go @@ -29,15 +29,15 @@ func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]*AttestationManife } var attestationManifests []*AttestationManifest - for _, manifest := range idx.Manifests { - if manifest.Annotations[DockerReferenceType] == AttestationManifestType { - subject := subjects[manifest.Annotations[DockerReferenceDigest]] + for _, desc := range idx.Manifests { + if desc.Annotations[DockerReferenceType] == AttestationManifestType { + subject := subjects[desc.Annotations[DockerReferenceDigest]] if subject == nil { return nil, fmt.Errorf("failed to find subject for attestation manifest: %w", err) } - attestationImage, err := index.Image(manifest.Digest) + attestationImage, err := index.Image(desc.Digest) 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 extract attestation image with digest %s: %w", desc.Digest.String(), err) } attestationLayers, err := GetAttestationsFromImage(attestationImage) if err != nil { @@ -45,14 +45,9 @@ func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]*AttestationManife } attestationManifests = append(attestationManifests, &AttestationManifest{ - Descriptor: &manifest, - SubjectDescriptor: subject, - Attestation: &AttestationImage{ - Layers: attestationLayers, - Image: attestationImage}, - MediaType: manifest.MediaType, - Annotations: manifest.Annotations, - Digest: manifest.Digest}) + OriginalDescriptor: &desc, + SubjectDescriptor: subject, + OriginalLayers: attestationLayers}) } } return attestationManifests, nil @@ -90,7 +85,7 @@ func GetAttestationsFromImage(image v1.Image) ([]*AttestationLayer, error) { return nil, fmt.Errorf("failed to decode statement layer contents: %w", err) } } - attestationLayers = append(attestationLayers, &AttestationLayer{Layer: layer, MediaType: mt, Statement: stmt, Annotations: ann}) + attestationLayers = append(attestationLayers, &AttestationLayer{Layer: layer, Statement: stmt, Annotations: ann}) } return attestationLayers, nil } @@ -100,17 +95,11 @@ func (manifest *AttestationManifest) AddAttestation(ctx context.Context, signer if err != nil { return fmt.Errorf("failed to create signed layer: %w", err) } - newImg, newDesc, err := addLayerToImage(manifest, layer, opts) - if err != nil { - return fmt.Errorf("failed to add signed layers to image: %w", err) - } - manifest.Attestation.Image = newImg - manifest.Descriptor = newDesc + manifest.SignedLayers = append(manifest.SignedLayers, layer) return nil } func createSignedImageLayer(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*AttestationLayer, error) { - // sign the statement env, err := SignInTotoStatement(ctx, statement, signer, opts) if err != nil { @@ -127,7 +116,6 @@ func createSignedImageLayer(ctx context.Context, statement *intoto.Statement, si } return &AttestationLayer{ Statement: statement, - MediaType: types.MediaType(intoto.PayloadType), Annotations: map[string]string{ InTotoPredicateType: statement.PredicateType, InTotoReferenceLifecycleStage: LifecycleStageExperimental, @@ -148,104 +136,182 @@ func SignInTotoStatement(ctx context.Context, statement *intoto.Statement, signe return env, nil } -func addLayerToImage( - manifest *AttestationManifest, - layer *AttestationLayer, - opts *SigningOptions) (v1.Image, *v1.Descriptor, error) { - - err := manifest.AddOrReplaceLayer(layer, opts) - - if err != nil { - return nil, nil, fmt.Errorf("failed to add signed layers: %w", err) - } - newImg := manifest.Attestation.Image - if !opts.SkipSubject { - newImg = mutate.Subject(newImg, *manifest.SubjectDescriptor).(v1.Image) - } - newDesc, err := partial.Descriptor(newImg) - if err != nil { - return nil, nil, fmt.Errorf("failed to get descriptor: %w", err) - } - cf, err := manifest.Attestation.Image.ConfigFile() - if err != nil { - return nil, nil, fmt.Errorf("failed to get config file: %w", err) - } - newDesc.Platform = cf.Platform() - if newDesc.Platform == nil { - newDesc.Platform = &v1.Platform{ - Architecture: "unknown", - OS: "unknown", - } - } - newDesc.MediaType = manifest.MediaType - newDesc.Annotations = manifest.Annotations - return newImg, newDesc, nil -} - -// AddOrReplaceLayer adds signed layers to a new or existing attestation image -// NOTE: the pointers attestation.AttestationLayer.Statement are compared when replacing, -// so make sure you are signing a layer extracted from the original attestation-manifest image! -func (manifest *AttestationManifest) AddOrReplaceLayer(signedLayer *AttestationLayer, opts *SigningOptions) error { - var err error - // always create a new image from all the layers - newImg := empty.Image - newImg = mutate.Annotations(newImg, map[string]string{ - DockerReferenceType: AttestationManifestType, - DockerReferenceDigest: manifest.SubjectDescriptor.Digest.String(), - }).(v1.Image) - - newImg = mutate.MediaType(newImg, manifest.MediaType) - newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.image.config.v1+json") - add := mutate.Addendum{ - Layer: signedLayer.Layer, - Annotations: signedLayer.Annotations, - } - newImg, err = mutate.Append(newImg, add) - if err != nil { - return fmt.Errorf("failed to add signed layer to image: %w", err) - } - layers := make([]*AttestationLayer, 0) - for _, layer := range manifest.Attestation.Layers { - if layer.Statement == signedLayer.Statement && opts.Replace { - continue - } - add := mutate.Addendum{ - Layer: layer.Layer, - Annotations: layer.Annotations, - } - newImg, err = mutate.Append(newImg, add) - layers = append(layers, layer) - if err != nil { - return fmt.Errorf("failed to add layer to image: %w", err) - } - } - manifest.Attestation.Layers = append(layers, signedLayer) - manifest.Attestation.Image = newImg - return nil -} - -func AddImageToIndex( +func UpdateIndexImage( idx v1.ImageIndex, manifest *AttestationManifest, -) (v1.ImageIndex, error) { - idx = mutate.RemoveManifests(idx, match.Digests(manifest.Digest)) + options ...func(*AttestationManifestImageOptions) error) (v1.ImageIndex, error) { + image, err := manifest.BuildAttestationImage(options...) + + if err != nil { + return nil, fmt.Errorf("failed to build image: %w", err) + } + newDesc, err := partial.Descriptor(image) + if err != nil { + return nil, fmt.Errorf("failed to get descriptor: %w", err) + } + newDesc.Platform = &v1.Platform{ + Architecture: "unknown", + OS: "unknown", + } + newDesc.MediaType = manifest.OriginalDescriptor.MediaType + newDesc.Annotations = manifest.OriginalDescriptor.Annotations + idx = mutate.RemoveManifests(idx, match.Digests(manifest.OriginalDescriptor.Digest)) idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ - Add: manifest.Attestation.Image, - Descriptor: *manifest.Descriptor, + Add: image, + Descriptor: *newDesc, }) return idx, nil } -func AddImagesToIndex( - idx v1.ImageIndex, - manifests []*AttestationManifest, -) (v1.ImageIndex, error) { - for _, manifest := range manifests { - var err error - idx, err = AddImageToIndex(idx, manifest) +func UpdateIndexImages(idx v1.ImageIndex, manifest []*AttestationManifest, options ...func(*AttestationManifestImageOptions) error) (v1.ImageIndex, error) { + var err error + for _, m := range manifest { + idx, err = UpdateIndexImage(idx, m, options...) if err != nil { return nil, fmt.Errorf("failed to add image to index: %w", err) } } return idx, nil } + +func newOptions(options ...func(*AttestationManifestImageOptions) error) (*AttestationManifestImageOptions, error) { + opts := &AttestationManifestImageOptions{} + for _, opt := range options { + err := opt(opts) + if err != nil { + return nil, err + } + } + return opts, nil +} + +func WithoutSubject(skipSubject bool) func(*AttestationManifestImageOptions) error { + return func(r *AttestationManifestImageOptions) error { + r.skipSubject = skipSubject + return nil + } +} + +func WithReplacedLayers(replaceLayers bool) func(*AttestationManifestImageOptions) error { + return func(r *AttestationManifestImageOptions) error { + r.replaceLayers = replaceLayers + return nil + } +} + +// build an image with signed attestations, optionally replacing existing layers with signed layers +func (manifest *AttestationManifest) BuildAttestationImage(options ...func(*AttestationManifestImageOptions) error) (v1.Image, error) { + opts, err := newOptions(options...) + if err != nil { + return nil, fmt.Errorf("failed to create options: %w", err) + } + resultLayers := manifest.SignedLayers + for _, existingLayer := range manifest.OriginalLayers { + var found bool + for _, signedLayer := range manifest.SignedLayers { + if existingLayer.Statement == signedLayer.Statement { + found = true + // copy over original annotations + for k, v := range existingLayer.Annotations { + signedLayer.Annotations[k] = v + } + break + } + } + //add existing layers if they've not been signed or we're not replacing them + if !found || !opts.replaceLayers { + resultLayers = append(resultLayers, existingLayer) + } + } + // so taht we attach all attestations to a single attestations image - as per current buildkit + opts.laxReferrers = true + newImg, err := buildImage(resultLayers, manifest.OriginalDescriptor, manifest.SubjectDescriptor, opts) + if err != nil { + return nil, fmt.Errorf("failed to build image: %w", err) + } + return newImg, nil +} + +// build an image per attestation (layer) suitable for use as Referrers +func (manifest *AttestationManifest) BuildReferringArtifacts() ([]v1.Image, error) { + var images []v1.Image + for _, layer := range manifest.SignedLayers { + opts := &AttestationManifestImageOptions{} + newImg, err := buildImage([]*AttestationLayer{layer}, manifest.OriginalDescriptor, manifest.SubjectDescriptor, opts) + if err != nil { + return nil, fmt.Errorf("failed to build image: %w", err) + } + images = append(images, newImg) + } + return images, nil +} + +// build and image containing only layers +func buildImage(layers []*AttestationLayer, manifest *v1.Descriptor, subject *v1.Descriptor, opts *AttestationManifestImageOptions) (v1.Image, error) { + newImg := empty.Image + var err error + + // NB: if we add the subject before the layers, it does not end up being computed/serialised in the output for some reason + //TODO - recreate this bug and push upstream + for _, layer := range layers { + add := mutate.Addendum{ + Layer: layer.Layer, + Annotations: layer.Annotations, + } + newImg, err = mutate.Append(newImg, add) + if err != nil { + return nil, fmt.Errorf("failed to add layer to image: %w", err) + } + } + + // this is for attaching attestations to an attestation image in the index + if opts.laxReferrers { + newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.image.config.v1+json") + } else { + newImg = mutate.ArtifactType(newImg, intoto.PayloadType) + newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.empty.v1+json") + } + // we need to set this even when we set the artifact type otherwise things break (even the go-container-registry client) + // even though it's allowed to be empty by spec when setting artifact type + newImg = mutate.MediaType(newImg, manifest.MediaType) + + // see note above - must be added after the layers! + if !opts.skipSubject { + newImg = mutate.Subject(newImg, *subject).(v1.Image) + } + if !opts.laxReferrers { + // as per https://github.com/opencontainers/image-spec/blob/main/manifest.md#guidance-for-an-empty-descriptor + newImg = &EmptyConfigImage{newImg} + } + return newImg, nil +} + +type EmptyConfigImage struct { + v1.Image +} + +func (i *EmptyConfigImage) RawConfigFile() ([]byte, error) { + return []byte("{}"), nil +} + +func (i *EmptyConfigImage) Manifest() (*v1.Manifest, error) { + mf, err := i.Image.Manifest() + if err != nil { + return nil, fmt.Errorf("failed to get manifest: %w", err) + } + mf.Config = v1.Descriptor{ + MediaType: "application/vnd.oci.empty.v1+json", + Size: 2, + Digest: v1.Hash{Algorithm: "sha256", Hex: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"}, + Data: []byte("{}"), + } + return mf, nil +} + +func (i *EmptyConfigImage) RawManifest() ([]byte, error) { + mf, err := i.Manifest() + if err != nil { + return nil, fmt.Errorf("failed to get manifest: %w", err) + } + return json.Marshal(mf) +} diff --git a/pkg/attestation/referrers_test.go b/pkg/attestation/referrers_test.go index 4fb44a1..bce9ced 100644 --- a/pkg/attestation/referrers_test.go +++ b/pkg/attestation/referrers_test.go @@ -102,8 +102,7 @@ func TestAttestationReferenceTypes(t *testing.T) { require.NoError(t, err) opts := &attestation.SigningOptions{ - Replace: true, - SkipSubject: tc.skipSubject, + SkipTL: true, } attIdx, err := oci.IndexFromPath(UnsignedTestImage) require.NoError(t, err) @@ -120,15 +119,17 @@ func TestAttestationReferenceTypes(t *testing.T) { require.NoError(t, err) err = mirror.PushIndexToRegistry(attIdx.Index, indexName) require.NoError(t, err) - for _, img := range signedManifests { - err = mirror.PushImageToRegistry(img.Attestation.Image, fmt.Sprintf("%s:tag-does-not-matter", repo)) + for _, signedManifest := range signedManifests { + image, err := signedManifest.BuildAttestationImage(attestation.WithoutSubject(tc.skipSubject), attestation.WithReplacedLayers(true)) + require.NoError(t, err) + err = mirror.PushImageToRegistry(image, fmt.Sprintf("%s:tag-does-not-matter", repo)) require.NoError(t, err) } } else { signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) signedIndex := attIdx.Index - signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests, attestation.WithReplacedLayers(true), attestation.WithoutSubject(tc.skipSubject)) require.NoError(t, err) err = mirror.PushIndexToRegistry(signedIndex, indexName) require.NoError(t, err) @@ -213,10 +214,35 @@ func TestReferencesInDifferentRepo(t *testing.T) { refServer: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), }, } { - t.Run(tc.name, func(t *testing.T) { - server := tc.server - defer server.Close() - serverUrl, err := url.Parse(server.URL) + server := tc.server + defer server.Close() + serverUrl, err := url.Parse(server.URL) + require.NoError(t, err) + + refServer := tc.refServer + defer refServer.Close() + refServerUrl, err := url.Parse(refServer.URL) + require.NoError(t, err) + + opts := &attestation.SigningOptions{ + SkipTL: true, + } + attIdx, err := oci.IndexFromPath(UnsignedTestImage) + require.NoError(t, err) + + indexName := fmt.Sprintf("%s/%s:latest", serverUrl.Host, repoName) + err = mirror.PushIndexToRegistry(attIdx.Index, indexName) + require.NoError(t, err) + + signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + + // push signed attestation image to the ref server + for _, signedManifest := range signedManifests { + // push references using subject-digest.att convention + image, err := signedManifest.BuildAttestationImage() + require.NoError(t, err) + err = mirror.PushImageToRegistry(image, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerUrl.Host, repoName)) require.NoError(t, err) refServer := tc.refServer @@ -225,8 +251,7 @@ func TestReferencesInDifferentRepo(t *testing.T) { require.NoError(t, err) opts := &attestation.SigningOptions{ - Replace: true, - SkipTL: true, + SkipTL: true, } attIdx, err := oci.IndexFromPath(UnsignedTestImage) require.NoError(t, err) @@ -239,10 +264,14 @@ func TestReferencesInDifferentRepo(t *testing.T) { require.NoError(t, err) // push signed attestation image to the ref server - for _, img := range signedManifests { + for _, mf := range signedManifests { // push references using subject-digest.att convention - err = mirror.PushImageToRegistry(img.Attestation.Image, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerUrl.Host, repoName)) + imgs, err := mf.BuildReferringArtifacts() require.NoError(t, err) + for _, img := range imgs { + err = mirror.PushImageToRegistry(img, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerUrl.Host, repoName)) + require.NoError(t, err) + } } mfs2, err := attIdx.Index.IndexManifest() require.NoError(t, err) @@ -262,6 +291,6 @@ func TestReferencesInDifferentRepo(t *testing.T) { require.NoError(t, err) assert.Equal(t, attest.OutcomeSuccess, results.Outcome) } - }) + } } } diff --git a/pkg/attestation/types.go b/pkg/attestation/types.go index 595f6c5..de55fe8 100644 --- a/pkg/attestation/types.go +++ b/pkg/attestation/types.go @@ -5,7 +5,6 @@ import ( "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" @@ -28,30 +27,27 @@ 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 SignedAttestationImage struct { - Image v1.Image - Descriptor *v1.Descriptor - AttestationManifest *AttestationManifest -} - type AttestationManifest struct { - Descriptor *v1.Descriptor - Attestation *AttestationImage - MediaType types.MediaType - Annotations map[string]string - Digest v1.Hash + OriginalDescriptor *v1.Descriptor + OriginalLayers []*AttestationLayer + + // accumulated during signing + SignedLayers []*AttestationLayer + // details of subect image + SubjectName string SubjectDescriptor *v1.Descriptor } +type AttestationManifestImageOptions struct { + // how to output the image + skipSubject bool + replaceLayers bool + laxReferrers bool +} + // the following types are needed until https://github.com/secure-systems-lab/dsse/pull/61 is merged type Envelope struct { PayloadType string `json:"payloadType"` @@ -83,12 +79,8 @@ type VerifyOptions struct { } type SigningOptions struct { - // replace unsigned statements with signed attestations - Replace bool // don't log to the configured transparency log SkipTL bool - // don't add OCI subject field to attestation image - SkipSubject bool } func DSSEMediaType(predicateType string) (string, error) { diff --git a/pkg/mirror/mirror.go b/pkg/mirror/mirror.go index 6050ff0..3f53484 100644 --- a/pkg/mirror/mirror.go +++ b/pkg/mirror/mirror.go @@ -5,12 +5,14 @@ import ( "os" "github.com/docker/attest/internal/embed" + "github.com/docker/attest/pkg/attestation" "github.com/docker/attest/pkg/oci" "github.com/docker/attest/pkg/tuf" "github.com/google/go-containerregistry/pkg/name" 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/remote" ) @@ -35,7 +37,7 @@ func PushImageToRegistry(image v1.Image, imageName string) error { return remote.Write(ref, image, oci.MultiKeychainOption()) } -func PushIndexToRegistry(image v1.ImageIndex, imageName string) error { +func PushIndexToRegistry(index v1.ImageIndex, imageName string) error { // Parse the index name ref, err := name.ParseReference(imageName) if err != nil { @@ -43,7 +45,7 @@ func PushIndexToRegistry(image v1.ImageIndex, imageName string) error { } // Push the index to the registry - return remote.WriteIndex(ref, image, oci.MultiKeychainOption()) + return remote.WriteIndex(ref, index, oci.MultiKeychainOption()) } func SaveImageAsOCILayout(image v1.Image, path string) error { @@ -73,3 +75,83 @@ func SaveIndexAsOCILayout(image v1.ImageIndex, path string) error { } return nil } + +func SaveIndex(outputs []*oci.ImageSpec, index v1.ImageIndex, indexName string) error { + // split output by comma and write or push each one + for _, output := range outputs { + if output.Type == oci.OCI { + idx := v1.ImageIndex(empty.Index) + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ + Add: index, + Descriptor: v1.Descriptor{ + Annotations: map[string]string{ + oci.OciReferenceTarget: indexName, + }, + }, + }) + err := SaveIndexAsOCILayout(idx, output.Identifier) + if err != nil { + return fmt.Errorf("failed to write signed image: %w", err) + } + } else { + err := PushIndexToRegistry(index, output.Identifier) + if err != nil { + return fmt.Errorf("failed to push signed image: %w", err) + } + } + } + return nil +} + +func SaveImage(output *oci.ImageSpec, image v1.Image, imageName string) error { + if output.Type == oci.OCI { + idx := v1.ImageIndex(empty.Index) + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ + Add: image, + Descriptor: v1.Descriptor{ + Annotations: map[string]string{ + oci.OciReferenceTarget: imageName, + }, + }, + }) + err := SaveIndexAsOCILayout(idx, output.Identifier) + if err != nil { + return fmt.Errorf("failed to write signed image: %w", err) + } + } else { + err := PushImageToRegistry(image, output.Identifier) + if err != nil { + return fmt.Errorf("failed to push signed image: %w", err) + } + } + return nil +} + +func SaveReferrers(manifest *attestation.AttestationManifest, outputs []*oci.ImageSpec) error { + for _, output := range outputs { + if output.Type == oci.OCI { + continue + } + // so that we use the same tag each time to reduce number of tags (tags aren't needed for referrers but we must push one) + attOut, err := oci.ReplaceTagInSpec(output, manifest.SubjectDescriptor.Digest) + if err != nil { + return err + } + //otherwise we end up with the detected platform, though I'm not sure it matters + attOut.Platform = &v1.Platform{ + OS: "unknown", + Architecture: "unknown", + } + images, err := manifest.BuildReferringArtifacts() + if err != nil { + return fmt.Errorf("failed to build image: %w", err) + } + for _, image := range images { + err = SaveImage(attOut, image, "") + } + if err != nil { + return fmt.Errorf("failed to push image: %w", err) + } + } + return nil +} diff --git a/pkg/mirror/mirror_test.go b/pkg/mirror/mirror_test.go new file mode 100644 index 0000000..f99fb5b --- /dev/null +++ b/pkg/mirror/mirror_test.go @@ -0,0 +1,111 @@ +package mirror + +import ( + "fmt" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + + "github.com/docker/attest/internal/test" + "github.com/docker/attest/pkg/attest" + "github.com/docker/attest/pkg/attestation" + "github.com/docker/attest/pkg/oci" + "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/stretchr/testify/require" +) + +func TestSavingIndex(t *testing.T) { + UnsignedTestImage := filepath.Join("..", "..", "test", "testdata", "unsigned-test-image") + outputLayout := test.CreateTempDir(t, "", "mirror-test") + attIdx, err := oci.IndexFromPath(UnsignedTestImage) + require.NoError(t, err) + + server := httptest.NewServer(registry.New()) + defer server.Close() + + u, err := url.Parse(server.URL) + require.NoError(t, err) + + indexName := fmt.Sprintf("%s/repo:root", u.Host) + output, err := oci.ParseImageSpecs(indexName) + require.NoError(t, err) + err = SaveIndex(output, attIdx.Index, indexName) + require.NoError(t, err) + + ociOutput, err := oci.ParseImageSpecs("oci://" + outputLayout) + require.NoError(t, err) + err = SaveIndex(ociOutput, attIdx.Index, indexName) + require.NoError(t, err) +} + +func TestSavingImage(t *testing.T) { + + outputLayout := test.CreateTempDir(t, "", "mirror-test") + + img := empty.Image + + server := httptest.NewServer(registry.New()) + defer server.Close() + + u, err := url.Parse(server.URL) + require.NoError(t, err) + + indexName := fmt.Sprintf("%s/repo:root", u.Host) + output, err := oci.ParseImageSpec(indexName) + require.NoError(t, err) + err = SaveImage(output, img, indexName) + require.NoError(t, err) + + ociOutput, err := oci.ParseImageSpec("oci://" + outputLayout) + require.NoError(t, err) + err = SaveImage(ociOutput, img, indexName) + require.NoError(t, err) +} + +func TestSavingReferrers(t *testing.T) { + ctx, signer := test.Setup(t) + opts := &attestation.SigningOptions{} + statement := &intoto.Statement{ + StatementHeader: intoto.StatementHeader{ + PredicateType: attestation.VSAPredicateType, + }, + } + + digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620") + require.NoError(t, err) + subject := &v1.Descriptor{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Digest: digest, + } + manifest, err := attest.NewAttestationManifest(subject) + require.NoError(t, err) + err = manifest.AddAttestation(ctx, signer, statement, opts) + require.NoError(t, err) + server := httptest.NewServer(registry.New(registry.WithReferrersSupport(true))) + defer server.Close() + + u, err := url.Parse(server.URL) + require.NoError(t, err) + + indexName := fmt.Sprintf("%s/repo:root", u.Host) + output, err := oci.ParseImageSpecs(indexName) + require.NoError(t, err) + err = SaveReferrers(manifest, output) + require.NoError(t, err) + + reg := &test.MockRegistryResolver{ + Subject: subject, + MockResolver: &test.MockResolver{}, + ImageNameStr: indexName, + } + require.NoError(t, err) + refResolver, err := oci.NewReferrersAttestationResolver(reg) + require.NoError(t, err) + attestations, err := refResolver.Attestations(ctx, attestation.VSAPredicateType) + require.NoError(t, err) + require.Len(t, attestations, 1) +} diff --git a/pkg/oci/layout.go b/pkg/oci/layout.go index bbb3b92..4793ecb 100644 --- a/pkg/oci/layout.go +++ b/pkg/oci/layout.go @@ -15,7 +15,7 @@ import ( // implementation of AttestationResolver that closes over attestations from an oci layout type OCILayoutResolver struct { - *AttestationManifest + *attestation.AttestationManifest *ImageSpec } @@ -30,7 +30,7 @@ func NewOCILayoutAttestationResolver(src *ImageSpec) (*OCILayoutResolver, error) return r, nil } -func (r *OCILayoutResolver) fetchAttestationManifest() (*AttestationManifest, error) { +func (r *OCILayoutResolver) fetchAttestationManifest() (*attestation.AttestationManifest, error) { if r.AttestationManifest == nil { m, err := attestationManifestFromOCILayout(r.Identifier, r.ImageSpec.Platform) if err != nil { @@ -43,19 +43,13 @@ func (r *OCILayoutResolver) fetchAttestationManifest() (*AttestationManifest, er } func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) { - attestationImage := r.AttestationManifest.Image - layers, err := attestationImage.Layers() - if err != nil { - return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err) - } var envs []*att.Envelope - manifest := r.AttestationManifest.Manifest - for i, l := range manifest.Layers { - if l.Annotations[attestation.InTotoPredicateType] != predicateType { + for _, attestationLayer := range r.AttestationManifest.OriginalLayers { + if attestationLayer.Annotations[attestation.InTotoPredicateType] != predicateType { continue } - layer := layers[i] - mt, err := layer.MediaType() + + mt, err := attestationLayer.Layer.MediaType() if err != nil { return nil, fmt.Errorf("failed to get layer media type: %w", err) } @@ -65,7 +59,7 @@ func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType stri } var env = new(att.Envelope) // parse layer blob as json - r, err := layer.Uncompressed() + r, err := attestationLayer.Layer.Uncompressed() if err != nil { return nil, fmt.Errorf("failed to get layer contents: %w", err) @@ -81,18 +75,18 @@ func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType stri } func (r *OCILayoutResolver) ImageName(ctx context.Context) (string, error) { - return r.Name, nil + return r.SubjectName, nil } -func (r *OCILayoutResolver) ImageDigest(ctx context.Context) (string, error) { - return r.Digest, nil +func (r *OCILayoutResolver) ImageDescriptor(ctx context.Context) (*v1.Descriptor, error) { + return r.SubjectDescriptor, nil } func (r *OCILayoutResolver) ImagePlatform(ctx context.Context) (*v1.Platform, error) { return r.ImageSpec.Platform, nil } -func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*AttestationManifest, error) { +func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*attestation.AttestationManifest, error) { idx, err := layout.ImageIndexFromPath(path) if err != nil { return nil, err @@ -115,10 +109,11 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*Atte if err != nil { return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err) } - var imageDigest string + var subjectDescriptor *v1.Descriptor for _, mf := range mfs2.Manifests { if mf.Platform.Equals(*platform) { - imageDigest = mf.Digest.String() + subjectDescriptor = &mf + break } } for _, mf := range mfs2.Manifests { @@ -126,7 +121,7 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*Atte continue } - if mf.Annotations[att.DockerReferenceDigest] != imageDigest { + if mf.Annotations[att.DockerReferenceDigest] != subjectDescriptor.Digest.String() { continue } @@ -134,17 +129,15 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*Atte if err != nil { return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err) } - manifest, err := attestationImage.Manifest() + layers, err := attestation.GetAttestationsFromImage(attestationImage) if err != nil { - return nil, fmt.Errorf("failed to get manifest: %w", err) + return nil, fmt.Errorf("failed to get attestations from image: %w", err) } - attest := &AttestationManifest{ - Name: name, - Image: attestationImage, - Manifest: manifest, - Descriptor: &mf, - Digest: imageDigest, - Platform: platform, + attest := &attestation.AttestationManifest{ + OriginalLayers: layers, + OriginalDescriptor: &mf, + SubjectName: name, + SubjectDescriptor: subjectDescriptor, } return attest, nil } diff --git a/pkg/oci/oci.go b/pkg/oci/oci.go index 255d8bf..f5f051c 100644 --- a/pkg/oci/oci.go +++ b/pkg/oci/oci.go @@ -8,6 +8,7 @@ import ( "github.com/containerd/containerd/platforms" "github.com/distribution/reference" + "github.com/docker/attest/pkg/attestation" att "github.com/docker/attest/pkg/attestation" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -46,19 +47,17 @@ func WithOptions(ctx context.Context, platform *v1.Platform) []remote.Option { return options } -func ExtractEnvelopes(ia *AttestationManifest, predicateType string) ([]*att.Envelope, error) { - manifest := ia.Manifest - image := ia.Image +func ExtractEnvelopes(manifest *attestation.AttestationManifest, predicateType string) ([]*att.Envelope, error) { var envs []*att.Envelope - layers, err := image.Layers() - if err != nil { - return nil, fmt.Errorf("failed to get layers: %w", err) - } - for i, l := range manifest.Layers { - if (strings.HasPrefix(string(l.MediaType), "application/vnd.in-toto.")) && - strings.HasSuffix(string(l.MediaType), "+dsse") && - l.Annotations[att.InTotoPredicateType] == predicateType { - reader, err := layers[i].Uncompressed() + for _, attestationLayer := range manifest.OriginalLayers { + mt, err := attestationLayer.Layer.MediaType() + if err != nil { + return nil, fmt.Errorf("failed to get layer media type: %w", err) + } + if (strings.HasPrefix(string(mt), "application/vnd.in-toto.")) && + strings.HasSuffix(string(mt), "+dsse") && + attestationLayer.Annotations[att.InTotoPredicateType] == predicateType { + reader, err := attestationLayer.Layer.Uncompressed() if err != nil { return nil, fmt.Errorf("failed to get layer contents: %w", err) } @@ -75,13 +74,13 @@ func ExtractEnvelopes(ia *AttestationManifest, predicateType string) ([]*att.Env return envs, nil } -func imageDigestForPlatform(ix *v1.IndexManifest, platform *v1.Platform) (string, error) { +func imageDescriptor(ix *v1.IndexManifest, platform *v1.Platform) (*v1.Descriptor, error) { for _, m := range ix.Manifests { if (m.MediaType == ocispec.MediaTypeImageManifest || m.MediaType == "application/vnd.docker.distribution.manifest.v2+json") && m.Platform.Equals(*platform) { - return m.Digest.String(), nil + return &m, nil } } - return "", errors.New(fmt.Sprintf("no image found for platform %v", platform)) + return nil, errors.New(fmt.Sprintf("no image found for platform %v", platform)) } func attestationDigestForDigest(ix *v1.IndexManifest, imageDigest string, attestType string) (string, error) { @@ -147,3 +146,27 @@ func SplitDigest(digest string) (common.DigestSet, error) { parts[0]: parts[1], }, nil } + +func ReplaceTagInSpec(src *ImageSpec, digest v1.Hash) (*ImageSpec, error) { + newName, err := replaceTag(src.Identifier, digest) + if err != nil { + return nil, fmt.Errorf("failed to parse repo name: %w", err) + } + return &ImageSpec{ + Identifier: newName, + Type: src.Type, + Platform: src.Platform, + }, nil +} + +// so that the index tag is replaced with a tag unique to the image digest and doesn't overwrite it +func replaceTag(image string, digest v1.Hash) (string, error) { + if strings.HasPrefix(image, LocalPrefix) { + return image, nil + } + notag, err := WithoutTag(image) + if err != nil { + return "", nil + } + return fmt.Sprintf("%s:%s-%s.att", notag, digest.Algorithm, digest.Hex), nil +} diff --git a/pkg/oci/oci_test.go b/pkg/oci/oci_test.go index eaff934..b9aa68b 100644 --- a/pkg/oci/oci_test.go +++ b/pkg/oci/oci_test.go @@ -4,6 +4,7 @@ import ( "path/filepath" "testing" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/layout" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -75,14 +76,16 @@ func TestImageDigestForPlatform(t *testing.T) { p, err := ParsePlatform("linux/amd64") assert.NoError(t, err) - digest, err := imageDigestForPlatform(mfs2, p) + desc, err := imageDescriptor(mfs2, p) assert.NoError(t, err) + digest := desc.Digest.String() assert.Equal(t, "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620", digest) p, err = ParsePlatform("linux/arm64") assert.NoError(t, err) - digest, err = imageDigestForPlatform(mfs2, p) + desc, err = imageDescriptor(mfs2, p) assert.NoError(t, err) + digest = desc.Digest.String() assert.Equal(t, "sha256:7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e", digest) } @@ -106,3 +109,31 @@ func TestWithoutTag(t *testing.T) { }) } } + +func TestReplaceTag(t *testing.T) { + tc := []struct { + name string + expected string + }{ + {name: "image:tag", expected: "index.docker.io/library/image:sha256-digest.att"}, + {name: "image", expected: "index.docker.io/library/image:sha256-digest.att"}, + {name: "image:sha256-digest.att", expected: "index.docker.io/library/image:sha256-digest.att"}, + {name: "docker://image:tag", expected: "docker://index.docker.io/library/image:sha256-digest.att"}, + {name: "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: "index.docker.io/library/image:sha256-digest.att"}, + {name: "oci://foobar", expected: "oci://foobar"}, + {name: "docker://image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: "docker://index.docker.io/library/image:sha256-digest.att"}, + {name: "docker://127.0.0.1:36555/repo:latest", expected: "docker://127.0.0.1:36555/repo:sha256-digest.att"}, + } + + digest := v1.Hash{ + Algorithm: "sha256", + Hex: "digest", + } + for _, c := range tc { + t.Run(c.name, func(t *testing.T) { + replaced, err := replaceTag(c.name, digest) + require.NoError(t, err) + assert.Equal(t, c.expected, replaced) + }) + } +} diff --git a/pkg/oci/referrers.go b/pkg/oci/referrers.go index 5ede960..12c0a4c 100644 --- a/pkg/oci/referrers.go +++ b/pkg/oci/referrers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/docker/attest/pkg/attestation" att "github.com/docker/attest/pkg/attestation" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -13,13 +14,13 @@ import ( type ReferrersResolver struct { digest string referrersRepo string - manifests []*AttestationManifest - *RegistryImageDetailsResolver + manifests []*attestation.AttestationManifest + ImageDetailsResolver } -func NewReferrersAttestationResolver(src *RegistryImageDetailsResolver, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) { +func NewReferrersAttestationResolver(src ImageDetailsResolver, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) { res := &ReferrersResolver{ - RegistryImageDetailsResolver: src, + ImageDetailsResolver: src, } for _, opt := range options { err := opt(res) @@ -39,11 +40,19 @@ func WithReferrersRepo(repo string) func(*ReferrersResolver) error { func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error { if r.manifests == nil { - subjectRef, err := name.ParseReference(r.Identifier) + imageName, err := r.ImageName(ctx) + if err != nil { + return fmt.Errorf("failed to get image name: %w", err) + } + subjectRef, err := name.ParseReference(imageName) if err != nil { return fmt.Errorf("failed to parse reference: %w", err) } - subjectDigest, err := r.ImageDigest(ctx) + desc, err := r.ImageDescriptor(ctx) + if err != nil { + return fmt.Errorf("failed to get descriptor: %w", err) + } + subjectDigest := desc.Digest.String() if err != nil { return fmt.Errorf("failed to get digest: %w", err) } @@ -56,6 +65,7 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error { } else { referrersSubjectRef = subjectRef.Context().Digest(subjectDigest) } + // TODO - search for in-toto artifact type referrersIndex, err := remote.Referrers(referrersSubjectRef) if err != nil { return fmt.Errorf("failed to get referrers: %w", err) @@ -67,31 +77,22 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error { if len(referrersIndexManifest.Manifests) == 0 { return errors.New("no referrers found") } - aManifests := make([]*AttestationManifest, 0) + aManifests := make([]*attestation.AttestationManifest, 0) for _, m := range referrersIndexManifest.Manifests { - remoteRef := referrersSubjectRef.Context().Digest(m.Digest.String()) attestationImage, err := remote.Image(remoteRef) if err != nil { return fmt.Errorf("failed to get referred image: %w", err) } - manifest, err := attestationImage.Manifest() + layers, err := attestation.GetAttestationsFromImage(attestationImage) if err != nil { - return fmt.Errorf("failed to get manifest: %w", err) + return fmt.Errorf("failed to get attestations from image: %w", err) } - if manifest.Annotations[att.DockerReferenceType] != att.AttestationManifestType { - continue - } - if manifest.Annotations[att.DockerReferenceDigest] != subjectDigest { - continue - } - attest := &AttestationManifest{ - Name: r.Identifier, - Image: attestationImage, - Manifest: manifest, - Descriptor: &m, - Digest: subjectDigest, - Platform: r.Platform, + attest := &attestation.AttestationManifest{ + SubjectName: imageName, + OriginalLayers: layers, + OriginalDescriptor: &m, + SubjectDescriptor: desc, } aManifests = append(aManifests, attest) } diff --git a/pkg/oci/registry.go b/pkg/oci/registry.go index ab0f6a5..4e766c7 100644 --- a/pkg/oci/registry.go +++ b/pkg/oci/registry.go @@ -2,9 +2,9 @@ package oci import ( "context" - "encoding/json" "fmt" + "github.com/docker/attest/pkg/attestation" att "github.com/docker/attest/pkg/attestation" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -13,12 +13,12 @@ import ( type RegistryResolver struct { *RegistryImageDetailsResolver - *AttestationManifest + *attestation.AttestationManifest } type RegistryImageDetailsResolver struct { *ImageSpec - digest string + descriptor *v1.Descriptor } func NewRegistryImageDetailsResolver(src *ImageSpec) (*RegistryImageDetailsResolver, error) { @@ -41,24 +41,36 @@ func (r *RegistryImageDetailsResolver) ImagePlatform(ctx context.Context) (*v1.P return r.Platform, nil } -func (r *RegistryImageDetailsResolver) ImageDigest(ctx context.Context) (string, error) { - if r.digest == "" { +func (r *RegistryImageDetailsResolver) ImageDescriptor(ctx context.Context) (*v1.Descriptor, error) { + if r.descriptor == nil { subjectRef, err := name.ParseReference(r.Identifier) if err != nil { - return "", fmt.Errorf("failed to parse reference: %w", err) + return nil, fmt.Errorf("failed to parse reference: %w", err) } options := WithOptions(ctx, r.Platform) - desc, err := remote.Image(subjectRef, options...) + image, err := remote.Image(subjectRef, options...) if err != nil { - return "", fmt.Errorf("failed to get image manifest: %w", err) + return nil, fmt.Errorf("failed to get image manifest: %w", err) } - subjectDigest, err := desc.Digest() + digest, err := image.Digest() if err != nil { - return "", fmt.Errorf("failed to get image digest: %w", err) + return nil, fmt.Errorf("failed to get image digest: %w", err) + } + size, err := image.Size() + if err != nil { + return nil, fmt.Errorf("failed to get image size: %w", err) + } + mediaType, err := image.MediaType() + if err != nil { + return nil, fmt.Errorf("failed to get image media type: %w", err) + } + r.descriptor = &v1.Descriptor{ + Digest: digest, + Size: size, + MediaType: mediaType, } - r.digest = subjectDigest.String() } - return r.digest, nil + return r.descriptor, nil } func (r *RegistryResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) { @@ -72,7 +84,7 @@ func (r *RegistryResolver) Attestations(ctx context.Context, predicateType strin return ExtractEnvelopes(r.AttestationManifest, predicateType) } -func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Platform) (*AttestationManifest, error) { +func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Platform) (*attestation.AttestationManifest, error) { // we want to get to the image index, so ignoring platform for now options := WithOptions(ctx, nil) ref, err := name.ParseReference(image) @@ -87,10 +99,12 @@ func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Pl if err != nil { return nil, fmt.Errorf("failed to get index manifest: %w", err) } - digest, err := imageDigestForPlatform(indexManifest, platform) + subjectDescriptor, err := imageDescriptor(indexManifest, platform) if err != nil { return nil, fmt.Errorf("failed to obtain image for platform: %w", err) } + + digest := subjectDescriptor.Digest.String() ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), digest)) if err != nil { return nil, fmt.Errorf("failed to parse attestation reference: %w", err) @@ -108,22 +122,20 @@ func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Pl if err != nil { return nil, fmt.Errorf("failed to get attestation: %w", err) } - manifest := new(v1.Manifest) - err = json.Unmarshal(remoteDescriptor.Manifest, manifest) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal attestation: %w", err) - } attestationImage, err := remoteDescriptor.Image() if err != nil { return nil, fmt.Errorf("failed to get attestation image: %w", err) } - attest := &AttestationManifest{ - Name: image, - Image: attestationImage, - Manifest: manifest, - Descriptor: &remoteDescriptor.Descriptor, - Digest: digest, - Platform: platform, + + layers, err := attestation.GetAttestationsFromImage(attestationImage) + if err != nil { + return nil, fmt.Errorf("failed to get attestations from image: %w", err) + } + attest := &attestation.AttestationManifest{ + OriginalLayers: layers, + OriginalDescriptor: &remoteDescriptor.Descriptor, + SubjectName: image, + SubjectDescriptor: subjectDescriptor, } return attest, nil } diff --git a/pkg/oci/registry_test.go b/pkg/oci/registry_test.go index 655de82..d6b1edf 100644 --- a/pkg/oci/registry_test.go +++ b/pkg/oci/registry_test.go @@ -25,16 +25,13 @@ func TestRegistry(t *testing.T) { u, err := url.Parse(server.URL) require.NoError(t, err) - opts := &attestation.SigningOptions{ - Replace: true, - SkipSubject: true, - } + opts := &attestation.SigningOptions{} attIdx, err := oci.IndexFromPath(oci.UnsignedTestImage) require.NoError(t, err) signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) signedIndex := attIdx.Index - signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests) require.NoError(t, err) indexName := fmt.Sprintf("%s/repo:root", u.Host) @@ -47,7 +44,8 @@ func TestRegistry(t *testing.T) { resolver, err := policy.CreateImageDetailsResolver(spec) require.NoError(t, err) - digest, err := resolver.ImageDigest(ctx) + desc, err := resolver.ImageDescriptor(ctx) require.NoError(t, err) + digest := desc.Digest.String() assert.True(t, strings.Contains(digest, "sha256:")) } diff --git a/pkg/oci/resolver.go b/pkg/oci/resolver.go index c6628cb..2c653f9 100644 --- a/pkg/oci/resolver.go +++ b/pkg/oci/resolver.go @@ -7,21 +7,6 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" ) -type AttestationManifests struct { - Manifests []*AttestationManifest -} - -type AttestationManifest struct { - // attestation image details - Image v1.Image - Manifest *v1.Manifest - Descriptor *v1.Descriptor - // details of subect image - Name string - Digest string - Platform *v1.Platform -} - type AttestationResolver interface { ImageDetailsResolver Attestations(ctx context.Context, mediaType string) ([]*att.Envelope, error) @@ -30,25 +15,5 @@ type AttestationResolver interface { type ImageDetailsResolver interface { ImageName(ctx context.Context) (string, error) ImagePlatform(ctx context.Context) (*v1.Platform, error) - ImageDigest(ctx context.Context) (string, error) -} - -type MockResolver struct { - Envs []*att.Envelope -} - -func (r MockResolver) Attestations(ctx context.Context, mediaType string) ([]*att.Envelope, error) { - return r.Envs, nil -} - -func (r MockResolver) ImageName(ctx context.Context) (string, error) { - return "library/alpine:latest", nil -} - -func (r MockResolver) ImageDigest(ctx context.Context) (string, error) { - return "sha256:test-digest", nil -} - -func (r MockResolver) ImagePlatform(ctx context.Context) (*v1.Platform, error) { - return ParsePlatform("linux/amd64") + ImageDescriptor(ctx context.Context) (*v1.Descriptor, error) } diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index c45e445..82ab8b1 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -38,7 +38,7 @@ func TestRegoEvaluator_Evaluate(t *testing.T) { re := policy.NewRegoEvaluator(true) - defaultResolver := oci.MockResolver{ + defaultResolver := test.MockResolver{ Envs: []*attestation.Envelope{loadAttestation(t, ExampleAttestation)}, } diff --git a/pkg/signerverifier/gcp_test.go b/pkg/signerverifier/gcp_test.go index cdaa2cf..78ca8c4 100644 --- a/pkg/signerverifier/gcp_test.go +++ b/pkg/signerverifier/gcp_test.go @@ -22,7 +22,7 @@ k2s4SO3XbQ2GG2alm289SUUpmBAuVxvT8muYQ8HC/QzixzyTACTXsBDjQg== func TestGCPKMS_Signer(t *testing.T) { // create a new signer - ctx := context.Background() + ctx := context.TODO() ref := "projects/attest-kms-test/locations/us-west1/keyRings/attest-kms-test/cryptoKeys/test-signing-key/cryptoKeyVersions/1" signer, err := GetGCPSigner(ctx, ref) require.NoError(t, err)