Skip to content

Commit d8eaaeb

Browse files
authored
feat(sbom): add support for SPDX attestations (#9829)
1 parent 5c42cc5 commit d8eaaeb

File tree

5 files changed

+238
-14
lines changed

5 files changed

+238
-14
lines changed

pkg/fanal/analyzer/sbom/testdata/elasticsearch.spdx.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"SPDXID": "SPDXRef-elasticsearch",
2+
"SPDXID": "SPDXRef-DOCUMENT",
33
"spdxVersion": "SPDX-2.3",
44
"creationInfo": {
55
"created": "2023-08-18T20:09:40.708Z",

pkg/fanal/analyzer/sbom/testdata/postgresql.spdx.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"SPDXID": "SPDXRef-postgresql",
2+
"SPDXID": "SPDXRef-DOCUMENT",
33
"spdxVersion": "SPDX-2.3",
44
"creationInfo": {
55
"created": "2023-07-13T19:24:23.609Z",

pkg/fanal/artifact/sbom/sbom.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func (a Artifact) Inspect(ctx context.Context) (artifact.Reference, error) {
7979
switch format {
8080
case sbom.FormatCycloneDXJSON, sbom.FormatCycloneDXXML, sbom.FormatAttestCycloneDXJSON, sbom.FormatLegacyCosignAttestCycloneDXJSON:
8181
artifactType = types.TypeCycloneDX
82-
case sbom.FormatSPDXTV, sbom.FormatSPDXJSON:
82+
case sbom.FormatSPDXTV, sbom.FormatSPDXJSON, sbom.FormatAttestSPDXJSON:
8383
artifactType = types.TypeSPDX
8484

8585
}

pkg/sbom/sbom.go

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
FormatSPDXTV Format = "spdx-tv"
2929
FormatSPDXXML Format = "spdx-xml"
3030
FormatAttestCycloneDXJSON Format = "attest-cyclonedx-json"
31+
FormatAttestSPDXJSON Format = "attest-spdx-json"
3132
FormatUnknown Format = "unknown"
3233

3334
// FormatLegacyCosignAttestCycloneDXJSON is used to support the older format of CycloneDX JSON Attestation
@@ -89,7 +90,7 @@ func IsSPDXJSON(r io.ReadSeeker) (bool, error) {
8990

9091
var spdxBom spdxHeader
9192
if err := json.NewDecoder(r).Decode(&spdxBom); err == nil {
92-
if strings.HasPrefix(spdxBom.SpdxID, "SPDX") {
93+
if spdxBom.SpdxID == "SPDXRef-DOCUMENT" {
9394
return true, nil
9495
}
9596
}
@@ -145,26 +146,22 @@ func DetectFormat(r io.ReadSeeker) (Format, error) {
145146
return FormatUnknown, xerrors.Errorf("seek error: %w", err)
146147
}
147148

148-
// Try in-toto attestation
149-
format, ok := decodeAttestCycloneDXJSONFormat(r)
149+
// Try in-toto attestation (CycloneDX or SPDX)
150+
format, ok := decodeAttestationFormat(r)
150151
if ok {
151152
return format, nil
152153
}
153154

154155
return FormatUnknown, nil
155156
}
156157

157-
func decodeAttestCycloneDXJSONFormat(r io.ReadSeeker) (Format, bool) {
158+
func decodeAttestationFormat(r io.ReadSeeker) (Format, bool) {
158159
var s attestation.Statement
159160

160161
if err := json.NewDecoder(r).Decode(&s); err != nil {
161162
return "", false
162163
}
163164

164-
if s.PredicateType != in_toto.PredicateCycloneDX && s.PredicateType != PredicateCycloneDXBeforeV05 {
165-
return "", false
166-
}
167-
168165
if s.Predicate == nil {
169166
return "", false
170167
}
@@ -174,11 +171,22 @@ func decodeAttestCycloneDXJSONFormat(r io.ReadSeeker) (Format, bool) {
174171
return "", false
175172
}
176173

177-
if _, ok := m["Data"]; ok {
178-
return FormatLegacyCosignAttestCycloneDXJSON, true
174+
// Check CycloneDX
175+
if s.PredicateType == in_toto.PredicateCycloneDX || s.PredicateType == PredicateCycloneDXBeforeV05 {
176+
if _, ok := m["Data"]; ok {
177+
return FormatLegacyCosignAttestCycloneDXJSON, true
178+
}
179+
return FormatAttestCycloneDXJSON, true
180+
}
181+
182+
// Check SPDX
183+
if s.PredicateType == in_toto.PredicateSPDX {
184+
if spdxID, ok := m["SPDXID"].(string); ok && spdxID == "SPDXRef-DOCUMENT" {
185+
return FormatAttestSPDXJSON, true
186+
}
179187
}
180188

181-
return FormatAttestCycloneDXJSON, true
189+
return "", false
182190
}
183191

184192
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)
214222
},
215223
}
216224
decoder = json.NewDecoder(f)
225+
case FormatAttestSPDXJSON:
226+
// dsse envelope
227+
// => in-toto attestation
228+
// => SPDX JSON
229+
bom = core.NewBOM(core.Options{})
230+
v = &attestation.Statement{
231+
Predicate: &spdx.SPDX{BOM: bom},
232+
}
233+
decoder = json.NewDecoder(f)
217234
case FormatSPDXJSON:
218235
bom = core.NewBOM(core.Options{})
219236
v = &spdx.SPDX{BOM: bom}

pkg/sbom/sbom_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package sbom_test
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/aquasecurity/trivy/pkg/sbom"
12+
)
13+
14+
func TestDetectFormat(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
input string
18+
want sbom.Format
19+
}{
20+
{
21+
name: "SPDX attestation with valid predicate",
22+
// DSSE envelope with base64-encoded in-toto statement
23+
input: `{
24+
"payloadType": "application/vnd.in-toto+json",
25+
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IlNQRFhSZWYtRE9DVU1FTlQiLCJzcGR4VmVyc2lvbiI6IlNQRFgtMi4zIiwibmFtZSI6InRlc3QifX0=",
26+
"signatures": []
27+
}`,
28+
want: sbom.FormatAttestSPDXJSON,
29+
},
30+
{
31+
name: "SPDX attestation without SPDXID prefix",
32+
// 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"}}
33+
input: `{
34+
"payloadType": "application/vnd.in-toto+json",
35+
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IkludmFsaWRJRCIsInNwZHhWZXJzaW9uIjoiU1BEWC0yLjMiLCJuYW1lIjoidGVzdCJ9fQ==",
36+
"signatures": []
37+
}`,
38+
want: sbom.FormatUnknown,
39+
},
40+
{
41+
name: "CycloneDX attestation",
42+
// 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"}}
43+
input: `{
44+
"payloadType": "application/vnd.in-toto+json",
45+
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2N5Y2xvbmVkeC5vcmcvYm9tIiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7ImJvbUZvcm1hdCI6IkN5Y2xvbmVEWCIsInNwZWNWZXJzaW9uIjoiMS40In19",
46+
"signatures": []
47+
}`,
48+
want: sbom.FormatAttestCycloneDXJSON,
49+
},
50+
{
51+
name: "Regular SPDX JSON (not attestation)",
52+
input: `{
53+
"SPDXID": "SPDXRef-DOCUMENT",
54+
"spdxVersion": "SPDX-2.3",
55+
"name": "test"
56+
}`,
57+
want: sbom.FormatSPDXJSON,
58+
},
59+
{
60+
name: "Regular CycloneDX JSON (not attestation)",
61+
input: `{
62+
"bomFormat": "CycloneDX",
63+
"specVersion": "1.4"
64+
}`,
65+
want: sbom.FormatCycloneDXJSON,
66+
},
67+
{
68+
name: "Unknown format",
69+
input: `{
70+
"unknown": "format"
71+
}`,
72+
want: sbom.FormatUnknown,
73+
},
74+
}
75+
76+
for _, tt := range tests {
77+
t.Run(tt.name, func(t *testing.T) {
78+
r := strings.NewReader(tt.input)
79+
got, err := sbom.DetectFormat(r)
80+
81+
require.NoError(t, err)
82+
assert.Equal(t, tt.want, got)
83+
})
84+
}
85+
}
86+
87+
func TestDecode_SPDXAttestation(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
input string
91+
format sbom.Format
92+
wantErr bool
93+
}{
94+
{
95+
name: "SPDX attestation decode",
96+
// 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":[]}}
97+
input: `{
98+
"payloadType": "application/vnd.in-toto+json",
99+
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjp7IlNQRFhJRCI6IlNQRFhSZWYtRE9DVU1FTlQiLCJzcGR4VmVyc2lvbiI6IlNQRFgtMi4zIiwibmFtZSI6InRlc3QiLCJkYXRhTGljZW5zZSI6IkNDMC0xLjAiLCJkb2N1bWVudE5hbWVzcGFjZSI6Imh0dHA6Ly90cml2eS5kZXYvdGVzdCIsImNyZWF0aW9uSW5mbyI6eyJjcmVhdG9ycyI6WyJUb29sOiB0ZXN0Il0sImNyZWF0ZWQiOiIyMDI1LTAxLTAxVDAwOjAwOjAwWiJ9LCJwYWNrYWdlcyI6W119fQ==",
100+
"signatures": []
101+
}`,
102+
format: sbom.FormatAttestSPDXJSON,
103+
wantErr: false,
104+
},
105+
{
106+
name: "Invalid SPDX attestation",
107+
// Base64-encoded: {"_type":"https://in-toto.io/Statement/v0.1","predicateType":"https://spdx.dev/Document","subject":[{"name":"test","digest":{"sha256":"abc123"}}],"predicate":"invalid"}
108+
input: `{
109+
"payloadType": "application/vnd.in-toto+json",
110+
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3NwZHguZGV2L0RvY3VtZW50Iiwic3ViamVjdCI6W3sibmFtZSI6InRlc3QiLCJkaWdlc3QiOnsic2hhMjU2IjoiYWJjMTIzIn19XSwicHJlZGljYXRlIjoiaW52YWxpZCJ9",
111+
"signatures": []
112+
}`,
113+
format: sbom.FormatAttestSPDXJSON,
114+
wantErr: true,
115+
},
116+
}
117+
118+
for _, tt := range tests {
119+
t.Run(tt.name, func(t *testing.T) {
120+
r := strings.NewReader(tt.input)
121+
_, err := sbom.Decode(context.Background(), r, tt.format)
122+
123+
if tt.wantErr {
124+
require.Error(t, err)
125+
return
126+
}
127+
128+
require.NoError(t, err)
129+
})
130+
}
131+
}
132+
133+
func TestIsSPDXJSON(t *testing.T) {
134+
tests := []struct {
135+
name string
136+
input string
137+
want bool
138+
}{
139+
{
140+
name: "Valid SPDX JSON",
141+
input: `{
142+
"SPDXID": "SPDXRef-DOCUMENT",
143+
"spdxVersion": "SPDX-2.3"
144+
}`,
145+
want: true,
146+
},
147+
{
148+
name: "Invalid SPDXID",
149+
input: `{
150+
"SPDXID": "InvalidID",
151+
"spdxVersion": "SPDX-2.3"
152+
}`,
153+
want: false,
154+
},
155+
{
156+
name: "Not SPDX",
157+
input: `{
158+
"bomFormat": "CycloneDX"
159+
}`,
160+
want: false,
161+
},
162+
}
163+
164+
for _, tt := range tests {
165+
t.Run(tt.name, func(t *testing.T) {
166+
r := strings.NewReader(tt.input)
167+
got, err := sbom.IsSPDXJSON(r)
168+
169+
require.NoError(t, err)
170+
assert.Equal(t, tt.want, got)
171+
})
172+
}
173+
}
174+
175+
func TestIsCycloneDXJSON(t *testing.T) {
176+
tests := []struct {
177+
name string
178+
input string
179+
want bool
180+
}{
181+
{
182+
name: "Valid CycloneDX JSON",
183+
input: `{
184+
"bomFormat": "CycloneDX",
185+
"specVersion": "1.4"
186+
}`,
187+
want: true,
188+
},
189+
{
190+
name: "Not CycloneDX",
191+
input: `{
192+
"SPDXID": "SPDXRef-DOCUMENT"
193+
}`,
194+
want: false,
195+
},
196+
}
197+
198+
for _, tt := range tests {
199+
t.Run(tt.name, func(t *testing.T) {
200+
r := strings.NewReader(tt.input)
201+
got, err := sbom.IsCycloneDXJSON(r)
202+
203+
require.NoError(t, err)
204+
assert.Equal(t, tt.want, got)
205+
})
206+
}
207+
}

0 commit comments

Comments
 (0)