11#!/usr/bin/env node
22/**
33 * Per-image SBOM attestation for the release Docker images. For each built image:
4- * cdxgen scans it (OS + npm), enrich-sbom resolves licenses, check-sbom-licenses
4+ * syft scans it (OS + npm), enrich-sbom resolves licenses, check-sbom-licenses
55 * gates the npm components, and the result is attested to the image digest via
66 * cosign — the same mechanism as the VEX/provenance attestations.
77 *
1111 * Usage: node .github/scripts/attest-image-sbom.mjs (run from the repo root)
1212 */
1313import { execFileSync } from 'node:child_process' ;
14+ import { readFileSync } from 'node:fs' ;
1415import path from 'node:path' ;
1516import { fileURLToPath , pathToFileURL } from 'node:url' ;
1617
1718const scriptDir = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
1819const REPO_ROOT = path . resolve ( scriptDir , '..' , '..' ) ;
19- const CDXGEN = path . join ( scriptDir , 'node_modules' , '.bin' , 'cdxgen' ) ;
2020const ENRICH = path . join ( REPO_ROOT , 'scripts' , 'licenses' , 'enrich-sbom.mjs' ) ;
2121const CHECK = path . join ( REPO_ROOT , 'scripts' , 'licenses' , 'check-sbom-licenses.mjs' ) ;
2222const ALLOW_REFS = [
@@ -40,35 +40,78 @@ function run(cmd, args, extraEnv) {
4040 } ) ;
4141}
4242
43+ /**
44+ * The gate only inspects components it can see, so it passes on an SBOM that
45+ * catalogued nothing. Check the shape before signing a near-empty SBOM.
46+ */
47+ export function assertSbomIsUsable ( sbomPath , label ) {
48+ const components = JSON . parse ( readFileSync ( sbomPath , 'utf-8' ) ) . components ?? [ ] ;
49+ const npm = components . filter ( ( c ) => c . purl ?. startsWith ( 'pkg:npm/' ) ) . length ;
50+ if ( npm === 0 ) {
51+ throw new Error ( `${ label } : SBOM has no npm components. The scanner catalogued nothing.` ) ;
52+ }
53+ // Warn rather than block. Downstream scanners want this to pick a distro
54+ // vulnerability feed, but it is not a property these bases are known to
55+ // hold: the runtime base runs `apk del apk-tools` and the distroless runners
56+ // image carries no package manager at all. Blocking on an unverified
57+ // assumption would fail every release rather than catch a bad scan.
58+ if ( ! components . some ( ( c ) => c . type === 'operating-system' ) ) {
59+ console . log (
60+ `::warning::${ label } : SBOM has no operating-system component, so distro CVE feeds cannot be selected for it.` ,
61+ ) ;
62+ }
63+ }
64+
4365function attest ( { label, image, digest } ) {
4466 const ref = `${ image } @${ digest } ` ;
4567 const out = path . join ( REPO_ROOT , `sbom-${ label } .cdx.json` ) ;
4668 console . log ( `::group::SBOM for ${ label } (${ ref } )` ) ;
4769
48- // Pull the (host-arch) image and scan its filesystem: OS packages + npm.
49- run ( 'docker' , [ 'pull' , ref ] ) ;
50- // FETCH_LICENSE=true would make cdxgen call the npm registry for every package
51- // to resolve missing license data. In practice it resolves nothing — packages
52- // without a license field in their tarball also have no license in the registry —
53- // and adds hundreds of sequential HTTP requests. License gaps are covered by
54- // enrich-sbom.mjs (license-overrides.json + first-party detection) below.
55- run (
56- CDXGEN ,
57- [ '-t' , 'docker' , '--no-install-deps' , '--profile' , 'license-compliance' , '--spec-version' , '1.6' , '-o' , out , ref ] ,
58- { CDXGEN_NO_BANNER : '1' } ,
59- ) ;
70+ // finally, so a throw still closes the group — otherwise the error that
71+ // names the failing image renders inside a collapsed section.
72+ try {
73+ // Pull the (host-arch) image and scan its filesystem: OS packages + npm.
74+ run ( 'docker' , [ 'pull' , ref ] ) ;
75+ // `docker:` pins the scan to the image just pulled. A bare ref lets syft's
76+ // own provider order decide, and it may resolve the multi-arch index from
77+ // the registry instead — describing a different manifest than the one
78+ // cosign then attests to.
79+ // syft reads licenses from the LICENSE files on disk, so this scan makes no
80+ // registry requests. `-file` excludes its per-file catalogue, ~4000 entries.
81+ run ( 'syft' , [
82+ `docker:${ ref } ` ,
83+ '-o' ,
84+ `cyclonedx-json@1.6=${ out } ` ,
85+ '--select-catalogers' ,
86+ '-file' ,
87+ '-q' ,
88+ ] ) ;
6089
61- // Resolve first-party + override licenses (lenient: this image holds only a
62- // subset of the npm closure, so absent overrides are not stale pins) and drop
63- // cdxgen filesystem-scan phantoms.
64- run ( process . execPath , [ ENRICH , out , '--lenient-config' , '--drop-phantom-npm' ] ) ;
90+ // Resolve first-party + override licenses (lenient: this image holds only a
91+ // subset of the npm closure, so absent overrides are not stale pins) and drop
92+ // scanner filesystem phantoms.
93+ run ( process . execPath , [ ENRICH , out , '--lenient-config' , '--drop-phantom-npm' ] ) ;
6594
66- // Release-blocking gate, scoped to npm — OS packages carry upstream-distro
67- // license strings we don't control, so they're inventoried but not gated.
68- run ( process . execPath , [ CHECK , out , ...ALLOW_REFS , '--enforce-prefix=pkg:npm/' ] ) ;
95+ // Release-blocking gate, scoped to npm — OS packages carry upstream-distro
96+ // license strings we don't control, so they're inventoried but not gated.
97+ run ( process . execPath , [ CHECK , out , ...ALLOW_REFS , '--enforce-prefix=pkg:npm/' ] ) ;
98+ assertSbomIsUsable ( out , label ) ;
6999
70- run ( 'cosign' , [ 'attest' , '--yes' , '--type' , 'cyclonedx' , '--predicate' , out , ref ] ) ;
71- console . log ( '::endgroup::' ) ;
100+ // --replace, so re-running after a mid-loop failure does not leave the
101+ // digest carrying two CycloneDX attestations.
102+ run ( 'cosign' , [
103+ 'attest' ,
104+ '--yes' ,
105+ '--replace' ,
106+ '--type' ,
107+ 'cyclonedx' ,
108+ '--predicate' ,
109+ out ,
110+ ref ,
111+ ] ) ;
112+ } finally {
113+ console . log ( '::endgroup::' ) ;
114+ }
72115}
73116
74117function main ( ) {
@@ -77,7 +120,20 @@ function main() {
77120 console . log ( 'No images with digests to attest — skipping.' ) ;
78121 return ;
79122 }
80- for ( const target of targets ) attest ( target ) ;
123+ // Attempt every image, then report. Aborting on the first failure leaves the
124+ // later images silently unattested and hides whether they would have passed.
125+ const failed = [ ] ;
126+ for ( const target of targets ) {
127+ try {
128+ attest ( target ) ;
129+ } catch ( err ) {
130+ failed . push ( `${ target . label } : ${ err . message } ` ) ;
131+ console . log ( `::error title=SBOM attestation::${ target . label } : ${ err . message } ` ) ;
132+ }
133+ }
134+ if ( failed . length > 0 ) {
135+ throw new Error ( `${ failed . length } of ${ targets . length } image(s) failed:\n ${ failed . join ( '\n ' ) } ` ) ;
136+ }
81137}
82138
83139if ( process . argv [ 1 ] && import . meta. url === pathToFileURL ( process . argv [ 1 ] ) . href ) {
0 commit comments