feat(sbom): add support for SPDX attestations (#9829)

This commit is contained in:
Teppei Fukuda
2025-11-21 17:44:54 +09:00
committed by GitHub
parent 5c42cc590b
commit d8eaaeb611
5 changed files with 238 additions and 14 deletions

View File

@@ -1,5 +1,5 @@
{
"SPDXID": "SPDXRef-elasticsearch",
"SPDXID": "SPDXRef-DOCUMENT",
"spdxVersion": "SPDX-2.3",
"creationInfo": {
"created": "2023-08-18T20:09:40.708Z",

View File

@@ -1,5 +1,5 @@
{
"SPDXID": "SPDXRef-postgresql",
"SPDXID": "SPDXRef-DOCUMENT",
"spdxVersion": "SPDX-2.3",
"creationInfo": {
"created": "2023-07-13T19:24:23.609Z",

View File

@@ -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
}

View File

@@ -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
View 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)
})
}
}