From 6f82ce6ffde037566e57f86d561f662d0a05636e Mon Sep 17 00:00:00 2001 From: Ev Cheng Date: Tue, 16 Jun 2026 10:05:00 -0700 Subject: [PATCH] sbom: add initial CycloneDX format support Signed-off-by: Ev Cheng --- client/client_test.go | 241 ++++++++++++++++++++++++ docs/attestations/sbom-protocol.md | 19 +- frontend/attestations/sbom/sbom.go | 13 +- frontend/attestations/sbom/sbom_test.go | 54 ++++++ 4 files changed, 318 insertions(+), 9 deletions(-) create mode 100644 frontend/attestations/sbom/sbom_test.go diff --git a/client/client_test.go b/client/client_test.go index 7a1cc1943e2d..8294ec405141 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -230,6 +230,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){ testSBOMScan, testSBOMScanSingleRef, testSBOMSupplements, + testSBOMCycloneDX, testMultipleCacheExports, testMountStubsDirectory, testMountStubsTimestamp, @@ -12184,6 +12185,246 @@ func testSBOMSupplements(t *testing.T, sb integration.Sandbox) { checkAllReleasable(t, c, sb, true) } +func testSBOMCycloneDX(t *testing.T, sb integration.Sandbox) { + workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush, workers.FeatureSBOM) + requiresLinux(t) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + + p := platforms.DefaultSpec() + pk := platforms.Format(p) + + // Build a CycloneDX scanner image + scannerFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + st := llb.Image("busybox", llb.Platform(p)) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(pk, ref) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + var img ocispecs.Image + cmd := ` +cat < $BUILDKIT_SCAN_DESTINATION/cdx.json +{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "https://cyclonedx.org/bom", + "predicate": { + "bomFormat": "CycloneDX", + "specVersion": "1.4" + } +} +EOF +` + img.Config.Cmd = []string{"/bin/sh", "-c", cmd} + img.Platform = p + config, err := json.Marshal(img) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal image config") + } + res.AddMeta(fmt.Sprintf("%s/%s", exptypes.ExporterImageConfigKey, pk), config) + + return res, nil + } + + scannerTarget := registry + "/buildkit/testcdxscanner:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": scannerTarget, + "push": "true", + }, + }, + }, + }, "", scannerFrontend, nil) + require.NoError(t, err) + + // makeBaseResult builds a scratch image with a greeting file and platform metadata, + // shared by the plain target and the CDX-annotated frontend. + makeBaseResult := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + + st := llb.Scratch().File( + llb.Mkfile("/greeting", 0600, []byte("hello cyclonedx!")), + ) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + if _, err = ref.ToState(); err != nil { + return nil, err + } + res.AddRef(pk, ref) + + expPlatforms := &exptypes.Platforms{ + Platforms: []exptypes.Platform{{ID: pk, Platform: p}}, + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + // Test CycloneDX fallback scanner + target := registry + "/buildkit/testcdx:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "generator=" + scannerTarget, + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", makeBaseResult, nil) + require.NoError(t, err) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Len(t, imgs.Images, 2, "expected main image + attestation manifest") + + att := imgs.Find("unknown/unknown") + require.NotNil(t, att) + attest := intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateCycloneDX, attest.PredicateType) + require.NotEmpty(t, attest.Subject) + pred, ok := attest.Predicate.(map[string]interface{}) + require.True(t, ok, "predicate should be a JSON object") + require.Equal(t, "CycloneDX", pred["bomFormat"]) + require.Equal(t, "1.4", pred["specVersion"]) + + // Test that HasSBOM recognizes CycloneDX attestations from frontend + cdxFrontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res, err := makeBaseResult(ctx, c) + if err != nil { + return nil, err + } + + // Attach a CycloneDX attestation from the frontend + st := llb.Scratch(). + File(llb.Mkfile("/result.cdx", 0600, []byte(`{"bomFormat": "CycloneDX"}`))) + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + refAttest, err := r.SingleRef() + if err != nil { + return nil, err + } + if _, err = refAttest.ToState(); err != nil { + return nil, err + } + + res.AddAttestation(pk, gateway.Attestation{ + Kind: gatewaypb.AttestationKind_InToto, + Ref: refAttest, + Path: "/result.cdx", + InToto: result.InTotoAttestation{ + PredicateType: intoto.PredicateCycloneDX, + }, + }) + + return res, nil + } + + // When frontend provides CycloneDX SBOM, the fallback scanner should be skipped + target = registry + "/buildkit/testcdx2:latest" + _, err = c.Build(sb.Context(), SolveOpt{ + FrontendAttrs: map[string]string{ + "attest:sbom": "", + }, + Exports: []ExportEntry{ + { + Type: ExporterImage, + Attrs: map[string]string{ + "name": target, + "push": "true", + }, + }, + }, + }, "", cdxFrontend, nil) + require.NoError(t, err) + + desc, provider, err = contentutil.ProviderFromRef(target) + require.NoError(t, err) + + imgs, err = testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + require.Len(t, imgs.Images, 2, "expected main image + attestation manifest") + + att = imgs.Find("unknown/unknown") + require.NotNil(t, att) + attest = intoto.Statement{} + require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) + require.Equal(t, intoto.StatementInTotoV1, attest.Type) + require.Equal(t, intoto.PredicateCycloneDX, attest.PredicateType) + require.NotEmpty(t, attest.Subject) + pred, ok = attest.Predicate.(map[string]interface{}) + require.True(t, ok, "predicate should be a JSON object") + require.Equal(t, "CycloneDX", pred["bomFormat"]) +} + func testMultipleCacheExports(t *testing.T, sb integration.Sandbox) { workers.CheckFeatureCompat(t, sb, workers.FeatureMultiCacheExport) c, err := New(sb.Context(), sb.Address()) diff --git a/docs/attestations/sbom-protocol.md b/docs/attestations/sbom-protocol.md index 5978871da9b9..b381909ae179 100644 --- a/docs/attestations/sbom-protocol.md +++ b/docs/attestations/sbom-protocol.md @@ -13,11 +13,12 @@ The SBOM generator image is expected to follow the rules of the BuildKit SBOM generator protocol, defined in this document. > [!NOTE] -> Currently, only SBOMs in the [SPDX](https://spdx.dev) JSON format are -> supported. +> SBOMs in [SPDX](https://spdx.dev) JSON format and +> [CycloneDX](https://cyclonedx.org) JSON format are supported. > -> These SBOMs will be attached to the final image as an in-toto attestation -> with the `https://spdx.dev/Document` predicate type. +> SPDX SBOMs will be attached as in-toto attestations with the +> `https://spdx.dev/Document` predicate type. CycloneDX SBOMs will use +> the `https://cyclonedx.org/bom` predicate type. ## Implementations @@ -39,7 +40,9 @@ by BuildKit: - `BUILDKIT_SCAN_DESTINATION` (required) This variable specifies the directory where the scanner should write its - SBOM data. Scanners should write their SBOMs to `$BUILDKIT_SCAN_DESTINATION/.spdx.json` + SBOM data. Scanners should write their SBOMs to + `$BUILDKIT_SCAN_DESTINATION/.spdx.json` for SPDX format or + `$BUILDKIT_SCAN_DESTINATION/.cdx.json` for CycloneDX format, where `` is the name of an arbitrary scan. A scanner may produce multiple scans for a single target - scan names must be unique within a target, but should not be considered significant by producers or consumers. @@ -50,7 +53,8 @@ by BuildKit: filesystem of the final build result. The scanner should scan this filesystem, and write its SBOM result to - `$BUILDKIT_SCAN_DESTINATION/$(basename $BUILDKIT_SCAN_SOURCE).spdx.json`. + `$BUILDKIT_SCAN_DESTINATION/$(basename $BUILDKIT_SCAN_SOURCE).spdx.json` + (or `.cdx.json` for CycloneDX). - `BUILDKIT_SCAN_SOURCE_EXTRAS` (optional) @@ -59,7 +63,8 @@ by BuildKit: a directory that does not exist, then no extras should be scanned. The scanner should iterate through this directory, and write its SBOM scans - to `$BUILDKIT_SCAN_DESTINATION/.spdx.json`, similar to above. + to `$BUILDKIT_SCAN_DESTINATION/.spdx.json` (or `.cdx.json` for + CycloneDX), similar to above. A scanner must not error if optional parameters are not set. diff --git a/frontend/attestations/sbom/sbom.go b/frontend/attestations/sbom/sbom.go index 61f6444685c4..801a32c75112 100644 --- a/frontend/attestations/sbom/sbom.go +++ b/frontend/attestations/sbom/sbom.go @@ -100,7 +100,10 @@ func CreateSBOMScanner(ctx context.Context, resolver sourceresolver.MetaResolver result.AttestationSBOMCore: []byte(CoreSBOMName), }, InToto: result.InTotoAttestation{ - PredicateType: intoto.PredicateSPDX, + // PredicateType is left empty so that the unbundler + // detects the format (SPDX or CycloneDX) from the + // predicateType field in the scanner's JSON output. + // See exporter/attestation/unbundle.go }, }, nil }, nil @@ -109,10 +112,16 @@ func CreateSBOMScanner(ctx context.Context, resolver sourceresolver.MetaResolver func HasSBOM[T comparable](res *result.Result[T]) bool { for _, as := range res.Attestations { for _, a := range as { - if a.InToto.PredicateType == intoto.PredicateSPDX { + if IsSBOMPredicateType(a.InToto.PredicateType) { return true } } } return false } + +// IsSBOMPredicateType returns true if the predicate type represents an SBOM, +// either SPDX or CycloneDX. +func IsSBOMPredicateType(predicateType string) bool { + return predicateType == intoto.PredicateSPDX || predicateType == intoto.PredicateCycloneDX +} diff --git a/frontend/attestations/sbom/sbom_test.go b/frontend/attestations/sbom/sbom_test.go new file mode 100644 index 000000000000..ed3c0a906238 --- /dev/null +++ b/frontend/attestations/sbom/sbom_test.go @@ -0,0 +1,54 @@ +package sbom + +import ( + "testing" + + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/moby/buildkit/solver/result" + "github.com/stretchr/testify/assert" +) + +func TestIsSBOMPredicateType(t *testing.T) { + assert.True(t, IsSBOMPredicateType(intoto.PredicateSPDX)) + assert.True(t, IsSBOMPredicateType(intoto.PredicateCycloneDX)) + assert.False(t, IsSBOMPredicateType("")) + assert.False(t, IsSBOMPredicateType("https://slsa.dev/provenance/v0.2")) + assert.False(t, IsSBOMPredicateType("https://example.com/unknown")) +} + +func TestHasSBOM(t *testing.T) { + t.Run("SPDX", func(t *testing.T) { + res := &result.Result[int]{} + res.Attestations = map[string][]result.Attestation[int]{ + "linux/amd64": { + {InToto: result.InTotoAttestation{PredicateType: intoto.PredicateSPDX}}, + }, + } + assert.True(t, HasSBOM(res)) + }) + + t.Run("CycloneDX", func(t *testing.T) { + res := &result.Result[int]{} + res.Attestations = map[string][]result.Attestation[int]{ + "linux/amd64": { + {InToto: result.InTotoAttestation{PredicateType: intoto.PredicateCycloneDX}}, + }, + } + assert.True(t, HasSBOM(res)) + }) + + t.Run("none", func(t *testing.T) { + res := &result.Result[int]{} + res.Attestations = map[string][]result.Attestation[int]{ + "linux/amd64": { + {InToto: result.InTotoAttestation{PredicateType: "https://slsa.dev/provenance/v0.2"}}, + }, + } + assert.False(t, HasSBOM(res)) + }) + + t.Run("empty", func(t *testing.T) { + res := &result.Result[int]{} + assert.False(t, HasSBOM(res)) + }) +}