Skip to content

Commit 03d27b7

Browse files
shortstackedclaude
andauthored
ci: Scan release images with syft instead of cdxgen (no-changelog) (#37344)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1e4edb2 commit 03d27b7

15 files changed

Lines changed: 683 additions & 141 deletions

File tree

.github/WORKFLOWS.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -692,10 +692,43 @@ Supply chain security ensures artifacts haven't been tampered with. We provide t
692692

693693
### SBOM
694694

695-
- **Runs on:** release-publish
696-
- **Format:** CycloneDX JSON
697-
- **Signing:** GitHub Attestation API
698-
- **Attached to:** GitHub Release
695+
There are two, with different subjects and different consumers. They are not duplicates.
696+
697+
| | Release SBOM | Image SBOM |
698+
|---|---|---|
699+
| **Job** | `generate-and-attach-sbom` (`sbom-generation-callable.yml`) | `sbom-attestation` (`docker-build-push.yml`) |
700+
| **Scans** | the deployed npm closure in `compiled/` (`cdxgen -t pnpm`) | each pushed image, by digest (`syft`) |
701+
| **Covers** | npm only | OS packages **and** npm, as laid down in the image |
702+
| **Signing** | GitHub Attestation API, subject `./package.json` | `cosign attest`, subject = image digest |
703+
| **Output** | `sbom-source.cdx.json`, `THIRD_PARTY_LICENSES.md`, `vex.openvex.json` on the GitHub Release | attestation in the registry beside the image |
704+
| **Consumer** | humans — legal/license compliance; backs `/third-party-licenses` | machines — `cosign verify-attestation`, admission control |
705+
706+
Format is CycloneDX JSON 1.6 for both. Each pipeline pins the schema version, so a
707+
scanner upgrade cannot change the shape of a signed artifact without a visible diff.
708+
709+
The two use different scanners on purpose. The release SBOM runs `cdxgen -t pnpm` over the
710+
resolved pnpm closure with `FETCH_LICENSE=true`, because a lockfile scan has no package files
711+
to read licenses from. The image SBOM runs `syft` over the pushed image, which resolves
712+
licenses from the LICENSE files on disk and so needs no network at all.
713+
714+
The image job used to run `cdxgen -t docker --profile license-compliance`. That profile sets
715+
`FETCH_LICENSE=true` and nothing else, so it made one sequential npm registry call per
716+
component — roughly 3,700 per release, about half the job's runtime. syft resolves the same
717+
licenses locally in a fraction of the time, and catalogues more of the image besides.
718+
719+
A/B any scanner change against the current output before shipping it. The gate only enforces
720+
`pkg:npm/`, so a change can silently degrade PyPI or OS license coverage while CI stays green.
721+
Compare the licenses resolved per component, not just the component counts.
722+
723+
`enrich-sbom.mjs --drop-phantom-npm` removes scan artefacts that would otherwise assert
724+
components the image does not contain: nested test/fixture `package.json` and `exports`
725+
subpaths. It reads the component's source path from either scanner's property name (`SrcFile`
726+
for cdxgen, `syft:location:0:path` for syft) and treats syft's `version: "UNKNOWN"` the same as
727+
a missing version.
728+
729+
Packages whose license cannot be resolved from disk go in
730+
`scripts/licenses/license-overrides.json` with a verified `source` citation — the upstream
731+
LICENSE file, not registry metadata.
699732

700733
### SLSA L3 Provenance
701734

.github/actions/setup-nodejs/action.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ runs:
7373
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
7474
with:
7575
node-version: ${{ inputs.node-version }}
76-
cache: 'pnpm'
76+
# A job that installs nothing must not participate in the pnpm-store
77+
# cache. Its post step would save an empty store, and cache keys are
78+
# write-once — so if it lost the race to a real installer, every other
79+
# job on that key would restore nothing and silently re-download.
80+
cache: ${{ inputs.install-command != '' && 'pnpm' || '' }}
7781
cache-dependency-path: ${{ inputs.cache-dependency-path }}
7882

7983
# Fail fast if setup-node silently fell through to the runner's baked-in

.github/scripts/attest-image-sbom.mjs

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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
*
@@ -11,12 +11,12 @@
1111
* Usage: node .github/scripts/attest-image-sbom.mjs (run from the repo root)
1212
*/
1313
import { execFileSync } from 'node:child_process';
14+
import { readFileSync } from 'node:fs';
1415
import path from 'node:path';
1516
import { fileURLToPath, pathToFileURL } from 'node:url';
1617

1718
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
1819
const REPO_ROOT = path.resolve(scriptDir, '..', '..');
19-
const CDXGEN = path.join(scriptDir, 'node_modules', '.bin', 'cdxgen');
2020
const ENRICH = path.join(REPO_ROOT, 'scripts', 'licenses', 'enrich-sbom.mjs');
2121
const CHECK = path.join(REPO_ROOT, 'scripts', 'licenses', 'check-sbom-licenses.mjs');
2222
const 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+
4365
function 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

74117
function 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

83139
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {

.github/scripts/attest-image-sbom.test.mjs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, it } from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { parseTargets } from './attest-image-sbom.mjs';
3+
import { mkdtempSync, writeFileSync } from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { assertSbomIsUsable, parseTargets } from './attest-image-sbom.mjs';
47

58
describe('parseTargets', () => {
69
it('builds a target per image when both ref and digest are present', () => {
@@ -35,3 +38,47 @@ describe('parseTargets', () => {
3538
assert.deepEqual(parseTargets({}), []);
3639
});
3740
});
41+
42+
describe('assertSbomIsUsable', () => {
43+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'sbom-assert-'));
44+
const write = (name, components) => {
45+
const p = path.join(tmp, name);
46+
writeFileSync(p, JSON.stringify({ components }));
47+
return p;
48+
};
49+
const OS = { type: 'operating-system', name: 'alpine', version: '3.24' };
50+
51+
it('accepts an SBOM with npm components and an operating system', () => {
52+
assert.doesNotThrow(() =>
53+
assertSbomIsUsable(write('ok.json', [{ purl: 'pkg:npm/a@1' }, OS]), 'n8n'),
54+
);
55+
});
56+
57+
it('rejects an SBOM the scanner failed to populate', () => {
58+
const p = write('empty.json', [{ purl: 'pkg:apk/alpine/busybox@1.0' }, OS]);
59+
assert.throws(() => assertSbomIsUsable(p, 'n8n'), /no npm components/);
60+
});
61+
62+
// Warns rather than throws: the distroless runners image carries no package
63+
// manager and the runtime base strips apk-tools, so an absent OS component
64+
// is not known to be a fault. Blocking on it would fail every release.
65+
it('warns but accepts an SBOM with no operating-system component', () => {
66+
const logged = [];
67+
const original = console.log;
68+
console.log = (msg) => logged.push(String(msg));
69+
try {
70+
assert.doesNotThrow(() =>
71+
assertSbomIsUsable(write('no-os.json', [{ purl: 'pkg:npm/a@1' }]), 'runners'),
72+
);
73+
} finally {
74+
console.log = original;
75+
}
76+
assert.ok(logged.some((l) => /^::warning::runners: .*no operating-system component/.test(l)));
77+
});
78+
79+
it('names the image in the failure so a four-image run says which one broke', () => {
80+
assert.throws(() => assertSbomIsUsable(write('named.json', [OS]), 'runners-distroless'), {
81+
message: /^runners-distroless:/,
82+
});
83+
});
84+
});

.github/test-metrics/e2e-impact-map.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

.github/workflows/docker-build-push.yml

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,18 @@ jobs:
565565
- name: Install Cosign
566566
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
567567

568+
# An empty document still gets signed and attached, and trivy still loads
569+
# it — so a statement lost to a typo'd purl or a stale pin degrades to a
570+
# silent no-op. Warn rather than fail: empty is a legitimate state when
571+
# nothing is currently suppressed.
572+
- name: Report VEX statement count
573+
run: |
574+
COUNT=$(node -e "process.stdout.write(String((require('./security/vex.openvex.json').statements ?? []).length))")
575+
echo "VEX statements: $COUNT"
576+
if [ "$COUNT" -eq 0 ]; then
577+
echo "::warning::VEX document has no statements - every published image will carry an attestation that suppresses nothing."
578+
fi
579+
568580
- name: Login to GHCR
569581
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
570582
with:
@@ -605,11 +617,11 @@ jobs:
605617
${{ needs.create_multi_arch_manifest.outputs.runners_distroless_image }}@${{ needs.create_multi_arch_manifest.outputs.runners_distroless_digest }}
606618
607619
# SBOM Attestation - one enriched, license-gated CycloneDX SBOM per image.
608-
# cdxgen scans the image (OS + npm), enrich-sbom resolves first-party + override
609-
# licenses, check-sbom-licenses gates the npm components, and the result is
610-
# attested to the image digest via cosign (same mechanism as VEX/provenance).
611-
# This replaces BuildKit's `sbom: true` so the image carries the same resolved
612-
# license picture as the release SBOM rather than the un-enriched syft output.
620+
# syft scans the image for OS and npm packages. enrich-sbom resolves first-party
621+
# and override licenses. check-sbom-licenses gates the npm components. cosign then
622+
# attests the result to the image digest, as it does for VEX and provenance.
623+
# The build sets oci-mediatypes on the exporter to get the OCI index format,
624+
# so BuildKit emits no SBOM. This job supplies the license data.
613625
sbom-attestation:
614626
name: SBOM Attestation
615627
needs:
@@ -637,13 +649,29 @@ jobs:
637649
- name: Checkout
638650
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
639651

640-
- name: Setup Node.js and install SBOM tooling
652+
# Nothing to install: syft replaced cdxgen, and the two license scripts have
653+
# no dependencies. This step only pins the Node version.
654+
- name: Setup Node.js
641655
uses: ./.github/actions/setup-nodejs
642656
with:
643657
build-command: ''
644-
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
658+
install-command: ''
659+
# Scope the cache key: the default is the root lockfile, which would
660+
# restore the whole workspace pnpm store into a job that never runs pnpm.
645661
cache-dependency-path: .github/scripts/pnpm-lock.yaml
646662

663+
# Pin the scanner, not just the action. cdxgen was pinned in a lockfile;
664+
# syft's default version rides the action release, so a SHA bump would
665+
# silently change what produces the signed SBOM. enrich-sbom.mjs depends
666+
# on two syft specifics — the `syft:location:0:path` property name and the
667+
# `UNKNOWN` version sentinel — so re-A/B against a real image when bumping.
668+
- name: Install Syft
669+
uses: anchore/sbom-action/download-syft@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11
670+
with:
671+
# Matches what this action SHA already defaults to, so pinning it
672+
# records the current behaviour rather than changing it.
673+
syft-version: v1.38.2
674+
647675
- name: Install Cosign
648676
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
649677

packages/frontend/editor-ui/eslint.config.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,5 +366,19 @@ export default defineConfig(
366366
'n8n-local-rules/no-dynamic-regexp': 'off',
367367
},
368368
},
369+
{
370+
// The CodeMirror TypeScript language service runs in a browser web worker, so
371+
// Vite bundles `typescript` and `@typescript/vfs` into it and nothing resolves
372+
// them from node_modules at runtime. They stay devDependencies to keep the
373+
// ~24MB compiler out of the server image, which installs editor-ui's
374+
// production closure via packages/cli.
375+
files: ['src/features/shared/editors/plugins/codemirror/typescript/**'],
376+
rules: {
377+
'import-x/no-extraneous-dependencies': [
378+
'error',
379+
{ devDependencies: true, optionalDependencies: false },
380+
],
381+
},
382+
},
369383
...oxlint.buildFromOxlintConfigFile('./.oxlintrc.json'),
370384
);

