Skip to content

Commit faf4e39

Browse files
committed
sbom: add initial CycloneDX format support
1 parent d31ba4a commit faf4e39

4 files changed

Lines changed: 318 additions & 9 deletions

File tree

client/client_test.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
230230
testSBOMScan,
231231
testSBOMScanSingleRef,
232232
testSBOMSupplements,
233+
testSBOMCycloneDX,
233234
testMultipleCacheExports,
234235
testMountStubsDirectory,
235236
testMountStubsTimestamp,
@@ -12184,6 +12185,246 @@ func testSBOMSupplements(t *testing.T, sb integration.Sandbox) {
1218412185
checkAllReleasable(t, c, sb, true)
1218512186
}
1218612187

12188+
func testSBOMCycloneDX(t *testing.T, sb integration.Sandbox) {
12189+
workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM)
12190+
requiresLinux(t)
12191+
c, err := New(sb.Context(), sb.Address())
12192+
require.NoError(t, err)
12193+
defer c.Close()
12194+
12195+
registry, err := sb.NewRegistry()
12196+
if errors.Is(err, integration.ErrRequirements) {
12197+
t.Skip(err.Error())
12198+
}
12199+
require.NoError(t, err)
12200+
12201+
p := platforms.MustParse(integration.UnixOrWindows("linux/amd64", "windows/amd64"))
12202+
pk := platforms.Format(p)
12203+
12204+
// Build a CycloneDX scanner image
12205+
scannerFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
12206+
res := gateway.NewResult()
12207+
12208+
st := llb.Image("busybox")
12209+
def, err := st.Marshal(sb.Context())
12210+
require.NoError(t, err)
12211+
12212+
r, err := c.Solve(ctx, gateway.SolveRequest{
12213+
Definition: def.ToPB(),
12214+
})
12215+
if err != nil {
12216+
return nil, err
12217+
}
12218+
ref, err := r.SingleRef()
12219+
if err != nil {
12220+
return nil, err
12221+
}
12222+
_, err = ref.ToState()
12223+
if err != nil {
12224+
return nil, err
12225+
}
12226+
res.AddRef(pk, ref)
12227+
12228+
expPlatforms := &exptypes.Platforms{
12229+
Platforms: []exptypes.Platform{{ID: pk, Platform: p}},
12230+
}
12231+
dt, err := json.Marshal(expPlatforms)
12232+
if err != nil {
12233+
return nil, err
12234+
}
12235+
res.AddMeta(exptypes.ExporterPlatformsKey, dt)
12236+
12237+
var img ocispecs.Image
12238+
cmd := `
12239+
cat <<EOF > $BUILDKIT_SCAN_DESTINATION/cdx.json
12240+
{
12241+
"_type": "https://in-toto.io/Statement/v0.1",
12242+
"predicateType": "https://cyclonedx.org/bom",
12243+
"predicate": {
12244+
"bomFormat": "CycloneDX",
12245+
"specVersion": "1.4"
12246+
}
12247+
}
12248+
EOF
12249+
`
12250+
img.Config.Cmd = []string{"/bin/sh", "-c", cmd}
12251+
img.Platform = p
12252+
config, err := json.Marshal(img)
12253+
if err != nil {
12254+
return nil, errors.Wrapf(err, "failed to marshal image config")
12255+
}
12256+
res.AddMeta(fmt.Sprintf("%s/%s", exptypes.ExporterImageConfigKey, pk), config)
12257+
12258+
return res, nil
12259+
}
12260+
12261+
scannerTarget := registry + "/buildkit/testcdxscanner:latest"
12262+
_, err = c.Build(sb.Context(), SolveOpt{
12263+
Exports: []ExportEntry{
12264+
{
12265+
Type: ExporterImage,
12266+
Attrs: map[string]string{
12267+
"name": scannerTarget,
12268+
"push": "true",
12269+
},
12270+
},
12271+
},
12272+
}, "", scannerFrontend, nil)
12273+
require.NoError(t, err)
12274+
12275+
// makeBaseResult builds a scratch image with a greeting file and platform metadata,
12276+
// shared by the plain target and the CDX-annotated frontend.
12277+
makeBaseResult := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
12278+
res := gateway.NewResult()
12279+
12280+
st := llb.Scratch().File(
12281+
llb.Mkfile("/greeting", 0600, []byte("hello cyclonedx!")),
12282+
)
12283+
def, err := st.Marshal(ctx)
12284+
if err != nil {
12285+
return nil, err
12286+
}
12287+
r, err := c.Solve(ctx, gateway.SolveRequest{
12288+
Definition: def.ToPB(),
12289+
})
12290+
if err != nil {
12291+
return nil, err
12292+
}
12293+
ref, err := r.SingleRef()
12294+
if err != nil {
12295+
return nil, err
12296+
}
12297+
if _, err = ref.ToState(); err != nil {
12298+
return nil, err
12299+
}
12300+
res.AddRef(pk, ref)
12301+
12302+
expPlatforms := &exptypes.Platforms{
12303+
Platforms: []exptypes.Platform{{ID: pk, Platform: p}},
12304+
}
12305+
dt, err := json.Marshal(expPlatforms)
12306+
if err != nil {
12307+
return nil, err
12308+
}
12309+
res.AddMeta(exptypes.ExporterPlatformsKey, dt)
12310+
12311+
return res, nil
12312+
}
12313+
12314+
// Test CycloneDX fallback scanner
12315+
target := registry + "/buildkit/testcdx:latest"
12316+
_, err = c.Build(sb.Context(), SolveOpt{
12317+
FrontendAttrs: map[string]string{
12318+
"attest:sbom": "generator=" + scannerTarget,
12319+
},
12320+
Exports: []ExportEntry{
12321+
{
12322+
Type: ExporterImage,
12323+
Attrs: map[string]string{
12324+
"name": target,
12325+
"push": "true",
12326+
},
12327+
},
12328+
},
12329+
}, "", makeBaseResult, nil)
12330+
require.NoError(t, err)
12331+
12332+
desc, provider, err := contentutil.ProviderFromRef(target)
12333+
require.NoError(t, err)
12334+
12335+
imgs, err := testutil.ReadImages(sb.Context(), provider, desc)
12336+
require.NoError(t, err)
12337+
require.Len(t, imgs.Images, 2, "expected main image + attestation manifest")
12338+
12339+
att := imgs.Find("unknown/unknown")
12340+
require.NotNil(t, att)
12341+
attest := intoto.Statement{}
12342+
require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest))
12343+
require.Equal(t, intoto.StatementInTotoV1, attest.Type)
12344+
require.Equal(t, intoto.PredicateCycloneDX, attest.PredicateType)
12345+
require.NotEmpty(t, attest.Subject)
12346+
pred, ok := attest.Predicate.(map[string]interface{})
12347+
require.True(t, ok, "predicate should be a JSON object")
12348+
require.Equal(t, "CycloneDX", pred["bomFormat"])
12349+
require.Equal(t, "1.4", pred["specVersion"])
12350+
12351+
// Test that HasSBOM recognizes CycloneDX attestations from frontend
12352+
cdxFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) {
12353+
res, err := makeBaseResult(ctx, c)
12354+
if err != nil {
12355+
return nil, err
12356+
}
12357+
12358+
// Attach a CycloneDX attestation from the frontend
12359+
st := llb.Scratch().
12360+
File(llb.Mkfile("/result.cdx", 0600, []byte(`{"bomFormat": "CycloneDX"}`)))
12361+
def, err := st.Marshal(ctx)
12362+
if err != nil {
12363+
return nil, err
12364+
}
12365+
r, err := c.Solve(ctx, gateway.SolveRequest{
12366+
Definition: def.ToPB(),
12367+
})
12368+
if err != nil {
12369+
return nil, err
12370+
}
12371+
refAttest, err := r.SingleRef()
12372+
if err != nil {
12373+
return nil, err
12374+
}
12375+
if _, err = refAttest.ToState(); err != nil {
12376+
return nil, err
12377+
}
12378+
12379+
res.AddAttestation(pk, gateway.Attestation{
12380+
Kind: gatewaypb.AttestationKind_InToto,
12381+
Ref: refAttest,
12382+
Path: "/result.cdx",
12383+
InToto: result.InTotoAttestation{
12384+
PredicateType: intoto.PredicateCycloneDX,
12385+
},
12386+
})
12387+
12388+
return res, nil
12389+
}
12390+
12391+
// When frontend provides CycloneDX SBOM, the fallback scanner should be skipped
12392+
target = registry + "/buildkit/testcdx2:latest"
12393+
_, err = c.Build(sb.Context(), SolveOpt{
12394+
FrontendAttrs: map[string]string{
12395+
"attest:sbom": "",
12396+
},
12397+
Exports: []ExportEntry{
12398+
{
12399+
Type: ExporterImage,
12400+
Attrs: map[string]string{
12401+
"name": target,
12402+
"push": "true",
12403+
},
12404+
},
12405+
},
12406+
}, "", cdxFrontend, nil)
12407+
require.NoError(t, err)
12408+
12409+
desc, provider, err = contentutil.ProviderFromRef(target)
12410+
require.NoError(t, err)
12411+
12412+
imgs, err = testutil.ReadImages(sb.Context(), provider, desc)
12413+
require.NoError(t, err)
12414+
require.Len(t, imgs.Images, 2, "expected main image + attestation manifest")
12415+
12416+
att = imgs.Find("unknown/unknown")
12417+
require.NotNil(t, att)
12418+
attest = intoto.Statement{}
12419+
require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest))
12420+
require.Equal(t, intoto.StatementInTotoV1, attest.Type)
12421+
require.Equal(t, intoto.PredicateCycloneDX, attest.PredicateType)
12422+
require.NotEmpty(t, attest.Subject)
12423+
pred, ok = attest.Predicate.(map[string]interface{})
12424+
require.True(t, ok, "predicate should be a JSON object")
12425+
require.Equal(t, "CycloneDX", pred["bomFormat"])
12426+
}
12427+
1218712428
func testMultipleCacheExports(t *testing.T, sb integration.Sandbox) {
1218812429
workers.CheckFeatureCompat(t, sb, workers.FeatureMultiCacheExport)
1218912430
c, err := New(sb.Context(), sb.Address())

docs/attestations/sbom-protocol.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ The SBOM generator image is expected to follow the rules of the BuildKit SBOM
1313
generator protocol, defined in this document.
1414

1515
> [!NOTE]
16-
> Currently, only SBOMs in the [SPDX](https://spdx.dev) JSON format are
17-
> supported.
16+
> SBOMs in [SPDX](https://spdx.dev) JSON format and
17+
> [CycloneDX](https://cyclonedx.org) JSON format are supported.
1818
>
19-
> These SBOMs will be attached to the final image as an in-toto attestation
20-
> with the `https://spdx.dev/Document` predicate type.
19+
> SPDX SBOMs will be attached as in-toto attestations with the
20+
> `https://spdx.dev/Document` predicate type. CycloneDX SBOMs will use
21+
> the `https://cyclonedx.org/bom` predicate type.
2122
2223
## Implementations
2324

@@ -39,7 +40,9 @@ by BuildKit:
3940
- `BUILDKIT_SCAN_DESTINATION` (required)
4041

4142
This variable specifies the directory where the scanner should write its
42-
SBOM data. Scanners should write their SBOMs to `$BUILDKIT_SCAN_DESTINATION/<scan>.spdx.json`
43+
SBOM data. Scanners should write their SBOMs to
44+
`$BUILDKIT_SCAN_DESTINATION/<scan>.spdx.json` for SPDX format or
45+
`$BUILDKIT_SCAN_DESTINATION/<scan>.cdx.json` for CycloneDX format,
4346
where `<scan>` is the name of an arbitrary scan. A scanner may produce
4447
multiple scans for a single target - scan names must be unique within a
4548
target, but should not be considered significant by producers or consumers.
@@ -50,7 +53,8 @@ by BuildKit:
5053
filesystem of the final build result.
5154

5255
The scanner should scan this filesystem, and write its SBOM result to
53-
`$BUILDKIT_SCAN_DESTINATION/$(basename $BUILDKIT_SCAN_SOURCE).spdx.json`.
56+
`$BUILDKIT_SCAN_DESTINATION/$(basename $BUILDKIT_SCAN_SOURCE).spdx.json`
57+
(or `.cdx.json` for CycloneDX).
5458

5559
- `BUILDKIT_SCAN_SOURCE_EXTRAS` (optional)
5660

@@ -59,7 +63,8 @@ by BuildKit:
5963
a directory that does not exist, then no extras should be scanned.
6064

6165
The scanner should iterate through this directory, and write its SBOM scans
62-
to `$BUILDKIT_SCAN_DESTINATION/<scan>.spdx.json`, similar to above.
66+
to `$BUILDKIT_SCAN_DESTINATION/<scan>.spdx.json` (or `.cdx.json` for
67+
CycloneDX), similar to above.
6368

6469
A scanner must not error if optional parameters are not set.
6570

frontend/attestations/sbom/sbom.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ func CreateSBOMScanner(ctx context.Context, resolver sourceresolver.MetaResolver
100100
result.AttestationSBOMCore: []byte(CoreSBOMName),
101101
},
102102
InToto: result.InTotoAttestation{
103-
PredicateType: intoto.PredicateSPDX,
103+
// PredicateType is left empty so that the unbundler
104+
// detects the format (SPDX or CycloneDX) from the
105+
// predicateType field in the scanner's JSON output.
106+
// See exporter/attestation/unbundle.go
104107
},
105108
}, nil
106109
}, nil
@@ -109,10 +112,16 @@ func CreateSBOMScanner(ctx context.Context, resolver sourceresolver.MetaResolver
109112
func HasSBOM[T comparable](res *result.Result[T]) bool {
110113
for _, as := range res.Attestations {
111114
for _, a := range as {
112-
if a.InToto.PredicateType == intoto.PredicateSPDX {
115+
if IsSBOMPredicateType(a.InToto.PredicateType) {
113116
return true
114117
}
115118
}
116119
}
117120
return false
118121
}
122+
123+
// IsSBOMPredicateType returns true if the predicate type represents an SBOM,
124+
// either SPDX or CycloneDX.
125+
func IsSBOMPredicateType(predicateType string) bool {
126+
return predicateType == intoto.PredicateSPDX || predicateType == intoto.PredicateCycloneDX
127+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package sbom
2+
3+
import (
4+
"testing"
5+
6+
intoto "github.com/in-toto/in-toto-golang/in_toto"
7+
"github.com/moby/buildkit/solver/result"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestIsSBOMPredicateType(t *testing.T) {
12+
assert.True(t, IsSBOMPredicateType(intoto.PredicateSPDX))
13+
assert.True(t, IsSBOMPredicateType(intoto.PredicateCycloneDX))
14+
assert.False(t, IsSBOMPredicateType(""))
15+
assert.False(t, IsSBOMPredicateType("https://slsa.dev/provenance/v0.2"))
16+
assert.False(t, IsSBOMPredicateType("https://example.com/unknown"))
17+
}
18+
19+
func TestHasSBOM(t *testing.T) {
20+
t.Run("SPDX", func(t *testing.T) {
21+
res := &result.Result[int]{}
22+
res.Attestations = map[string][]result.Attestation[int]{
23+
"linux/amd64": {
24+
{InToto: result.InTotoAttestation{PredicateType: intoto.PredicateSPDX}},
25+
},
26+
}
27+
assert.True(t, HasSBOM(res))
28+
})
29+
30+
t.Run("CycloneDX", func(t *testing.T) {
31+
res := &result.Result[int]{}
32+
res.Attestations = map[string][]result.Attestation[int]{
33+
"linux/amd64": {
34+
{InToto: result.InTotoAttestation{PredicateType: intoto.PredicateCycloneDX}},
35+
},
36+
}
37+
assert.True(t, HasSBOM(res))
38+
})
39+
40+
t.Run("none", func(t *testing.T) {
41+
res := &result.Result[int]{}
42+
res.Attestations = map[string][]result.Attestation[int]{
43+
"linux/amd64": {
44+
{InToto: result.InTotoAttestation{PredicateType: "https://slsa.dev/provenance/v0.2"}},
45+
},
46+
}
47+
assert.False(t, HasSBOM(res))
48+
})
49+
50+
t.Run("empty", func(t *testing.T) {
51+
res := &result.Result[int]{}
52+
assert.False(t, HasSBOM(res))
53+
})
54+
}

0 commit comments

Comments
 (0)