mirror of
https://github.com/aquasecurity/trivy.git
synced 2025-12-12 07:40:48 -08:00
feat(sbom): add support for SPDX attestations (#9829)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-elasticsearch",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"creationInfo": {
|
||||
"created": "2023-08-18T20:09:40.708Z",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-postgresql",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"creationInfo": {
|
||||
"created": "2023-07-13T19:24:23.609Z",
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
207
pkg/sbom/sbom_test.go
Normal file
207
pkg/sbom/sbom_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user