packages/frontend/editor-ui/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@
6363
"@replit/codemirror-indentation-markers": "^6.5.3",
6464
"@sentry/vue": "catalog:frontend",
6565
"@types/semver": "catalog:",
66-
"@typescript/vfs": "^1.6.0",
6766
"@vscode/markdown-it-katex": "^1.1.2",
6867
"@vue-flow/background": "1.3.2",
6968
"@vue-flow/controls": "1.1.3",
@@ -108,9 +107,7 @@
108107
"sanitize-html": "catalog:",
109108
"semver": "catalog:",
110109
"stream-browserify": "^3.0.0",
111-
"stylelint": "catalog:",
112110
"timeago.js": "^4.0.2",
113-
"typescript": "catalog:",
114111
"uuid": "catalog:",
115112
"v-code-diff": "^1.13.1",
116113
"v3-infinite-loading": "^1.2.2",
@@ -162,6 +159,7 @@
162159
"@types/markdown-it": "catalog:",
163160
"@types/sanitize-html": "catalog:",
164161
"@types/uuid": "catalog:",
162+
"@typescript/vfs": "^1.6.0",
165163
"@vitejs/plugin-legacy": "^8.0.0",
166164
"@vitejs/plugin-vue": "catalog:frontend",
167165
"@vitest/coverage-v8": "catalog:",
@@ -176,8 +174,9 @@
176174
"oxlint": "catalog:",
177175
"oxlint-tsgolint": "catalog:",
178176
"sass-embedded": "catalog:",
179-
"stylelint": "catalog:",
180177
"storybook": "catalog:storybook",
178+
"stylelint": "catalog:",
179+
"typescript": "catalog:",
181180
"unplugin-icons": "catalog:frontend",
182181
"vite": "catalog:",
183182
"vite-plugin-node-polyfills": "^0.25.0",

0 commit comments

Comments
 (0)