Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
testSBOMScan,
testSBOMScanSingleRef,
testSBOMSupplements,
testSBOMCycloneDX,
testMultipleCacheExports,
testMountStubsDirectory,
testMountStubsTimestamp,
Expand Down Expand Up @@ -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 <<EOF > $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())
Expand Down
19 changes: 12 additions & 7 deletions docs/attestations/sbom-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<scan>.spdx.json`
SBOM data. Scanners should write their SBOMs to
`$BUILDKIT_SCAN_DESTINATION/<scan>.spdx.json` for SPDX format or
`$BUILDKIT_SCAN_DESTINATION/<scan>.cdx.json` for CycloneDX format,
where `<scan>` 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.
Expand All @@ -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)

Expand All @@ -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/<scan>.spdx.json`, similar to above.
to `$BUILDKIT_SCAN_DESTINATION/<scan>.spdx.json` (or `.cdx.json` for
CycloneDX), similar to above.

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

Expand Down
13 changes: 11 additions & 2 deletions frontend/attestations/sbom/sbom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
54 changes: 54 additions & 0 deletions frontend/attestations/sbom/sbom_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}