From e1f3f28ae4b86dd7f518a261080dc8d24ac2cdad Mon Sep 17 00:00:00 2001 From: Thomas Grininger Date: Tue, 25 Nov 2025 07:10:06 +0100 Subject: [PATCH] feat(image): add Sigstore bundle SBOM support (#9516) Co-authored-by: knqyf263 --- internal/registrytest/registrytest.go | 81 +++++ pkg/attestation/attestation.go | 7 + pkg/fanal/artifact/image/remote_sbom.go | 4 +- pkg/fanal/artifact/image/remote_sbom_test.go | 299 +++++++++--------- pkg/fanal/artifact/image/testdata/index.json | 15 - .../artifact/image/testdata/manifest.json | 24 -- .../image/testdata/sigstore-bundle.json | 59 ++++ pkg/fanal/artifact/image/testdata/valid.rego | 14 - pkg/fanal/artifact/sbom/sbom.go | 6 +- pkg/oci/artifact.go | 4 + pkg/sbom/sbom.go | 88 ++++++ pkg/sbom/sbom_test.go | 161 +++++++--- 12 files changed, 507 insertions(+), 255 deletions(-) create mode 100644 internal/registrytest/registrytest.go delete mode 100644 pkg/fanal/artifact/image/testdata/index.json delete mode 100644 pkg/fanal/artifact/image/testdata/manifest.json create mode 100644 pkg/fanal/artifact/image/testdata/sigstore-bundle.json delete mode 100644 pkg/fanal/artifact/image/testdata/valid.rego diff --git a/internal/registrytest/registrytest.go b/internal/registrytest/registrytest.go new file mode 100644 index 0000000000..1810248d37 --- /dev/null +++ b/internal/registrytest/registrytest.go @@ -0,0 +1,81 @@ +// Package registrytest provides utilities for testing with OCI registries. +package registrytest + +import ( + "fmt" + "net/http/httptest" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "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/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/stretchr/testify/require" +) + +// NewServer starts a test registry server with OCI 1.1 referrers support. +func NewServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(registry.New(registry.WithReferrersSupport(true))) +} + +// PushRandomImage pushes a random image to the registry and returns its reference and descriptor. +func PushRandomImage(t *testing.T, registryHost, repo, tag string) (name.Reference, v1.Descriptor) { + t.Helper() + + ref, err := name.ParseReference(fmt.Sprintf("%s/%s:%s", registryHost, repo, tag)) + require.NoError(t, err) + + img, err := random.Image(10, 1) + require.NoError(t, err) + + err = remote.Write(ref, img) + require.NoError(t, err) + + d, err := img.Digest() + require.NoError(t, err) + sz, err := img.Size() + require.NoError(t, err) + mt, err := img.MediaType() + require.NoError(t, err) + + return ref, v1.Descriptor{ + Digest: d, + Size: sz, + MediaType: mt, + } +} + +// PushReferrer pushes an artifact referrer to the registry attached to the subject image. +// The artifactType is used both as the config media type (for OCI 1.1 artifact type) and layer media type. +func PushReferrer(t *testing.T, registryHost, repo string, subjectDesc v1.Descriptor, artifactType string, content []byte) { + t.Helper() + + // Create an OCI artifact with the content as a layer + layer := static.NewLayer(content, types.MediaType(artifactType)) + + // Start with an empty image and add the layer + img := mutate.MediaType(empty.Image, types.OCIManifestSchema1) + // Set the config media type to the artifact type - this is how OCI 1.1 identifies the artifact type + // The registry will use this as the artifactType in the referrers API response + img = mutate.ConfigMediaType(img, types.MediaType(artifactType)) + img, err := mutate.AppendLayers(img, layer) + require.NoError(t, err) + + // Set the subject to create the referrer relationship + img = mutate.Subject(img, subjectDesc).(v1.Image) + + // Push the referrer + d, err := img.Digest() + require.NoError(t, err) + ref, err := name.ParseReference(fmt.Sprintf("%s/%s@%s", registryHost, repo, d.String())) + require.NoError(t, err) + + err = remote.Write(ref, img) + require.NoError(t, err) +} diff --git a/pkg/attestation/attestation.go b/pkg/attestation/attestation.go index 141752e834..7d804fdf33 100644 --- a/pkg/attestation/attestation.go +++ b/pkg/attestation/attestation.go @@ -17,6 +17,13 @@ type CosignPredicate struct { Data any } +// SigstoreBundle represents the structure of a Sigstore bundle containing a DSSE envelope. +// This format is used by Cosign v3+ with the new bundle format. +// cf. https://github.com/sigstore/cosign/blob/main/specs/BUNDLE_SPEC.md +type SigstoreBundle struct { + DSSEEnvelope Statement `json:"dsseEnvelope"` +} + // Statement holds in-toto statement headers and the predicate. type Statement in_toto.Statement diff --git a/pkg/fanal/artifact/image/remote_sbom.go b/pkg/fanal/artifact/image/remote_sbom.go index a8fcde86a3..63fb014275 100644 --- a/pkg/fanal/artifact/image/remote_sbom.go +++ b/pkg/fanal/artifact/image/remote_sbom.go @@ -105,7 +105,9 @@ func (a Artifact) parseReferrer(ctx context.Context, repo string, desc v1.Descri return artifact.Reference{}, xerrors.Errorf("SBOM download error: %w", err) } - res, err := a.inspectSBOMFile(ctx, filepath.Join(tmpDir, fileName)) + filePath := filepath.Join(tmpDir, fileName) + + res, err := a.inspectSBOMFile(ctx, filePath) if err != nil { return res, xerrors.Errorf("SBOM error: %w", err) } diff --git a/pkg/fanal/artifact/image/remote_sbom_test.go b/pkg/fanal/artifact/image/remote_sbom_test.go index 88cc8d1698..181a121b31 100644 --- a/pkg/fanal/artifact/image/remote_sbom_test.go +++ b/pkg/fanal/artifact/image/remote_sbom_test.go @@ -1,25 +1,26 @@ package image_test import ( - "net/http" - "net/http/httptest" + "fmt" "net/url" "os" "testing" v1 "github.com/google/go-containerregistry/pkg/v1" fakei "github.com/google/go-containerregistry/pkg/v1/fake" - typesv1 "github.com/google/go-containerregistry/pkg/v1/types" "github.com/package-url/packageurl-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/aquasecurity/trivy/internal/cachetest" + "github.com/aquasecurity/trivy/internal/registrytest" "github.com/aquasecurity/trivy/pkg/fanal/artifact" image2 "github.com/aquasecurity/trivy/pkg/fanal/artifact/image" - "github.com/aquasecurity/trivy/pkg/fanal/types" + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/log" + "github.com/aquasecurity/trivy/pkg/oci" "github.com/aquasecurity/trivy/pkg/rekortest" + "github.com/aquasecurity/trivy/pkg/sbom" ) func TestMain(m *testing.M) { @@ -31,7 +32,7 @@ type fakeImage struct { name string repoDigests []string v1.Image - types.ImageExtension + ftypes.ImageExtension } func (f fakeImage) ID() (string, error) { @@ -46,47 +47,42 @@ func (f fakeImage) RepoDigests() []string { return f.repoDigests } +func (f fakeImage) RepoTags() []string { + return nil +} + func TestArtifact_InspectRekorAttestation(t *testing.T) { - type fields struct { - imageName string - repoDigests []string - } tests := []struct { name string - fields fields - artifactOpt artifact.Option + imageName string + repoDigests []string wantBlobs []cachetest.WantBlob want artifact.Reference wantErr string }{ { - name: "happy path", - fields: fields{ - imageName: "test/image:10", - repoDigests: []string{ - "test/image@sha256:782143e39f1e7a04e3f6da2d88b1c057e5657363c4f90679f3e8a071b7619e02", - }, - }, - artifactOpt: artifact.Option{ - SBOMSources: []string{"rekor"}, + name: "happy path", + imageName: "test/image:10", + repoDigests: []string{ + "test/image@sha256:782143e39f1e7a04e3f6da2d88b1c057e5657363c4f90679f3e8a071b7619e02", }, wantBlobs: []cachetest.WantBlob{ { ID: "sha256:066b9998617ffb7dfe0a3219ac5c3efc1008a6223606fcf474e7d5c965e4e8da", - BlobInfo: types.BlobInfo{ - SchemaVersion: types.BlobJSONSchemaVersion, - OS: types.OS{ + BlobInfo: ftypes.BlobInfo{ + SchemaVersion: ftypes.BlobJSONSchemaVersion, + OS: ftypes.OS{ Family: "alpine", Name: "3.16.2", }, - PackageInfos: []types.PackageInfo{ + PackageInfos: []ftypes.PackageInfo{ { - Packages: types.Packages{ + Packages: ftypes.Packages{ { ID: "musl@1.2.3-r0", Name: "musl", Version: "1.2.3-r0", - Identifier: types.PkgIdentifier{ + Identifier: ftypes.PkgIdentifier{ PURL: &packageurl.PackageURL{ Type: packageurl.TypeApk, Namespace: "alpine", @@ -104,7 +100,7 @@ func TestArtifact_InspectRekorAttestation(t *testing.T) { SrcName: "musl", SrcVersion: "1.2.3-r0", Licenses: []string{"MIT"}, - Layer: types.Layer{ + Layer: ftypes.Layer{ DiffID: "sha256:994393dc58e7931862558d06e46aa2bb17487044f670f310dffe1d24e4d1eec7", }, }, @@ -116,7 +112,7 @@ func TestArtifact_InspectRekorAttestation(t *testing.T) { }, want: artifact.Reference{ Name: "test/image:10", - Type: types.TypeCycloneDX, + Type: ftypes.TypeCycloneDX, ID: "sha256:066b9998617ffb7dfe0a3219ac5c3efc1008a6223606fcf474e7d5c965e4e8da", BlobIDs: []string{ "sha256:066b9998617ffb7dfe0a3219ac5c3efc1008a6223606fcf474e7d5c965e4e8da", @@ -136,40 +132,34 @@ func TestArtifact_InspectRekorAttestation(t *testing.T) { }, }, { - name: "error", - fields: fields{ - imageName: "test/image:10", - repoDigests: []string{ - "test/image@sha256:123456e39f1e7a04e3f6da2d88b1c057e5657363c4f90679f3e8a071b7619e02", - }, - }, - artifactOpt: artifact.Option{ - SBOMSources: []string{"rekor"}, + name: "attestation not found", + imageName: "test/image:10", + repoDigests: []string{ + "test/image@sha256:123456e39f1e7a04e3f6da2d88b1c057e5657363c4f90679f3e8a071b7619e02", }, wantErr: "remote SBOM fetching error", }, } - log.InitLogger(false, true) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ts := rekortest.NewServer(t) defer ts.Close() - // Set the testing URL - tt.artifactOpt.RekorURL = ts.URL() - c := cachetest.NewCache(t, nil) fi := &fakei.FakeImage{} fi.ConfigFileReturns(&v1.ConfigFile{}, nil) img := &fakeImage{ - name: tt.fields.imageName, - repoDigests: tt.fields.repoDigests, + name: tt.imageName, + repoDigests: tt.repoDigests, Image: fi, } - a, err := image2.NewArtifact(img, c, tt.artifactOpt) + a, err := image2.NewArtifact(img, c, artifact.Option{ + SBOMSources: []string{"rekor"}, + RekorURL: ts.URL(), + }) require.NoError(t, err) got, err := a.Inspect(t.Context()) @@ -177,156 +167,155 @@ func TestArtifact_InspectRekorAttestation(t *testing.T) { assert.ErrorContains(t, err, tt.wantErr) return } + require.NoError(t, err) defer a.Clean(got) - require.NoError(t, err, tt.name) got.BOM = nil assert.Equal(t, tt.want, got) - cachetest.AssertBlobs(t, c, tt.wantBlobs) }) } } -func TestArtifact_inspectOCIReferrerSBOM(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v2": - _, err := w.Write([]byte("ok")) - assert.NoError(t, err) - case "/v2/test/image/referrers/sha256:782143e39f1e7a04e3f6da2d88b1c057e5657363c4f90679f3e8a071b7619e02": - w.Header().Set("Content-Type", string(typesv1.OCIImageIndex)) - http.ServeFile(w, r, "testdata/index.json") - case "/v2/test/image/manifests/sha256:37c89af4907fa0af078aeba12d6f18dc0c63937c010030baaaa88e958f0719a5": - http.ServeFile(w, r, "testdata/manifest.json") - case "/v2/test/image/blobs/sha256:9e05dda2a2dcdd526c9204be8645ae48742861c27f093bf496a6397834acecf2": - http.ServeFile(w, r, "testdata/cyclonedx.json") - } - })) - defer ts.Close() - - u, err := url.Parse(ts.URL) - require.NoError(t, err) - registry := u.Host - - type fields struct { - imageName string - repoDigests []string - } - - tests := []struct { - name string - fields fields - artifactOpt artifact.Option - wantBlobs []cachetest.WantBlob - want artifact.Reference - wantErr string - }{ - { - name: "happy path", - fields: fields{ - imageName: registry + "/test/image:10", - repoDigests: []string{ - registry + "/test/image@sha256:782143e39f1e7a04e3f6da2d88b1c057e5657363c4f90679f3e8a071b7619e02", - }, - }, - artifactOpt: artifact.Option{ - SBOMSources: []string{"oci"}, - }, - wantBlobs: []cachetest.WantBlob{ +// Common test data for CycloneDX SBOM (used by OCI referrer tests) +var wantBlobsCycloneDX = []cachetest.WantBlob{ + { + ID: "sha256:2171d8ccf798e94d09aca9c6abf15d28abd3236def1caa4a394b6f0a69c4266d", + BlobInfo: ftypes.BlobInfo{ + SchemaVersion: ftypes.BlobJSONSchemaVersion, + Applications: []ftypes.Application{ { - ID: "sha256:2171d8ccf798e94d09aca9c6abf15d28abd3236def1caa4a394b6f0a69c4266d", - BlobInfo: types.BlobInfo{ - SchemaVersion: types.BlobJSONSchemaVersion, - Applications: []types.Application{ - { - Type: types.GoBinary, - Packages: types.Packages{ - { - ID: "github.com/opencontainers/go-digest@v1.0.0", - Name: "github.com/opencontainers/go-digest", - Version: "v1.0.0", - Identifier: types.PkgIdentifier{ - PURL: &packageurl.PackageURL{ - Type: packageurl.TypeGolang, - Namespace: "github.com/opencontainers", - Name: "go-digest", - Version: "v1.0.0", - }, - BOMRef: "pkg:golang/github.com/opencontainers/go-digest@v1.0.0", - }, - }, - { - ID: "golang.org/x/sync@v0.1.0", - Name: "golang.org/x/sync", - Version: "v0.1.0", - Identifier: types.PkgIdentifier{ - PURL: &packageurl.PackageURL{ - Type: packageurl.TypeGolang, - Namespace: "golang.org/x", - Name: "sync", - Version: "v0.1.0", - }, - BOMRef: "pkg:golang/golang.org/x/sync@v0.1.0", - }, - }, + Type: ftypes.GoBinary, + Packages: ftypes.Packages{ + { + ID: "github.com/opencontainers/go-digest@v1.0.0", + Name: "github.com/opencontainers/go-digest", + Version: "v1.0.0", + Identifier: ftypes.PkgIdentifier{ + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeGolang, + Namespace: "github.com/opencontainers", + Name: "go-digest", + Version: "v1.0.0", }, + BOMRef: "pkg:golang/github.com/opencontainers/go-digest@v1.0.0", + }, + }, + { + ID: "golang.org/x/sync@v0.1.0", + Name: "golang.org/x/sync", + Version: "v0.1.0", + Identifier: ftypes.PkgIdentifier{ + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeGolang, + Namespace: "golang.org/x", + Name: "sync", + Version: "v0.1.0", + }, + BOMRef: "pkg:golang/golang.org/x/sync@v0.1.0", }, }, }, }, }, - want: artifact.Reference{ - Name: registry + "/test/image:10", - Type: types.TypeCycloneDX, - ID: "sha256:2171d8ccf798e94d09aca9c6abf15d28abd3236def1caa4a394b6f0a69c4266d", - BlobIDs: []string{ - "sha256:2171d8ccf798e94d09aca9c6abf15d28abd3236def1caa4a394b6f0a69c4266d", - }, + }, + }, +} + +func TestArtifact_InspectOCIReferrerSBOM(t *testing.T) { + // Start a test registry with referrers support + ts := registrytest.NewServer(t) + defer ts.Close() + + u, err := url.Parse(ts.URL) + require.NoError(t, err) + registryHost := u.Host + + tests := []struct { + name string + setup func(t *testing.T) (imageName string, repoDigests []string) + wantType ftypes.ArtifactType + wantID string + wantBlobs []cachetest.WantBlob + }{ + { + name: "CycloneDX SBOM", + setup: func(t *testing.T) (string, []string) { + ref, subjectDesc := registrytest.PushRandomImage(t, registryHost, "test/cyclonedx", "latest") + + sbomContent, err := os.ReadFile("testdata/cyclonedx.json") + require.NoError(t, err) + + registrytest.PushReferrer(t, registryHost, "test/cyclonedx", subjectDesc, oci.CycloneDXArtifactType, sbomContent) + + return ref.String(), + []string{fmt.Sprintf("%s/test/cyclonedx@%s", registryHost, subjectDesc.Digest.String())} }, + wantType: ftypes.TypeCycloneDX, + wantID: "sha256:2171d8ccf798e94d09aca9c6abf15d28abd3236def1caa4a394b6f0a69c4266d", + wantBlobs: wantBlobsCycloneDX, }, { - name: "404", - fields: fields{ - imageName: registry + "/test/image:unknown", - repoDigests: []string{ - registry + "/test/image@sha256:123456e39f1e7a04e3f6da2d88b1c057e5657363c4f90679f3e8a071b7619e02", - }, + name: "Sigstore bundle", + setup: func(t *testing.T) (string, []string) { + ref, subjectDesc := registrytest.PushRandomImage(t, registryHost, "test/sigstore", "latest") + + bundleContent, err := os.ReadFile("testdata/sigstore-bundle.json") + require.NoError(t, err) + + registrytest.PushReferrer(t, registryHost, "test/sigstore", subjectDesc, sbom.SigstoreBundleMediaType, bundleContent) + + return ref.String(), + []string{fmt.Sprintf("%s/test/sigstore@%s", registryHost, subjectDesc.Digest.String())} }, - artifactOpt: artifact.Option{ - SBOMSources: []string{"oci"}, + wantType: ftypes.TypeCycloneDX, + wantID: "sha256:2171d8ccf798e94d09aca9c6abf15d28abd3236def1caa4a394b6f0a69c4266d", + wantBlobs: wantBlobsCycloneDX, + }, + { + name: "no referrers", + setup: func(t *testing.T) (string, []string) { + // Push image without any referrers + ref, subjectDesc := registrytest.PushRandomImage(t, registryHost, "test/no-referrers", "latest") + + return ref.String(), + []string{fmt.Sprintf("%s/test/no-referrers@%s", registryHost, subjectDesc.Digest.String())} }, - wantErr: "unable to get manifest", + // Falls back to normal image scanning when no referrers found + wantType: ftypes.TypeContainerImage, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + imageName, repoDigests := tt.setup(t) + c := cachetest.NewCache(t, nil) fi := &fakei.FakeImage{} fi.ConfigFileReturns(&v1.ConfigFile{}, nil) img := &fakeImage{ - name: tt.fields.imageName, - repoDigests: tt.fields.repoDigests, + name: imageName, + repoDigests: repoDigests, Image: fi, } - a, err := image2.NewArtifact(img, c, tt.artifactOpt) + a, err := image2.NewArtifact(img, c, artifact.Option{ + SBOMSources: []string{"oci"}, + }) require.NoError(t, err) got, err := a.Inspect(t.Context()) - if tt.wantErr != "" { - assert.ErrorContains(t, err, tt.wantErr) - return - } + require.NoError(t, err) defer a.Clean(got) - require.NoError(t, err, tt.name) - got.BOM = nil - assert.Equal(t, tt.want, got) - - cachetest.AssertBlobs(t, c, tt.wantBlobs) + assert.Equal(t, tt.wantType, got.Type) + if tt.wantID != "" { + assert.Equal(t, tt.wantID, got.ID) + } + if tt.wantBlobs != nil { + cachetest.AssertBlobs(t, c, tt.wantBlobs) + } }) } } diff --git a/pkg/fanal/artifact/image/testdata/index.json b/pkg/fanal/artifact/image/testdata/index.json deleted file mode 100644 index 21cb0d3802..0000000000 --- a/pkg/fanal/artifact/image/testdata/index.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.index.v1+json", - "manifests": [ - { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "size": 621, - "digest": "sha256:37c89af4907fa0af078aeba12d6f18dc0c63937c010030baaaa88e958f0719a5", - "annotations": { - "org.opencontainers.artifact.description": "CycloneDX JSON SBOM" - }, - "artifactType": "application/vnd.cyclonedx+json" - } - ] -} \ No newline at end of file diff --git a/pkg/fanal/artifact/image/testdata/manifest.json b/pkg/fanal/artifact/image/testdata/manifest.json deleted file mode 100644 index bbb680cb40..0000000000 --- a/pkg/fanal/artifact/image/testdata/manifest.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "config": { - "mediaType": "application/vnd.cyclonedx+json", - "size": 2, - "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" - }, - "layers": [ - { - "mediaType": "application/vnd.cyclonedx+json", - "size": 2215, - "digest": "sha256:9e05dda2a2dcdd526c9204be8645ae48742861c27f093bf496a6397834acecf2" - } - ], - "annotations": { - "org.opencontainers.artifact.description": "CycloneDX JSON SBOM" - }, - "subject": { - "mediaType": "application/vnd.oci.image.manifest.v1+json", - "size": 1024, - "digest": "sha256:782143e39f1e7a04e3f6da2d88b1c057e5657363c4f90679f3e8a071b7619e02" - } -} \ No newline at end of file diff --git a/pkg/fanal/artifact/image/testdata/sigstore-bundle.json b/pkg/fanal/artifact/image/testdata/sigstore-bundle.json new file mode 100644 index 0000000000..ef881bebce --- /dev/null +++ b/pkg/fanal/artifact/image/testdata/sigstore-bundle.json @@ -0,0 +1,59 @@ +{ + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2N5Y2xvbmVkeC5vcmcvYm9tIiwic3ViamVjdCI6W3sibmFtZSI6ImdoY3IuaW8vcmluZ29kZXYvZGVtby1zYm9tIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImVhOWM2NTdjYTQ0MzcxZmFkZDAyNzUxMGYyYTFhNjhiNzk5MDAzMjk0YmY2YTQ4Mjc4Mjc5NzFjYzQzOTBhZDQifX1dLCJwcmVkaWNhdGUiOnsiYm9tRm9ybWF0IjoiQ3ljbG9uZURYIiwiY29tcG9uZW50cyI6W3siYm9tLXJlZiI6InBrZzpnb2xhbmcvZ2l0aHViLmNvbS9vcGVuY29udGFpbmVycy9nby1kaWdlc3RAdjEuMC4wIiwibmFtZSI6ImdpdGh1Yi5jb20vb3BlbmNvbnRhaW5lcnMvZ28tZGlnZXN0IiwicHJvcGVydGllcyI6W3sibmFtZSI6ImFxdWFzZWN1cml0eTp0cml2eTpQa2dUeXBlIiwidmFsdWUiOiJnb2JpbmFyeSJ9XSwicHVybCI6InBrZzpnb2xhbmcvZ2l0aHViLmNvbS9vcGVuY29udGFpbmVycy9nby1kaWdlc3RAdjEuMC4wIiwidHlwZSI6ImxpYnJhcnkiLCJ2ZXJzaW9uIjoidjEuMC4wIn0seyJib20tcmVmIjoicGtnOmdvbGFuZy9nb2xhbmcub3JnL3gvc3luY0B2MC4xLjAiLCJuYW1lIjoiZ29sYW5nLm9yZy94L3N5bmMiLCJwcm9wZXJ0aWVzIjpbeyJuYW1lIjoiYXF1YXNlY3VyaXR5OnRyaXZ5OlBrZ1R5cGUiLCJ2YWx1ZSI6ImdvYmluYXJ5In1dLCJwdXJsIjoicGtnOmdvbGFuZy9nb2xhbmcub3JnL3gvc3luY0B2MC4xLjAiLCJ0eXBlIjoibGlicmFyeSIsInZlcnNpb24iOiJ2MC4xLjAifV0sImRlcGVuZGVuY2llcyI6W3siZGVwZW5kc09uIjpbInBrZzpnb2xhbmcvZ2l0aHViLmNvbS9vcGVuY29udGFpbmVycy9nby1kaWdlc3RAdjEuMC4wIiwicGtnOmdvbGFuZy9nb2xhbmcub3JnL3gvc3luY0B2MC4xLjAiXSwicmVmIjoiYzYzMDlkNGEtZjZkYi00YWNjLThlNDQtZjBkMjcxNDkyYjY1In0seyJkZXBlbmRzT24iOlsiYzYzMDlkNGEtZjZkYi00YWNjLThlNDQtZjBkMjcxNDkyYjY1Il0sInJlZiI6InBrZzpvY2kvZGVtby1yZWZlcnJlcnMtMjAyM0BzaGEyNTY6ZTc2YTEzNDc1YzZiNGE3MTNhMGU0YTdhODU3NGNlNDUwMjc0ZDM0MDM1N2EyYzQwYjgyMjFjZmNmZWRmOGIxOT9yZXBvc2l0b3J5X3VybD1sb2NhbGhvc3Q6NTAwMSUyRmRlbW8tcmVmZXJyZXJzLTIwMjNcdTAwMjZhcmNoPWFybTY0In1dLCJtZXRhZGF0YSI6eyJjb21wb25lbnQiOnsiYm9tLXJlZiI6InBrZzpvY2kvZGVtby1yZWZlcnJlcnMtMjAyM0BzaGEyNTY6ZTc2YTEzNDc1YzZiNGE3MTNhMGU0YTdhODU3NGNlNDUwMjc0ZDM0MDM1N2EyYzQwYjgyMjFjZmNmZWRmOGIxOT9yZXBvc2l0b3J5X3VybD1sb2NhbGhvc3Q6NTAwMSUyRmRlbW8tcmVmZXJyZXJzLTIwMjNcdTAwMjZhcmNoPWFybTY0IiwibmFtZSI6ImxvY2FsaG9zdDo1MDAxL2RlbW8tcmVmZXJyZXJzLTIwMjM6YXBwIiwicHJvcGVydGllcyI6W3sibmFtZSI6ImFxdWFzZWN1cml0eTp0cml2eTpTY2hlbWFWZXJzaW9uIiwidmFsdWUiOiIyIn1dLCJwdXJsIjoicGtnOm9jaS9kZW1vLXJlZmVycmVycy0yMDIzQHNoYTI1NjplNzZhMTM0NzVjNmI0YTcxM2EwZTRhN2E4NTc0Y2U0NTAyNzRkMzQwMzU3YTJjNDBiODIyMWNmY2ZlZGY4YjE5P3JlcG9zaXRvcnlfdXJsPWxvY2FsaG9zdDo1MDAxJTJGZGVtby1yZWZlcnJlcnMtMjAyM1x1MDAyNmFyY2g9YXJtNjQiLCJ0eXBlIjoiY29udGFpbmVyIn0sInRpbWVzdGFtcCI6IjIwMjMtMDMtMjJUMTE6MzU6MzgrMDA6MDAiLCJ0b29scyI6W3sibmFtZSI6InRyaXZ5IiwidmVuZG9yIjoiYXF1YXNlY3VyaXR5IiwidmVyc2lvbiI6IjAuMzkuMCJ9XX0sInNlcmlhbE51bWJlciI6InVybjp1dWlkOjJlM2M2ODRjLTFmOTktNDY2Yy1hZDZiLWI3ODM5NDJlYjlkMSIsInNwZWNWZXJzaW9uIjoiMS40IiwidmVyc2lvbiI6MSwidnVsbmVyYWJpbGl0aWVzIjpbXX19", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEUCIQCixpYXidhSSGs+WhNVjF6eHiTj6eo0KxF8ihhHlyoHKgIgFv/p/hIhoS/yNTo4Xb/+Ac3fTfd1xNcGB+IJqTNyP/I=" + } + ] + }, + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "certificate": { + "rawBytes": "MIIC2zCCAmGgAwIBAgIUC8iVf2cW24GNi2YlrpI5xMh/3TMwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjUwOTIzMDUwMzQyWhcNMjUwOTIzMDUxMzQyWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAED4Q9U5ucpN780p9nUCiupZSrMpTVQW04/S+AFZVEKkBi3vndyRRXmogyQBDmpFO+ST8NZDBEJCKhEvfg/ZWKZaOCAYAwggF8MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUtHnkcI9f9YhXO7sRWkqqnKWaMBYwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wKwYDVR0RAQH/BCEwH4EddGhvbWFzLmdyaW5pbmdlckByaW5nb2Rldi5jb20wLAYKKwYBBAGDvzABAQQeaHR0cHM6Ly9naXRodWIuY29tL2xvZ2luL29hdXRoMC4GCisGAQQBg78wAQgEIAweaHR0cHM6Ly9naXRodWIuY29tL2xvZ2luL29hdXRoMIGJBgorBgEEAdZ5AgQCBHsEeQB3AHUA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGZdPTFfQAABAMARjBEAiAZakMhKO+tfDvTmISGjfgGaO+Ehj6u1hQPSTBQGpoSPgIgAZeiL6xH76MTSWoYww3EUheSgOwoKIYTMaNh57PqmEAwCgYIKoZIzj0EAwMDaAAwZQIwQBdf+r3InPKcMDBwMjZkdMarevw4IfC0PYGKBf3u0/Y0RK4cyDAuCS4y81is2WoGAjEAnVymG/CTeF4Ff/jqxqIWaMCoKFiJ85NsNi4+NJ7m5qr/UT1LTeuly075bSGHH9zj" + }, + "tlogEntries": [ + { + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZjYxZDkzMjkzMWVjNDliMTNiNTBjODgzZGQ3OTEzMzM3ZmMzZDQ2ZTdiYmI0YTczNDZiYTM4YmJmZmY1MjA1ZiJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjI4OTBmZmEyYmVlODFjYTg4OWQxM2Y3ZjRjNDJiN2NkODc1YTJiYWM4ZDIyODcyYzcyZjJlMjNmZDJhZTkzNWEifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRQ2l4cFlYaWRoU1NHcytXaE5WakY2ZUhpVGo2ZW8wS3hGOGloaEhseW9IS2dJZ0Z2L3AvaElob1MveU5UbzRYYi8rQWMzZlRmZDF4TmNHQitJSnFUTnlQL0k9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VNeWVrTkRRVzFIWjBGM1NVSkJaMGxWUXpocFZtWXlZMWN5TkVkT2FUSlpiSEp3U1RWNFRXZ3ZNMVJOZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwVmQwOVVTWHBOUkZWM1RYcFJlVmRvWTA1TmFsVjNUMVJKZWsxRVZYaE5lbEY1VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVkVORkU1VlRWMVkzQk9Oemd3Y0RsdVZVTnBkWEJhVTNKTmNGUldVVmN3TkM5VEswRUtSbHBXUlV0clFta3pkbTVrZVZKU1dHMXZaM2xSUWtSdGNFWlBLMU5VT0U1YVJFSkZTa05MYUVWMlptY3ZXbGRMV21GUFEwRlpRWGRuWjBZNFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVjBTRzVyQ21OSk9XWTVXV2hZVHpkelVsZHJjWEZ1UzFkaFRVSlpkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMHQzV1VSV1VqQlNRVkZJTDBKRFJYZElORVZrWkVkb2RtSlhSbnBNYldSNVlWYzFjR0p0Wkd4amEwSjVZVmMxYm1JeVVteGthVFZxWWpJd2R3cE1RVmxMUzNkWlFrSkJSMFIyZWtGQ1FWRlJaV0ZJVWpCalNFMDJUSGs1Ym1GWVVtOWtWMGwxV1RJNWRFd3llSFphTW14MVRESTVhR1JZVW05TlF6UkhDa05wYzBkQlVWRkNaemM0ZDBGUlowVkpRWGRsWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREo0ZGxveWJIVk1NamxvWkZoU2IwMUpSMG9LUW1kdmNrSm5SVVZCWkZvMVFXZFJRMEpJYzBWbFVVSXpRVWhWUVROVU1IZGhjMkpJUlZSS2FrZFNOR050VjJNelFYRktTMWh5YW1WUVN6TXZhRFJ3ZVFwblF6aHdOMjgwUVVGQlIxcGtVRlJHWmxGQlFVSkJUVUZTYWtKRlFXbEJXbUZyVFdoTFR5dDBaa1IyVkcxSlUwZHFabWRIWVU4clJXaHFOblV4YUZGUUNsTlVRbEZIY0c5VFVHZEpaMEZhWldsTU5uaElOelpOVkZOWGIxbDNkek5GVldobFUyZFBkMjlMU1ZsVVRXRk9hRFUzVUhGdFJVRjNRMmRaU1V0dldra0tlbW93UlVGM1RVUmhRVUYzV2xGSmQxRkNaR1lyY2pOSmJsQkxZMDFFUW5kTmFscHJaRTFoY21WMmR6Ukpaa013VUZsSFMwSm1NM1V3TDFrd1VrczBZd3A1UkVGMVExTTBlVGd4YVhNeVYyOUhRV3BGUVc1V2VXMUhMME5VWlVZMFJtWXZhbkY0Y1VsWFlVMURiMHRHYVVvNE5VNXpUbWswSzA1S04yMDFjWEl2Q2xWVU1VeFVaWFZzZVRBM05XSlRSMGhJT1hwcUNpMHRMUzB0UlU1RUlFTkZVbFJKUmtsRFFWUkZMUzB0TFMwSyJ9XX19", + "inclusionPromise": { + "signedEntryTimestamp": "MEQCHwyPMnKpy/yUQ0ZwR9gK4a2uckTuTUdLxu4evTPuVd4CIQCzhKnq3MeJbnQ+UKne4pLB+zkvN+e/Feb5EPc8WnyueQ==" + }, + "inclusionProof": { + "checkpoint": { + "envelope": "rekor.sigstore.dev - 1193050959916656506\n427690837\nsJPSNHZNtTPycokyg+lqN2e6YlRLlJ57qNvKbfgu4DA=\n\n— rekor.sigstore.dev wNI9ajBFAiEAsJNoQ9+KC5oVw5F8m6TJsMWRICYM/b5GtvdlEUzfKewCIEZ4yKxVTQnS8fOeS1wdxrENfB+ExH45cYvvUElK6lyZ\n" + }, + "hashes": [ + "LiSPCqC8Ls8vziACm+javgVtDbCunAnG231n36f+rmA=", + "SOInOiumiLsg+YEerz/uDUJxNsOKe+RoCBn66/8CAx0=", + "L7DlG4/e8uoLsciiN10/PQ9Dp11pI20zjdqLZkL+qes=", + "LF7kYuNdnJjUcOI8W65buH30EMIADhu/Kxl5u6ZEDxE=", + "jS89+x6mwua8j9KN5Dv6CO55M78GF8hi8oSIr0Bf2nw=", + "QnOvO+8sDYohFzr5WL8YeAUXNvpPvu8uJ94IvrDkCW0=", + "4ctDWxRuy9Mz4TnkTNS+M6IbMvBeMJ4nOhlt6OKpRjE=", + "CNxxnyEXbd2FPeCFwuWruKKVDmiib5NLbmERibRvCQ0=", + "hfVaV9xEkKbkj2E2/qTT82VHIma+u9NCAFqqsRiinIw=", + "2ZZMmiqKJ0oGmxZHgx7TcGakJLUPNXVLRdD2U48OC7g=", + "E7mN474KbNy0VYJOJx8PWikuCKMn+miLKMG6HAtUY6o=", + "fg+F12WPA6GgbJ0+XKaR8IkjWVPhNcjTcpgtCjk0a9A=", + "rO8wDSOjmY8VkspFqYaJS4TV5HxywICMlHM8gTxXkAA=", + "1mfy94KpcItqshH9+gwqV6jccupcaMpVsF28New8zDY=", + "vS7O4ozHIQZJWBiov+mkpI27GE8zAmVCEkRcP3NDyNE=" + ], + "logIndex": "427690836", + "rootHash": "sJPSNHZNtTPycokyg+lqN2e6YlRLlJ57qNvKbfgu4DA=", + "treeSize": "427690837" + }, + "integratedTime": "1758603823", + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + }, + "logIndex": "549595098" + } + ] + } +} \ No newline at end of file diff --git a/pkg/fanal/artifact/image/testdata/valid.rego b/pkg/fanal/artifact/image/testdata/valid.rego deleted file mode 100644 index 57f95ac2f8..0000000000 --- a/pkg/fanal/artifact/image/testdata/valid.rego +++ /dev/null @@ -1,14 +0,0 @@ -package testdata.kubernetes.id_100 - -__rego_metadata__ := { - "id": "ID-100", - "title": "Bad Deployment", - "version": "v1.0.0", - "severity": "HIGH", - "type": "Kubernetes Security Check", -} - -deny[res] { - input.kind == "Deployment" - res := {"type": "Kubernetes Check", "id": "ID-100", "msg": "deny", "severity": "CRITICAL"} -} \ No newline at end of file diff --git a/pkg/fanal/artifact/sbom/sbom.go b/pkg/fanal/artifact/sbom/sbom.go index 1dd6cec3bd..f6377b44a0 100644 --- a/pkg/fanal/artifact/sbom/sbom.go +++ b/pkg/fanal/artifact/sbom/sbom.go @@ -77,11 +77,11 @@ func (a Artifact) Inspect(ctx context.Context) (artifact.Reference, error) { var artifactType types.ArtifactType switch format { - case sbom.FormatCycloneDXJSON, sbom.FormatCycloneDXXML, sbom.FormatAttestCycloneDXJSON, sbom.FormatLegacyCosignAttestCycloneDXJSON: + case sbom.FormatCycloneDXJSON, sbom.FormatCycloneDXXML, sbom.FormatAttestCycloneDXJSON, + sbom.FormatLegacyCosignAttestCycloneDXJSON, sbom.FormatSigstoreBundleCycloneDXJSON: artifactType = types.TypeCycloneDX - case sbom.FormatSPDXTV, sbom.FormatSPDXJSON, sbom.FormatAttestSPDXJSON: + case sbom.FormatSPDXTV, sbom.FormatSPDXJSON, sbom.FormatAttestSPDXJSON, sbom.FormatSigstoreBundleSPDXJSON: artifactType = types.TypeSPDX - } return artifact.Reference{ diff --git a/pkg/oci/artifact.go b/pkg/oci/artifact.go index 8f03715756..89ca0d28d3 100644 --- a/pkg/oci/artifact.go +++ b/pkg/oci/artifact.go @@ -30,6 +30,9 @@ const ( CycloneDXArtifactType = "application/vnd.cyclonedx+json" SPDXArtifactType = "application/spdx+json" + // Sigstore bundle artifact type for Cosign attestations in "new bundle format" > 2.5.0 + SigstoreBundleArtifactType = "application/vnd.dev.sigstore.bundle.v0.3+json" + // Media types OCIImageManifest = "application/vnd.oci.image.manifest.v1+json" @@ -40,6 +43,7 @@ const ( var SupportedSBOMArtifactTypes = []string{ CycloneDXArtifactType, SPDXArtifactType, + SigstoreBundleArtifactType, } // Option is a functional option diff --git a/pkg/sbom/sbom.go b/pkg/sbom/sbom.go index 4630f3e3bd..44a6868da7 100644 --- a/pkg/sbom/sbom.go +++ b/pkg/sbom/sbom.go @@ -31,6 +31,14 @@ const ( FormatAttestSPDXJSON Format = "attest-spdx-json" FormatUnknown Format = "unknown" + // FormatSigstoreBundleCycloneDXJSON is used for Sigstore bundle format containing CycloneDX SBOM attestation. + // This format is produced by Cosign v3+ with the new bundle format. + // ref. https://github.com/sigstore/cosign/blob/main/specs/BUNDLE_SPEC.md + FormatSigstoreBundleCycloneDXJSON Format = "sigstore-bundle-cyclonedx-json" + + // FormatSigstoreBundleSPDXJSON is used for Sigstore bundle format containing SPDX SBOM attestation. + FormatSigstoreBundleSPDXJSON Format = "sigstore-bundle-spdx-json" + // FormatLegacyCosignAttestCycloneDXJSON is used to support the older format of CycloneDX JSON Attestation // produced by the Cosign V1. // ref. https://github.com/sigstore/cosign/pull/2718 @@ -40,6 +48,10 @@ const ( // This is necessary for backward-compatible SBOM detection. // ref. https://github.com/in-toto/in-toto-golang/pull/188 PredicateCycloneDXBeforeV05 = "https://cyclonedx.org/schema" + + // SigstoreBundleMediaType is the media type for Sigstore bundles v0.3 + // ref. https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto + SigstoreBundleMediaType = "application/vnd.dev.sigstore.bundle.v0.3+json" ) var ErrUnknownFormat = xerrors.New("Unknown SBOM format") @@ -56,6 +68,13 @@ type spdxHeader struct { SpdxID string `json:"SPDXID"` } +// sigstoreBundle represents the structure of a Sigstore bundle +// ref. https://github.com/sigstore/cosign/blob/main/specs/BUNDLE_SPEC.md +type sigstoreBundle struct { + MediaType string `json:"mediaType"` + DSSEEnvelope json.RawMessage `json:"dsseEnvelope"` +} + func IsCycloneDXJSON(r io.ReadSeeker) (bool, error) { if _, err := r.Seek(0, io.SeekStart); err != nil { return false, xerrors.Errorf("seek error: %w", err) @@ -152,6 +171,16 @@ func DetectFormat(r io.ReadSeeker) (Format, error) { return format, nil } + if _, err := r.Seek(0, io.SeekStart); err != nil { + return FormatUnknown, xerrors.Errorf("seek error: %w", err) + } + + // Try Sigstore bundle + format, ok = decodeSigstoreBundleFormat(r) + if ok { + return format, nil + } + return FormatUnknown, nil } @@ -189,6 +218,41 @@ func decodeAttestationFormat(r io.ReadSeeker) (Format, bool) { return "", false } +func decodeSigstoreBundleFormat(r io.ReadSeeker) (Format, bool) { + var bundle sigstoreBundle + if err := json.NewDecoder(r).Decode(&bundle); err != nil { + return "", false + } + + // Check if the media type indicates a Sigstore bundle + if bundle.MediaType != SigstoreBundleMediaType { + return "", false + } + + if bundle.DSSEEnvelope == nil { + return "", false + } + + // Parse the DSSE envelope to determine the SBOM format + var s attestation.Statement + if err := json.Unmarshal(bundle.DSSEEnvelope, &s); err != nil { + return "", false + } + + if s.Predicate == nil { + return "", false + } + + switch s.PredicateType { + case in_toto.PredicateCycloneDX, PredicateCycloneDXBeforeV05: + return FormatSigstoreBundleCycloneDXJSON, true + case in_toto.PredicateSPDX: + return FormatSigstoreBundleSPDXJSON, true + } + + return "", false +} + func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error) { var ( v any @@ -231,6 +295,30 @@ func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error) Predicate: &spdx.SPDX{BOM: bom}, } decoder = json.NewDecoder(f) + case FormatSigstoreBundleCycloneDXJSON: + // Sigstore bundle + // => dsse envelope + // => in-toto attestation + // => CycloneDX JSON + bom = core.NewBOM(core.Options{GenerateBOMRef: true}) + v = &attestation.SigstoreBundle{ + DSSEEnvelope: attestation.Statement{ + Predicate: &cyclonedx.BOM{BOM: bom}, + }, + } + decoder = json.NewDecoder(f) + case FormatSigstoreBundleSPDXJSON: + // Sigstore bundle + // => dsse envelope + // => in-toto attestation + // => SPDX JSON + bom = core.NewBOM(core.Options{}) + v = &attestation.SigstoreBundle{ + DSSEEnvelope: attestation.Statement{ + Predicate: &spdx.SPDX{BOM: bom}, + }, + } + decoder = json.NewDecoder(f) case FormatSPDXJSON: bom = core.NewBOM(core.Options{}) v = &spdx.SPDX{BOM: bom} diff --git a/pkg/sbom/sbom_test.go b/pkg/sbom/sbom_test.go index 8a9eaacfb0..d8e89d09a4 100644 --- a/pkg/sbom/sbom_test.go +++ b/pkg/sbom/sbom_test.go @@ -1,7 +1,6 @@ package sbom_test import ( - "context" "strings" "testing" @@ -11,6 +10,74 @@ import ( "github.com/aquasecurity/trivy/pkg/sbom" ) +// Test data constants for SBOM format detection and decoding. +// Each constant contains base64-encoded in-toto statement in the payload field. +const ( + // SPDX attestation (DSSE envelope) + // payload decoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://spdx.dev/Document","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":{"SPDXID":"SPDXRef-DOCUMENT","spdxVersion":"SPDX-2.3","name":"test","dataLicense":"CC0-1.0","documentNamespace":"http://example.invalid/test","creationInfo":{"creators":["Tool: test"],"created":"2025-01-01T00:00:00Z"},"packages":[]}} + spdxAttestation = `{ + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IlNQRFhSZWYtRE9DVU1FTlQiLCJzcGR4VmVyc2lvbiI6IlNQRFgtMi4zIiwibmFtZSI6InRlc3QiLCJkYXRhTGljZW5zZSI6IkNDMC0xLjAiLCJkb2N1bWVudE5hbWVzcGFjZSI6Imh0dHA6Ly9leGFtcGxlLmludmFsaWQvdGVzdCIsImNyZWF0aW9uSW5mbyI6eyJjcmVhdG9ycyI6WyJUb29sOiB0ZXN0Il0sImNyZWF0ZWQiOiIyMDI1LTAxLTAxVDAwOjAwOjAwWiJ9LCJwYWNrYWdlcyI6W119fQ==", + "signatures": [] + }` + + // SPDX attestation with invalid SPDXID + // payload decoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://spdx.dev/Document","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":{"SPDXID":"InvalidID","spdxVersion":"SPDX-2.3","name":"test"}} + spdxAttestationInvalidID = `{ + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IkludmFsaWRJRCIsInNwZHhWZXJzaW9uIjoiU1BEWC0yLjMiLCJuYW1lIjoidGVzdCJ9fQ==", + "signatures": [] + }` + + // SPDX attestation with invalid predicate (string instead of object) + // payload decoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://spdx.dev/Document","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":"invalid"} + spdxAttestationInvalidPredicate = `{ + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjoiaW52YWxpZCJ9", + "signatures": [] + }` + + // CycloneDX attestation (DSSE envelope) + // payload decoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://cyclonedx.org/bom","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":{"bomFormat":"CycloneDX","specVersion":"1.4","version":1}} + cycloneDXAttestation = `{ + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2N5Y2xvbmVkeC5vcmcvYm9tIiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7ImJvbUZvcm1hdCI6IkN5Y2xvbmVEWCIsInNwZWNWZXJzaW9uIjoiMS40IiwidmVyc2lvbiI6MX19", + "signatures": [] + }` + + // Sigstore bundle with CycloneDX SBOM + // dsseEnvelope.payload decoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://cyclonedx.org/bom","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":{"bomFormat":"CycloneDX","specVersion":"1.4","version":1}} + sigstoreBundleCycloneDX = `{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "dsseEnvelope": { + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2N5Y2xvbmVkeC5vcmcvYm9tIiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7ImJvbUZvcm1hdCI6IkN5Y2xvbmVEWCIsInNwZWNWZXJzaW9uIjoiMS40IiwidmVyc2lvbiI6MX19", + "signatures": [] + } + }` + + // Sigstore bundle with SPDX SBOM + // dsseEnvelope.payload decoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://spdx.dev/Document","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":{"SPDXID":"SPDXRef-DOCUMENT","spdxVersion":"SPDX-2.3","name":"test","dataLicense":"CC0-1.0","documentNamespace":"http://example.invalid/test","creationInfo":{"creators":["Tool: test"],"created":"2025-01-01T00:00:00Z"},"packages":[]}} + sigstoreBundleSPDX = `{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "dsseEnvelope": { + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IlNQRFhSZWYtRE9DVU1FTlQiLCJzcGR4VmVyc2lvbiI6IlNQRFgtMi4zIiwibmFtZSI6InRlc3QiLCJkYXRhTGljZW5zZSI6IkNDMC0xLjAiLCJkb2N1bWVudE5hbWVzcGFjZSI6Imh0dHA6Ly9leGFtcGxlLmludmFsaWQvdGVzdCIsImNyZWF0aW9uSW5mbyI6eyJjcmVhdG9ycyI6WyJUb29sOiB0ZXN0Il0sImNyZWF0ZWQiOiIyMDI1LTAxLTAxVDAwOjAwOjAwWiJ9LCJwYWNrYWdlcyI6W119fQ==", + "signatures": [] + } + }` + + // Sigstore bundle with unsupported media type version + sigstoreBundleUnsupportedVersion = `{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.4+json", + "dsseEnvelope": { + "payloadType": "application/vnd.in-toto+json", + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2N5Y2xvbmVkeC5vcmcvYm9tIiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7ImJvbUZvcm1hdCI6IkN5Y2xvbmVEWCIsInNwZWNWZXJzaW9uIjoiMS40IiwidmVyc2lvbiI6MX19", + "signatures": [] + } + }` +) + func TestDetectFormat(t *testing.T) { tests := []struct { name string @@ -18,37 +85,22 @@ func TestDetectFormat(t *testing.T) { want sbom.Format }{ { - name: "SPDX attestation with valid predicate", - // DSSE envelope with base64-encoded in-toto statement - input: `{ - "payloadType": "application/vnd.in-toto+json", - "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IlNQRFhSZWYtRE9DVU1FTlQiLCJzcGR4VmVyc2lvbiI6IlNQRFgtMi4zIiwibmFtZSI6InRlc3QifX0=", - "signatures": [] - }`, - want: sbom.FormatAttestSPDXJSON, + name: "SPDX attestation", + input: spdxAttestation, + want: sbom.FormatAttestSPDXJSON, }, { - name: "SPDX attestation without SPDXID prefix", - // Base64-encoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://spdx.dev/Document","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":{"SPDXID":"InvalidID","spdxVersion":"SPDX-2.3","name":"test"}} - input: `{ - "payloadType": "application/vnd.in-toto+json", - "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IkludmFsaWRJRCIsInNwZHhWZXJzaW9uIjoiU1BEWC0yLjMiLCJuYW1lIjoidGVzdCJ9fQ==", - "signatures": [] - }`, - want: sbom.FormatUnknown, + name: "SPDX attestation with invalid SPDXID", + input: spdxAttestationInvalidID, + want: sbom.FormatUnknown, }, { - name: "CycloneDX attestation", - // Base64-encoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://cyclonedx.org/bom","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":{"bomFormat":"CycloneDX","specVersion":"1.4"}} - input: `{ - "payloadType": "application/vnd.in-toto+json", - "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2N5Y2xvbmVkeC5vcmcvYm9tIiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7ImJvbUZvcm1hdCI6IkN5Y2xvbmVEWCIsInNwZWNWZXJzaW9uIjoiMS40In19", - "signatures": [] - }`, - want: sbom.FormatAttestCycloneDXJSON, + name: "CycloneDX attestation", + input: cycloneDXAttestation, + want: sbom.FormatAttestCycloneDXJSON, }, { - name: "Regular SPDX JSON (not attestation)", + name: "SPDX JSON", input: `{ "SPDXID": "SPDXRef-DOCUMENT", "spdxVersion": "SPDX-2.3", @@ -57,7 +109,7 @@ func TestDetectFormat(t *testing.T) { want: sbom.FormatSPDXJSON, }, { - name: "Regular CycloneDX JSON (not attestation)", + name: "CycloneDX JSON", input: `{ "bomFormat": "CycloneDX", "specVersion": "1.4" @@ -71,6 +123,21 @@ func TestDetectFormat(t *testing.T) { }`, want: sbom.FormatUnknown, }, + { + name: "Sigstore bundle with CycloneDX", + input: sigstoreBundleCycloneDX, + want: sbom.FormatSigstoreBundleCycloneDXJSON, + }, + { + name: "Sigstore bundle with SPDX", + input: sigstoreBundleSPDX, + want: sbom.FormatSigstoreBundleSPDXJSON, + }, + { + name: "Sigstore bundle with unsupported version", + input: sigstoreBundleUnsupportedVersion, + want: sbom.FormatUnknown, + }, } for _, tt := range tests { @@ -84,7 +151,7 @@ func TestDetectFormat(t *testing.T) { } } -func TestDecode_SPDXAttestation(t *testing.T) { +func TestDecode(t *testing.T) { tests := []struct { name string input string @@ -92,33 +159,41 @@ func TestDecode_SPDXAttestation(t *testing.T) { wantErr bool }{ { - name: "SPDX attestation decode", - // Base64-encoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://spdx.dev/Document","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":{"SPDXID":"SPDXRef-DOCUMENT","spdxVersion":"SPDX-2.3","name":"test","dataLicense":"CC0-1.0","documentNamespace":"http://trivy.dev/test","creationInfo":{"creators":["Tool: test"],"created":"2025-01-01T00:00:00Z"},"packages":[]}} - input: `{ - "payloadType": "application/vnd.in-toto+json", - "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IlNQRFhSZWYtRE9DVU1FTlQiLCJzcGR4VmVyc2lvbiI6IlNQRFgtMi4zIiwibmFtZSI6InRlc3QiLCJkYXRhTGljZW5zZSI6IkNDMC0xLjAiLCJkb2N1bWVudE5hbWVzcGFjZSI6Imh0dHA6Ly90cml2eS5kZXYvdGVzdCIsImNyZWF0aW9uSW5mbyI6eyJjcmVhdG9ycyI6WyJUb29sOiB0ZXN0Il0sImNyZWF0ZWQiOiIyMDI1LTAxLTAxVDAwOjAwOjAwWiJ9LCJwYWNrYWdlcyI6W119fQ==", - "signatures": [] - }`, + name: "SPDX attestation", + input: spdxAttestation, format: sbom.FormatAttestSPDXJSON, wantErr: false, }, { - name: "Invalid SPDX attestation", - // Base64-encoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://spdx.dev/Document","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":"invalid"} - input: `{ - "payloadType": "application/vnd.in-toto+json", - "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjoiaW52YWxpZCJ9", - "signatures": [] - }`, + name: "SPDX attestation with invalid predicate", + input: spdxAttestationInvalidPredicate, format: sbom.FormatAttestSPDXJSON, wantErr: true, }, + { + name: "CycloneDX attestation", + input: cycloneDXAttestation, + format: sbom.FormatAttestCycloneDXJSON, + wantErr: false, + }, + { + name: "Sigstore bundle with CycloneDX", + input: sigstoreBundleCycloneDX, + format: sbom.FormatSigstoreBundleCycloneDXJSON, + wantErr: false, + }, + { + name: "Sigstore bundle with SPDX", + input: sigstoreBundleSPDX, + format: sbom.FormatSigstoreBundleSPDXJSON, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := strings.NewReader(tt.input) - _, err := sbom.Decode(context.Background(), r, tt.format) + _, err := sbom.Decode(t.Context(), r, tt.format) if tt.wantErr { require.Error(t, err)