diff --git a/pkg/fanal/analyzer/sbom/testdata/elasticsearch.spdx.json b/pkg/fanal/analyzer/sbom/testdata/elasticsearch.spdx.json index 155d7de903..3dc6df904d 100644 --- a/pkg/fanal/analyzer/sbom/testdata/elasticsearch.spdx.json +++ b/pkg/fanal/analyzer/sbom/testdata/elasticsearch.spdx.json @@ -1,5 +1,5 @@ { - "SPDXID": "SPDXRef-elasticsearch", + "SPDXID": "SPDXRef-DOCUMENT", "spdxVersion": "SPDX-2.3", "creationInfo": { "created": "2023-08-18T20:09:40.708Z", diff --git a/pkg/fanal/analyzer/sbom/testdata/postgresql.spdx.json b/pkg/fanal/analyzer/sbom/testdata/postgresql.spdx.json index ec9860bb01..3173e5d2d5 100644 --- a/pkg/fanal/analyzer/sbom/testdata/postgresql.spdx.json +++ b/pkg/fanal/analyzer/sbom/testdata/postgresql.spdx.json @@ -1,5 +1,5 @@ { - "SPDXID": "SPDXRef-postgresql", + "SPDXID": "SPDXRef-DOCUMENT", "spdxVersion": "SPDX-2.3", "creationInfo": { "created": "2023-07-13T19:24:23.609Z", diff --git a/pkg/fanal/artifact/sbom/sbom.go b/pkg/fanal/artifact/sbom/sbom.go index a09cbd2125..1dd6cec3bd 100644 --- a/pkg/fanal/artifact/sbom/sbom.go +++ b/pkg/fanal/artifact/sbom/sbom.go @@ -79,7 +79,7 @@ func (a Artifact) Inspect(ctx context.Context) (artifact.Reference, error) { switch format { case sbom.FormatCycloneDXJSON, sbom.FormatCycloneDXXML, sbom.FormatAttestCycloneDXJSON, sbom.FormatLegacyCosignAttestCycloneDXJSON: artifactType = types.TypeCycloneDX - case sbom.FormatSPDXTV, sbom.FormatSPDXJSON: + case sbom.FormatSPDXTV, sbom.FormatSPDXJSON, sbom.FormatAttestSPDXJSON: artifactType = types.TypeSPDX } diff --git a/pkg/sbom/sbom.go b/pkg/sbom/sbom.go index 65f2e7d3f0..4630f3e3bd 100644 --- a/pkg/sbom/sbom.go +++ b/pkg/sbom/sbom.go @@ -28,6 +28,7 @@ const ( FormatSPDXTV Format = "spdx-tv" FormatSPDXXML Format = "spdx-xml" FormatAttestCycloneDXJSON Format = "attest-cyclonedx-json" + FormatAttestSPDXJSON Format = "attest-spdx-json" FormatUnknown Format = "unknown" // FormatLegacyCosignAttestCycloneDXJSON is used to support the older format of CycloneDX JSON Attestation @@ -89,7 +90,7 @@ func IsSPDXJSON(r io.ReadSeeker) (bool, error) { var spdxBom spdxHeader if err := json.NewDecoder(r).Decode(&spdxBom); err == nil { - if strings.HasPrefix(spdxBom.SpdxID, "SPDX") { + if spdxBom.SpdxID == "SPDXRef-DOCUMENT" { return true, nil } } @@ -145,8 +146,8 @@ func DetectFormat(r io.ReadSeeker) (Format, error) { return FormatUnknown, xerrors.Errorf("seek error: %w", err) } - // Try in-toto attestation - format, ok := decodeAttestCycloneDXJSONFormat(r) + // Try in-toto attestation (CycloneDX or SPDX) + format, ok := decodeAttestationFormat(r) if ok { return format, nil } @@ -154,17 +155,13 @@ func DetectFormat(r io.ReadSeeker) (Format, error) { return FormatUnknown, nil } -func decodeAttestCycloneDXJSONFormat(r io.ReadSeeker) (Format, bool) { +func decodeAttestationFormat(r io.ReadSeeker) (Format, bool) { var s attestation.Statement if err := json.NewDecoder(r).Decode(&s); err != nil { return "", false } - if s.PredicateType != in_toto.PredicateCycloneDX && s.PredicateType != PredicateCycloneDXBeforeV05 { - return "", false - } - if s.Predicate == nil { return "", false } @@ -174,11 +171,22 @@ func decodeAttestCycloneDXJSONFormat(r io.ReadSeeker) (Format, bool) { return "", false } - if _, ok := m["Data"]; ok { - return FormatLegacyCosignAttestCycloneDXJSON, true + // Check CycloneDX + if s.PredicateType == in_toto.PredicateCycloneDX || s.PredicateType == PredicateCycloneDXBeforeV05 { + if _, ok := m["Data"]; ok { + return FormatLegacyCosignAttestCycloneDXJSON, true + } + return FormatAttestCycloneDXJSON, true } - return FormatAttestCycloneDXJSON, true + // Check SPDX + if s.PredicateType == in_toto.PredicateSPDX { + if spdxID, ok := m["SPDXID"].(string); ok && spdxID == "SPDXRef-DOCUMENT" { + return FormatAttestSPDXJSON, true + } + } + + return "", false } func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error) { @@ -214,6 +222,15 @@ func Decode(ctx context.Context, f io.Reader, format Format) (types.SBOM, error) }, } decoder = json.NewDecoder(f) + case FormatAttestSPDXJSON: + // dsse envelope + // => in-toto attestation + // => SPDX JSON + bom = core.NewBOM(core.Options{}) + v = &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 new file mode 100644 index 0000000000..8a9eaacfb0 --- /dev/null +++ b/pkg/sbom/sbom_test.go @@ -0,0 +1,207 @@ +package sbom_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/sbom" +) + +func TestDetectFormat(t *testing.T) { + tests := []struct { + name string + input string + 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 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: "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: "Regular SPDX JSON (not attestation)", + input: `{ + "SPDXID": "SPDXRef-DOCUMENT", + "spdxVersion": "SPDX-2.3", + "name": "test" + }`, + want: sbom.FormatSPDXJSON, + }, + { + name: "Regular CycloneDX JSON (not attestation)", + input: `{ + "bomFormat": "CycloneDX", + "specVersion": "1.4" + }`, + want: sbom.FormatCycloneDXJSON, + }, + { + name: "Unknown format", + input: `{ + "unknown": "format" + }`, + want: sbom.FormatUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := strings.NewReader(tt.input) + got, err := sbom.DetectFormat(r) + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDecode_SPDXAttestation(t *testing.T) { + tests := []struct { + name string + input string + format sbom.Format + 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": [] + }`, + 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": [] + }`, + format: sbom.FormatAttestSPDXJSON, + wantErr: true, + }, + } + + 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) + + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestIsSPDXJSON(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + { + name: "Valid SPDX JSON", + input: `{ + "SPDXID": "SPDXRef-DOCUMENT", + "spdxVersion": "SPDX-2.3" + }`, + want: true, + }, + { + name: "Invalid SPDXID", + input: `{ + "SPDXID": "InvalidID", + "spdxVersion": "SPDX-2.3" + }`, + want: false, + }, + { + name: "Not SPDX", + input: `{ + "bomFormat": "CycloneDX" + }`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := strings.NewReader(tt.input) + got, err := sbom.IsSPDXJSON(r) + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsCycloneDXJSON(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + { + name: "Valid CycloneDX JSON", + input: `{ + "bomFormat": "CycloneDX", + "specVersion": "1.4" + }`, + want: true, + }, + { + name: "Not CycloneDX", + input: `{ + "SPDXID": "SPDXRef-DOCUMENT" + }`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := strings.NewReader(tt.input) + got, err := sbom.IsCycloneDXJSON(r) + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}