diff --git a/go.mod b/go.mod index 01ea50ff..bc62f848 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 @@ -107,7 +108,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..77a3d74b --- /dev/null +++ b/util/imagetools/imagetools_helpers_test.go @@ -0,0 +1,399 @@ +package imagetools + +import ( + "context" + "encoding/base64" + "encoding/json" + "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 +} + +var manifests = make(map[digest.Digest]manifest) +var indexes = make(map[digest.Digest]index) + +func (f mockFetcher) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error) { + switch desc.MediaType { + case ocispec.MediaTypeImageIndex: + reader := io.NopCloser(strings.NewReader(indexes[desc.Digest].desc.Annotations["test_content"])) + return reader, nil + case ocispec.MediaTypeImageManifest: + reader := io.NopCloser(strings.NewReader(manifests[desc.Digest].desc.Annotations["test_content"])) + return reader, nil + default: + 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) { + d := digest.Digest(strings.ReplaceAll(ref, "docker.io/library/test@", "")) + return string(d), indexes[d].desc, 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 { + return getImageFromManifests(getBaseManifests()) +} + +func getImageWithAttestation(t attestationType) *result { + manifestList := getBaseManifests() + + objManifest := ocispec.Manifest{ + MediaType: v1.MediaTypeImageManifest, + Layers: getAttestationLayers(t), + Annotations: map[string]string{ + "platform": "linux/amd64", + }, + } + jsonContent, _ := json.Marshal(objManifest) + jsonString := string(jsonContent) + d := digest.FromString(jsonString) + + manifestList[d] = manifest{ + desc: ocispec.Descriptor{ + MediaType: v1.MediaTypeImageManifest, + Digest: d, + Size: int64(len(jsonString)), + Annotations: map[string]string{ + "vnd.docker.reference.digest": string(getManifestDigestForArch(manifestList, "linux", "amd64")), + "vnd.docker.reference.type": "attestation-manifest", + "test_content": jsonString, + }, + Platform: &v1.Platform{ + Architecture: "unknown", + OS: "unknown", + }, + }, + manifest: objManifest, + } + + objManifest = ocispec.Manifest{ + MediaType: v1.MediaTypeImageManifest, + Layers: getAttestationLayers(t), + Annotations: map[string]string{ + "platform": "linux/arm64", + }, + } + jsonContent, _ = json.Marshal(objManifest) + jsonString = string(jsonContent) + d = digest.FromString(jsonString) + manifestList[d] = manifest{ + desc: ocispec.Descriptor{ + MediaType: v1.MediaTypeImageManifest, + Digest: d, + Size: int64(len(jsonString)), + Annotations: map[string]string{ + "vnd.docker.reference.digest": string(getManifestDigestForArch(manifestList, "linux", "arm64")), + "vnd.docker.reference.type": "attestation-manifest", + "test_content": jsonString, + }, + Platform: &v1.Platform{ + Architecture: "unknown", + OS: "unknown", + }, + }, + } + + return getImageFromManifests(manifestList) +} + +func getImageFromManifests(manifests map[digest.Digest]manifest) *result { + r := &result{ + indexes: make(map[digest.Digest]index), + manifests: manifests, + images: make(map[string]digest.Digest), + refs: make(map[digest.Digest][]digest.Digest), + assets: make(map[string]asset), + } + + r.images["linux/amd64"] = getManifestDigestForArch(manifests, "linux", "amd64") + r.images["linux/arm64"] = getManifestDigestForArch(manifests, "linux", "arm64") + + manifestsDesc := []v1.Descriptor{} + for _, val := range manifests { + manifestsDesc = append(manifestsDesc, val.desc) + } + + objIndex := v1.Index{ + MediaType: v1.MediaTypeImageIndex, + Manifests: manifestsDesc, + } + jsonContent, _ := json.Marshal(objIndex) + jsonString := string(jsonContent) + d := digest.FromString(jsonString) + + if _, ok := indexes[d]; !ok { + indexes[d] = index{ + desc: ocispec.Descriptor{ + MediaType: v1.MediaTypeImageIndex, + Digest: d, + Size: int64(len(jsonString)), + Annotations: map[string]string{ + "test_content": jsonString, + }, + }, + index: objIndex, + } + } + + r.indexes[d] = indexes[d] + return r +} + +func getManifestDigestForArch(manifests map[digest.Digest]manifest, os string, arch string) digest.Digest { + for d, m := range manifests { + if m.desc.Platform.OS == os && m.desc.Platform.Architecture == arch { + return d + } + } + + return digest.Digest("") +} + +func getBaseManifests() map[digest.Digest]manifest { + if len(manifests) == 0 { + config := getConfig() + content := "amd64-content" + objManifest := ocispec.Manifest{ + MediaType: v1.MediaTypeImageManifest, + Config: config, + Layers: []v1.Descriptor{ + { + MediaType: v1.MediaTypeImageLayerGzip, + Digest: digest.FromString(content), + Size: int64(len(content)), + }, + }, + } + jsonContent, _ := json.Marshal(objManifest) + jsonString := string(jsonContent) + d := digest.FromString(jsonString) + + manifests[d] = manifest{ + desc: ocispec.Descriptor{ + MediaType: v1.MediaTypeImageManifest, + Digest: d, + Size: int64(len(jsonString)), + Platform: &v1.Platform{ + Architecture: "amd64", + OS: "linux", + }, + Annotations: map[string]string{ + "test_content": jsonString, + }, + }, + manifest: objManifest, + } + + content = "arm64-content" + objManifest = ocispec.Manifest{ + MediaType: v1.MediaTypeImageManifest, + Config: config, + Layers: []v1.Descriptor{ + { + MediaType: v1.MediaTypeImageLayerGzip, + Digest: digest.FromString(content), + Size: int64(len(content)), + }, + }, + } + jsonContent, _ = json.Marshal(objManifest) + jsonString = string(jsonContent) + d = digest.FromString(jsonString) + + manifests[d] = manifest{ + desc: ocispec.Descriptor{ + MediaType: v1.MediaTypeImageManifest, + Digest: d, + Size: int64(len(jsonString)), + Platform: &v1.Platform{ + Architecture: "arm64", + OS: "linux", + }, + Annotations: map[string]string{ + "test_content": jsonString, + }, + }, + manifest: objManifest, + } + } + + return manifests +} + +func getConfig() v1.Descriptor { + config := v1.ImageConfig{ + Env: []string{ + "config", + }, + } + jsonContent, _ := json.Marshal(config) + jsonString := string(jsonContent) + d := digest.FromString(jsonString) + + return v1.Descriptor{ + MediaType: ocispec.MediaTypeImageConfig, + Digest: d, + Size: int64(len(jsonString)), + Annotations: map[string]string{ + "test_content": jsonString, + }, + } +} + +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 new file mode 100644 index 00000000..e4713742 --- /dev/null +++ b/util/imagetools/loader_test.go @@ -0,0 +1,193 @@ +package imagetools + +import ( + "context" + "encoding/base64" + "fmt" + "reflect" + "testing" + + "github.com/opencontainers/go-digest" + + "github.com/stretchr/testify/assert" +) + +func TestLoad(t *testing.T) { + loader := newLoader(getMockResolver()) + ctx := context.Background() + + r := getImageNoAttestation() + indexDigest := reflect.ValueOf(r.indexes).MapKeys()[0].String() + result, err := loader.Load(ctx, fmt.Sprintf("test@%s", indexDigest)) + assert.NoError(t, err) + if err == nil { + assert.Equal(t, 1, len(result.indexes)) + assert.Equal(t, 2, len(result.images)) + assert.Equal(t, 2, len(result.platforms)) + assert.Equal(t, 2, len(result.manifests)) + assert.Equal(t, 2, len(result.assets)) + assert.Equal(t, 0, len(result.refs)) + } + + r = getImageWithAttestation(plainSpdx) + indexDigest = reflect.ValueOf(r.indexes).MapKeys()[0].String() + result, err = loader.Load(ctx, fmt.Sprintf("test@%s", indexDigest)) + assert.NoError(t, err) + if err == nil { + assert.Equal(t, 1, len(result.indexes)) + assert.Equal(t, 2, len(result.images)) + assert.Equal(t, 2, len(result.platforms)) + assert.Equal(t, 4, len(result.manifests)) + assert.Equal(t, 2, len(result.assets)) + assert.Equal(t, 2, len(result.refs)) + + for d1, m := range r.manifests { + if _, ok := m.desc.Annotations["vnd.docker.reference.digest"]; ok { + d2 := digest.Digest(m.desc.Annotations["vnd.docker.reference.digest"]) + assert.Equal(t, d1, result.refs[d2][0]) + } + } + } +} + +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) + imageDigest := r.images["linux/amd64"] + + // Manual mapping + for d, m := range r.manifests { + if m.desc.Annotations["vnd.docker.reference.digest"] == string(imageDigest) { + r.refs[imageDigest] = []digest.Digest{ + d, + } + } + } + + a := asset{} + loader.scanSBOM(ctx, fetcher, r, r.refs[imageDigest], &a) + r.assets["linux/amd64"] = a + actual, err := r.SBOM() + + assert.NoError(t, err) + assert.Equal(t, 1, len(actual)) + }) + } +} + +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) + imageDigest := r.images["linux/amd64"] + + // Manual mapping + for d, m := range r.manifests { + if m.desc.Annotations["vnd.docker.reference.digest"] == string(imageDigest) { + r.refs[imageDigest] = []digest.Digest{ + d, + } + } + } + + a := asset{} + loader.scanProvenance(ctx, fetcher, r, r.refs[imageDigest], &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) { + tests := []struct { + mime string + expected bool + }{ + { + mime: "application/vnd.in-toto.spdx+dsse", + expected: true, + }, + { + mime: "application/vnd.in-toto.provenance+dsse", + expected: true, + }, + { + mime: "application/vnd.in-toto+json", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.mime, func(t *testing.T) { + assert.Equal(t, isInTotoDSSE(test.mime), test.expected) + }) + } +} + +func Test_decodeDSSE(t *testing.T) { + // Returns input when mime isn't a DSSE type + actual, err := decodeDSSE([]byte("foobar"), "application/vnd.in-toto+json") + assert.NoError(t, err) + assert.Equal(t, []byte("foobar"), actual) + + // Returns the base64 decoded payload if is a DSSE + payload := base64.StdEncoding.EncodeToString([]byte("hello world")) + envelope := fmt.Sprintf("{\"payload\":\"%s\"}", payload) + actual, err = decodeDSSE([]byte(envelope), "application/vnd.in-toto.spdx+dsse") + assert.NoError(t, err) + assert.Equal(t, "hello world", string(actual)) + + _, err = decodeDSSE([]byte("not a json"), "application/vnd.in-toto.spdx+dsse") + assert.Error(t, err) + + _, err = decodeDSSE([]byte("{\"payload\": \"not base64\"}"), "application/vnd.in-toto.spdx+dsse") + assert.Error(t, err) +}