diff --git a/go.mod b/go.mod index 7cfc48bb..db04613f 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty-funcs v0.0.0-20230405223818-a090f58aa992 github.com/hashicorp/hcl/v2 v2.19.1 + github.com/in-toto/in-toto-golang v0.5.0 github.com/moby/buildkit v0.13.0 github.com/moby/sys/mountinfo v0.7.1 github.com/moby/sys/signal v0.7.0 @@ -106,7 +107,6 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/imdario/mergo v0.3.16 // indirect - github.com/in-toto/in-toto-golang v0.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/util/imagetools/imagetools_helpers_test.go b/util/imagetools/imagetools_helpers_test.go new file mode 100644 index 00000000..52a3a05d --- /dev/null +++ b/util/imagetools/imagetools_helpers_test.go @@ -0,0 +1,255 @@ +package imagetools + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "strings" + + "github.com/containerd/containerd/remotes" + intoto "github.com/in-toto/in-toto-golang/in_toto" + slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +type attestationType int + +const ( + plainSpdx attestationType = 0 + dsseEmbeded attestationType = 1 + plainSpdxAndDSSEEmbed attestationType = 2 +) + +type mockFetcher struct { +} + +type mockResolver struct { + fetcher remotes.Fetcher + pusher remotes.Pusher +} + +func (f mockFetcher) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error) { + reader := io.NopCloser(strings.NewReader(desc.Annotations["test_content"])) + return reader, nil +} + +func (r mockResolver) Resolve(ctx context.Context, ref string) (name string, desc ocispec.Descriptor, err error) { + return "", ocispec.Descriptor{}, nil +} + +func (r mockResolver) Fetcher(ctx context.Context, ref string) (remotes.Fetcher, error) { + return r.fetcher, nil +} + +func (r mockResolver) Pusher(ctx context.Context, ref string) (remotes.Pusher, error) { + return r.pusher, nil +} + +func getMockResolver() remotes.Resolver { + resolver := mockResolver{ + fetcher: mockFetcher{}, + } + + return resolver +} + +func getImageNoAttestation() *result { + r := &result{ + indexes: make(map[digest.Digest]index), + manifests: make(map[digest.Digest]manifest), + images: make(map[string]digest.Digest), + refs: make(map[digest.Digest][]digest.Digest), + assets: make(map[string]asset), + } + + r.images["linux/amd64"] = "sha256:linux/amd64" + r.images["linux/arm64"] = "sha256:linux/arm64" + + r.manifests["sha256:linux/amd64-manifest"] = manifest{ + desc: ocispec.Descriptor{ + MediaType: v1.MediaTypeImageManifest, + Digest: "sha256:linux/amd64-manifest", + Platform: &v1.Platform{ + Architecture: "amd64", + OS: "linux", + }, + }, + manifest: ocispec.Manifest{ + MediaType: v1.MediaTypeImageManifest, + Layers: []v1.Descriptor{ + { + MediaType: v1.MediaTypeImageLayerGzip, + Digest: "sha256:linux/amd64-content", + Size: 1234, + }, + }, + }, + } + r.manifests["sha256:linux/arm64-manifest"] = manifest{ + desc: ocispec.Descriptor{ + MediaType: v1.MediaTypeImageManifest, + Digest: "sha256:linux/arm64-manifest", + Platform: &v1.Platform{ + Architecture: "arm64", + OS: "linux", + }, + }, + manifest: ocispec.Manifest{ + MediaType: v1.MediaTypeImageManifest, + Layers: []v1.Descriptor{ + { + MediaType: v1.MediaTypeImageLayerGzip, + Digest: "sha256:linux/arm64-content", + Size: 1234, + }, + }, + }, + } + + return r +} + +func getImageWithAttestation(t attestationType) *result { + r := getImageNoAttestation() + + r.manifests["sha256:linux/amd64-attestation"] = manifest{ + desc: ocispec.Descriptor{ + MediaType: v1.MediaTypeImageManifest, + Digest: "sha256:linux/amd64-attestation", + Annotations: map[string]string{ + "vnd.docker.reference.digest": "sha256:linux/amd64", + "vnd.docker.reference.type": "attestation-manifest", + }, + Platform: &v1.Platform{ + Architecture: "unknown", + OS: "unknown", + }, + }, + manifest: ocispec.Manifest{ + MediaType: v1.MediaTypeImageManifest, + Layers: getAttestationLayers(t), + }, + } + + return r +} + +func getAttestationLayers(t attestationType) []v1.Descriptor { + layers := []v1.Descriptor{} + + if t == plainSpdx || t == plainSpdxAndDSSEEmbed { + layers = append(layers, v1.Descriptor{ + MediaType: inTotoGenericMime, + Digest: digest.FromString(attestationContent), + Size: int64(len(attestationContent)), + Annotations: map[string]string{ + "in-toto.io/predicate-type": intoto.PredicateSPDX, + "test_content": attestationContent, + }, + }) + layers = append(layers, v1.Descriptor{ + MediaType: inTotoGenericMime, + Digest: digest.FromString(provenanceContent), + Size: int64(len(provenanceContent)), + Annotations: map[string]string{ + "in-toto.io/predicate-type": slsa02.PredicateSLSAProvenance, + "test_content": provenanceContent, + }, + }) + } + + if t == dsseEmbeded || t == plainSpdxAndDSSEEmbed { + dsseAttestation := fmt.Sprintf("{\"payload\":\"%s\"}", base64.StdEncoding.EncodeToString([]byte(attestationContent))) + dsseProvenance := fmt.Sprintf("{\"payload\":\"%s\"}", base64.StdEncoding.EncodeToString([]byte(provenanceContent))) + layers = append(layers, v1.Descriptor{ + MediaType: inTotoSPDXDSSEMime, + Digest: digest.FromString(dsseAttestation), + Size: int64(len(dsseAttestation)), + Annotations: map[string]string{ + "in-toto.io/predicate-type": intoto.PredicateSPDX, + "test_content": dsseAttestation, + }, + }) + layers = append(layers, v1.Descriptor{ + MediaType: inTotoProvenanceDSSEMime, + Digest: digest.FromString(dsseProvenance), + Size: int64(len(dsseProvenance)), + Annotations: map[string]string{ + "in-toto.io/predicate-type": slsa02.PredicateSLSAProvenance, + "test_content": dsseProvenance, + }, + }) + } + + return layers +} + +const attestationContent = ` +{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://spdx.dev/Document", + "predicate": { + "name": "sbom", + "spdxVersion": "SPDX-2.3", + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2024-01-31T16:09:05Z", + "creators": [ + "Tool: buildkit-v0.11.0" + ], + "licenseListVersion": "3.22" + }, + "dataLicense": "CC0-1.0", + "documentNamespace": "https://example.com", + "packages": [ + { + "name": "sbom", + "SPDXID": "SPDXRef-DocumentRoot-Directory-sbom", + "copyrightText": "", + "downloadLocation": "NOASSERTION", + "primaryPackagePurpose": "FILE", + "supplier": "NOASSERTION" + } + ], + "relationships": [ + { + "relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-sbom", + "relationshipType": "DESCRIBES", + "spdxElementId": "SPDXRef-DOCUMENT" + } + ] + } +} +` + +const provenanceContent = ` +{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://slsa.dev/provenance/v0.2", + "predicate": { + "buildType": "https://example.com/Makefile", + "builder": { + "id": "mailto:person@example.com" + }, + "invocation": { + "configSource": { + "uri": "https://example.com/example-1.2.3.tar.gz", + "digest": {"sha256": ""}, + "entryPoint": "src:foo" + }, + "parameters": { + "CFLAGS": "-O3" + }, + "materials": [ + { + "uri": "https://example.com/example-1.2.3.tar.gz", + "digest": {"sha256": ""} + } + ] + } + } +} +` diff --git a/util/imagetools/loader.go b/util/imagetools/loader.go index 06df9c01..b09f05d4 100644 --- a/util/imagetools/loader.go +++ b/util/imagetools/loader.go @@ -16,6 +16,7 @@ import ( "github.com/containerd/containerd/platforms" "github.com/containerd/containerd/remotes" "github.com/distribution/reference" + intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/moby/buildkit/util/contentutil" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -292,7 +293,7 @@ func (l *loader) scanSBOM(ctx context.Context, fetcher remotes.Fetcher, r *resul } for _, layer := range mfst.manifest.Layers { if (layer.MediaType == inTotoGenericMime || isInTotoDSSE(layer.MediaType)) && - layer.Annotations["in-toto.io/predicate-type"] == "https://spdx.dev/Document" { + layer.Annotations["in-toto.io/predicate-type"] == intoto.PredicateSPDX { _, err := remotes.FetchHandler(l.cache, fetcher)(ctx, layer) if err != nil { return nil, err diff --git a/util/imagetools/loader_test.go b/util/imagetools/loader_test.go index 5adbb287..acea6fd5 100644 --- a/util/imagetools/loader_test.go +++ b/util/imagetools/loader_test.go @@ -1,19 +1,96 @@ package imagetools import ( + "context" "encoding/base64" "fmt" "testing" + "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" ) -func Test_scanSBOM(t *testing.T) { +func TestSBOM(t *testing.T) { + tests := []struct { + name string + contentType attestationType + }{ + { + name: "Plain SPDX", + contentType: plainSpdx, + }, + { + name: "SPDX in DSSE envelope", + contentType: dsseEmbeded, + }, + { + name: "Plain SPDX and SPDX in DSSE envelope", + contentType: plainSpdxAndDSSEEmbed, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + loader := newLoader(getMockResolver()) + ctx := context.Background() + fetcher, _ := loader.resolver.Fetcher(ctx, "") + + r := getImageWithAttestation(test.contentType) + r.refs["sha256:linux/amd64"] = []digest.Digest{ + "sha256:linux/amd64-attestation", + } + a := asset{} + loader.scanSBOM(ctx, fetcher, r, r.refs["sha256:linux/amd64"], &a) + r.assets["linux/amd64"] = a + actual, err := r.SBOM() + + assert.NoError(t, err) + assert.Equal(t, 1, len(actual)) + }) + } } -func Test_scanProvenance(t *testing.T) { +func TestProvenance(t *testing.T) { + tests := []struct { + name string + contentType attestationType + }{ + { + name: "Plain SPDX", + contentType: plainSpdx, + }, + { + name: "SPDX in DSSE envelope", + contentType: dsseEmbeded, + }, + { + name: "Plain SPDX and SPDX in DSSE envelope", + contentType: plainSpdxAndDSSEEmbed, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + loader := newLoader(getMockResolver()) + ctx := context.Background() + fetcher, _ := loader.resolver.Fetcher(ctx, "") + + r := getImageWithAttestation(test.contentType) + + r.refs["sha256:linux/amd64"] = []digest.Digest{ + "sha256:linux/amd64-attestation", + } + + a := asset{} + loader.scanProvenance(ctx, fetcher, r, r.refs["sha256:linux/amd64"], &a) + r.assets["linux/amd64"] = a + actual, err := r.Provenance() + + assert.NoError(t, err) + assert.Equal(t, 1, len(actual)) + }) + } } func Test_isInTotoDSSE(t *testing.T) { @@ -55,9 +132,9 @@ func Test_decodeDSSE(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "hello world", string(actual)) - actual, err = decodeDSSE([]byte("not a json"), "application/vnd.in-toto.spdx+dsse") + _, err = decodeDSSE([]byte("not a json"), "application/vnd.in-toto.spdx+dsse") assert.Error(t, err) - actual, err = decodeDSSE([]byte("{\"payload\": \"not base64\"}"), "application/vnd.in-toto.spdx+dsse") + _, err = decodeDSSE([]byte("{\"payload\": \"not base64\"}"), "application/vnd.in-toto.spdx+dsse") assert.Error(t, err) }