diff --git a/scripts/licenses/__fixtures__/license-overrides.json b/scripts/licenses/__fixtures__/license-overrides.json new file mode 100644 index 00000000000..81afbda6203 --- /dev/null +++ b/scripts/licenses/__fixtures__/license-overrides.json @@ -0,0 +1,25 @@ +{ + "_comment": "Frozen fixture config, paired with sample.cdx.json. It holds only what the behaviour suites (pipeline.test.mjs, properties.test.mjs) exercise and must NOT be kept in sync with the shipped scripts/licenses/license-overrides.json — that coupling is what made a dependency removal break these tests. The shipped file is validated by render-licenses-md.test.mjs instead.", + "overrides": { + "pkg:npm/binascii@0.0.2": { + "license": "MIT", + "source": "Fixture only. A PURL-pinned override for a component the fixture SBOM declares with no license at all." + } + }, + "byName": { + "wa-sqlite": { + "license": "MIT", + "source": "Fixture only. The second license-less component in sample.cdx.json, resolved version-agnostically." + }, + "ssh2": { + "license": "MIT", + "source": "Fixture only. The byName version-drift case in properties.test.mjs (SBOM version differs from any pin)." + } + }, + "elections": { + "pkg:npm/jszip@3.10.1": { + "elected": "MIT", + "source": "Fixture only. The dual-license (MIT OR GPL-3.0-or-later) election case." + } + } +} diff --git a/scripts/licenses/enrich-sbom.mjs b/scripts/licenses/enrich-sbom.mjs index c43470c0a95..9b389f8ad44 100644 --- a/scripts/licenses/enrich-sbom.mjs +++ b/scripts/licenses/enrich-sbom.mjs @@ -26,8 +26,10 @@ * test/fixture/exports-subpath package.json) — only relevant for image scans. * * Usage: node enrich-sbom.mjs [output-path] [--license-file=] - * [--lenient-config] [--drop-phantom-npm] + * [--overrides=] [--lenient-config] + * [--drop-phantom-npm] * output-path defaults to (in-place). + * --overrides defaults to license-overrides.json next to this script. */ import { readFile, writeFile, readdir } from 'node:fs/promises'; @@ -48,9 +50,9 @@ const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage'] export const FIRST_PARTY_LICENSE_REF = 'LicenseRef-n8n-sustainable-use'; export const ELECTED_PROPERTY = 'cdx:license:elected'; -export async function loadLicenseConfig() { +export async function loadLicenseConfig(overridesPath = OVERRIDES_PATH) { try { - const raw = await readFile(OVERRIDES_PATH, 'utf-8'); + const raw = await readFile(overridesPath, 'utf-8'); const parsed = JSON.parse(raw); return { overrides: parsed.overrides ?? {}, @@ -290,6 +292,9 @@ async function main() { const args = process.argv.slice(2); const positional = args.filter((a) => !a.startsWith('--')); const licenseFileArg = args.find((a) => a.startsWith('--license-file=')); + // Lets a test drive the chain against a frozen fixture config instead of the + // shipped one, so a dependency change can't break a behaviour test. + const overridesArg = args.find((a) => a.startsWith('--overrides=')); // A per-image scan contains only the npm subset present in that image, so most // overrides/elections won't match — that's expected, not a stale pin. Lenient // mode warns instead of failing. The full release-closure run stays strict. @@ -302,7 +307,7 @@ async function main() { const sbomPath = positional[0]; if (!sbomPath) { console.error( - 'Usage: enrich-sbom.mjs [output-path] [--license-file=] [--lenient-config] [--drop-phantom-npm]', + 'Usage: enrich-sbom.mjs [output-path] [--license-file=] [--overrides=] [--lenient-config] [--drop-phantom-npm]', ); process.exit(1); } @@ -310,9 +315,12 @@ async function main() { const licenseFile = licenseFileArg ? licenseFileArg.slice('--license-file='.length) : DEFAULT_LICENSE_FILE; + const overridesPath = overridesArg + ? overridesArg.slice('--overrides='.length) + : OVERRIDES_PATH; const sbom = JSON.parse(await readFile(sbomPath, 'utf-8')); - const { overrides, byName, elections } = await loadLicenseConfig(); + const { overrides, byName, elections } = await loadLicenseConfig(overridesPath); const validIds = await loadSpdxIds(); const firstPartyOsi = await buildFirstPartyOsiMap(DEFAULT_PACKAGES_DIR, validIds); diff --git a/scripts/licenses/license-overrides.json b/scripts/licenses/license-overrides.json index fefd0a5d7f3..ec7c58042fb 100644 --- a/scripts/licenses/license-overrides.json +++ b/scripts/licenses/license-overrides.json @@ -17,10 +17,6 @@ "license": "Python-2.0", "source": "https://github.com/ewoudenberg/difflib.js — package.json declares legacy licenses[] array with PSF type, http://docs.python.org/license.html" }, - "pkg:npm/binascii@0.0.2": { - "license": "MIT", - "source": "compiled/node_modules/binascii/LICENSE — verbatim MIT, no license field in package.json" - }, "pkg:npm/busboy@1.6.0": { "license": "MIT", "source": "compiled/node_modules/busboy/LICENSE — package.json uses legacy licenses[] array" diff --git a/scripts/licenses/pipeline.test.mjs b/scripts/licenses/pipeline.test.mjs index f986b2c48e1..2bde715e853 100644 --- a/scripts/licenses/pipeline.test.mjs +++ b/scripts/licenses/pipeline.test.mjs @@ -1,7 +1,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdtemp, rm, readFile, copyFile } from 'node:fs/promises'; +import { mkdtemp, rm, readFile, copyFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -14,9 +14,14 @@ import { fileURLToPath } from 'node:url'; // change that makes a downstream stage mis-parse. (render-licenses-md is a // full-closure renderer covered end-to-end by its own suite; it intentionally // rejects a subset fixture via its unused-override check, so it's excluded here.) +// +// Both fixtures are frozen: the SBOM and the override config it needs are a +// matched pair, so the chain runs strict and no dependency change can break it. +// The *shipped* license-overrides.json is validated by render-licenses-md.test.mjs. const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(scriptDir, '__fixtures__', 'sample.cdx.json'); +const FIXTURE_OVERRIDES = path.join(scriptDir, '__fixtures__', 'license-overrides.json'); const ENRICH = path.join(scriptDir, 'enrich-sbom.mjs'); const CHECK = path.join(scriptDir, 'check-sbom-licenses.mjs'); const ALLOW_REFS = [ @@ -45,9 +50,9 @@ describe('license-generation chain (real CLIs end-to-end)', () => { }); it('enriches the SBOM (exit 0) and writes valid JSON resolving every path', async () => { - // --lenient-config: the fixture is a representative subset, so overrides for - // packages not in it are expected-absent, not stale pins (same as an image scan). - const r = run(ENRICH, [raw, enriched, '--lenient-config']); + // No --lenient-config: every entry in the fixture config matches a fixture + // component, so the strict stale-config gate must stay quiet. + const r = run(ENRICH, [raw, enriched, `--overrides=${FIXTURE_OVERRIDES}`]); assert.equal(r.status, 0, r.stderr); const sbom = JSON.parse(await readFile(enriched, 'utf-8')); @@ -73,4 +78,19 @@ describe('license-generation chain (real CLIs end-to-end)', () => { assert.equal(r.status, 0, r.stderr); assert.match(r.stderr, /jszip/); // surfaced as a dual-license warning, not a failure }); + + it('fails on a stale override pin (exit 3) unless --lenient-config', async () => { + const stale = path.join(dir, 'stale-overrides.json'); + await writeFile( + stale, + JSON.stringify({ overrides: { 'pkg:npm/not-in-the-sbom@1.0.0': { license: 'MIT' } } }), + ); + const args = [raw, path.join(dir, 'stale.cdx.json'), `--overrides=${stale}`]; + + const strict = run(ENRICH, args); + assert.equal(strict.status, 3, strict.stderr); + assert.match(strict.stderr, /not-in-the-sbom/); + + assert.equal(run(ENRICH, [...args, '--lenient-config']).status, 0); + }); }); diff --git a/scripts/licenses/properties.test.mjs b/scripts/licenses/properties.test.mjs index a79996e946a..fa98acc9186 100644 --- a/scripts/licenses/properties.test.mjs +++ b/scripts/licenses/properties.test.mjs @@ -27,6 +27,9 @@ import { isFirstParty } from './render-licenses-md.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PACKAGES_DIR = path.resolve(HERE, '..', '..', 'packages'); +// Frozen fixture config, not the shipped one: these are rule assertions over the +// component classes below, so they must not move when a dependency does. +const FIXTURE_OVERRIDES = path.join(HERE, '__fixtures__', 'license-overrides.json'); const ALLOW = new Set([FIRST_PARTY_LICENSE_REF]); const NPM = ['pkg:npm/']; @@ -45,7 +48,7 @@ const cfg = () => ({ before(async () => { spdxIds = await loadSpdxIds(); - ({ overrides, byName, elections } = await loadLicenseConfig()); + ({ overrides, byName, elections } = await loadLicenseConfig(FIXTURE_OVERRIDES)); firstPartyOsi = await buildFirstPartyOsiMap(PACKAGES_DIR, spdxIds); }); @@ -125,7 +128,7 @@ const RESOLVABLE = [ licenses: [{ expression: 'SEE LICENSE IN LICENSE.md' }], }, // first-party ref { group: '@n8n', name: 'tournament', version: '9.9.9', purl: 'pkg:npm/%40n8n/tournament@9.9.9' }, // first-party OSI (Apache-2.0) - { name: 'binascii', version: '0.0.2', purl: 'pkg:npm/binascii@0.0.2' }, // purl-pinned override + { name: 'binascii', version: '0.0.2', purl: 'pkg:npm/binascii@0.0.2' }, // purl-pinned override (fixture config) { name: 'ssh2', version: '9.9.9', diff --git a/scripts/licenses/render-licenses-md.test.mjs b/scripts/licenses/render-licenses-md.test.mjs index 44a92093187..934465fd7c2 100644 --- a/scripts/licenses/render-licenses-md.test.mjs +++ b/scripts/licenses/render-licenses-md.test.mjs @@ -360,16 +360,15 @@ describe('renderSbom — edge cases', () => { }); it('all documented overrides resolve to zero unresolved (end-to-end)', async () => { - const purls = [ - 'pkg:npm/%40ewoudenberg/difflib@0.1.0', - 'pkg:npm/binascii@0.0.2', - 'pkg:npm/busboy@1.6.0', - 'pkg:npm/js-nacl@1.4.0', - 'pkg:npm/streamsearch@1.1.0', - 'pkg:npm/nub@0.0.0', - 'pkg:npm/xml-escape@1.1.0', - 'pkg:npm/duck@0.1.12', - ]; + // Derived from the shipped config, never hand-listed: a copy here would + // break on every override the repo adds or drops. This is the one suite + // that reads the real file — it asserts each documented override yields a + // resolvable license, and (via unusedOverrides) that the matcher still + // recognises every key shape, including percent-encoded scopes. + const overridesModule = await loadOverrides(); + const purls = Object.keys(overridesModule); + assert.ok(purls.length > 0, 'expected at least one documented override'); + const sbom = { components: purls.map((purl) => { const m = purl.match(/^pkg:npm\/(?:%40([^/]+)\/)?([^@]+)@(.+)$/); @@ -383,7 +382,6 @@ describe('renderSbom — edge cases', () => { }), }; - const overridesModule = await loadOverrides(); const { summary, unresolved, unusedOverrides } = await renderSbom(sbom, overridesModule); assert.equal(unresolved.length, 0, `unresolved: ${unresolved.join(', ')}`); assert.equal(summary.unresolved, 0);