ci: Scan release images with syft instead of cdxgen (no-changelog)

Merged
SH
2mo ago
+683-141

Stacked on #37356 (docker bake). That PR merges first; GitHub will retarget this to master.

Summary

Replaces cdxgen with syft for the release image SBOM attestation. Verified end to end on a real CI run.

BeforeAfter
SBOM Attestation job22.0 min5.7 min
Scan time, 4 images1124s135s (8.3×)
npm registry requests~3,7000

~16 min off the release critical path. Combined with the parent PR (17m09s ->
4m48s on the build job), the two together take the release path from ~39 min to
~10.5 min. Baseline: run 33151088330 (n8n@2.37.4). After: run 33200937665.

attest-image-sbom.mjs passed --profile license-compliance to cdxgen. That profile sets FETCH_LICENSE=true and nothing else, so it made one sequential npm registry call per component — the exact thing the comment above it claimed was disabled. syft reads licenses from the LICENSE files already on disk, so it needs no network.

The fetch could not simply be dropped: without it, 13 npm packages lose their license and the release-blocking gate fails.

Accuracy improves

All four images pass the gate with 0 licenses lost vs the cdxgen baseline, and syft catalogues more:

n8n                 1886 npm enforced  ✅   (cdxgen: 1843)
n8n-pc              2030 npm enforced  ✅   (cdxgen: 1855)
runners              483 npm enforced  ✅   (cdxgen:  459)
runners-distroless   435 npm enforced  ✅   (cdxgen:  432)
  • Finds @n8n/task-runner — a first-party package we ship that was missing from its own attested SBOM.
  • Emits an operating-system component, which cdxgen did not. Scanners need it to select the distro vulnerability feed, so trivy sbom against every SBOM we have published has silently skipped Alpine CVEs. Confirmed on the artifact this run published: it now reports alpine 3.24 and Node.js.
  • Drops 2191 pkg:generic entries that were config files (/etc/fstab, /etc/hostname) catalogued as components, and 21 Python packages declared in pyproject.toml but never installed.

Reviewer note — apk count drops 87 → 69, and that is correct. The apk database holds exactly 69 entries. cdxgen's extra 18 were installed subpackages collapsed onto their origin names (libssl3/libcrypto3 reported as openssl). syft names what is installed and keeps the origin in the purl (?upstream=openssl), so CVE mapping still resolves. Checked that this does not break the purl join with the VEX attestation on the same digest.

Also here

  • 3 license overrides, each verified against the upstream LICENSE file rather than registry metadata: qrcode-terminal (Apache-2.0), dreamopt (MIT), @getzep/zep-cloud (Apache-2.0). Two have no license field in the registry at all.
  • Cross-scanner phantom filter. syft names the source path syft:location:0:path (not SrcFile) and marks phantoms version: "UNKNOWN". Without both fixes the filter matches nothing and 23 phantoms reach the gate. enrich-sbom.mjs now logs which purls it drops.
  • assertSbomIsUsable() — the gate only inspects components it can see, so it passes on an SBOM that catalogued nothing. Now requires npm components and an operating-system component before cosign runs.
  • Obsolete VEX statements removed. All three graphicsmagick statements pinned 1.3.45-r0; the image ships 1.3.47-r0 and NVD gives versionEndExcluding 1.3.46 for all three CVEs, so they suppressed nothing. CVE-2025-27796 was recorded as affected, telling consumers the released image carried a WPG heap-overflow patched two releases earlier.
  • Docs. security/sca/README.md claimed "0 unlicensed", which was already untrue. Rewritten to lead with the SPDX gate and document the OS-coverage trap above.

Verification

  • Real CI run green; cosign attest succeeded against all four digests (4 Rekor entries)
  • Pulled the published attestation back with the documented consumer command: specVersion 1.6, operating-system: alpine 3.24, gate passes 1886/1886
  • 509 tests, actionlint and biome clean
  • Reviewed by pr-council (correctness, security, tests, integration); findings fixed in e6975c64af

Notes

  • sbom: true on the buildx builds is removed by the parent PR (#37356). It was
    restored in 320d454e62 for the OCI manifest index format; that format now comes
    from oci-mediatypes on the exporter instead, without the syft scan this job
    replaces anyway.
  • The release SBOM job is untouched; a lockfile scan has no package files to read licenses from, so it still needs cdxgen with FETCH_LICENSE=true.
  • Docker pulls (154s) are now the largest cost in the job, not scanning. Next lever for DEVP-906.
  • The verification run built amd64 only, and its Trivy/VEX jobs were skipped (they carry the same stable|rc|nightly gate). Neither is on the SBOM path.
  • SBOM shape changes — consumers diffing attestations across releases will see a one-time discontinuity.

https://linear.app/n8n/issue/DEVP-907

🤖 Generated with Claude Code