screentinker/scripts/license-check.js
screentinker 3f9459139f
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Licence gate + SBOM (production deps) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Gate licences in CI and publish an SBOM (#282)
The licence audit that found org.json in the APK was run by hand. Nothing stopped the next
transitive dependency arriving the same way, and "we track licences" was a claim rather than
something anyone could check.

TWO GATES, BOTH FAIL CLOSED.

scripts/license-check.js audits the server's npm tree. scripts/android-license-check.js
resolves the real releaseRuntimeClasspath — everything that can enter the APK a customer
installs — and checks it against android/licenses.json, where each entry records the licence
AND the evidence for it. A dependency nobody has recorded fails the build. That is the case
worth catching: org.json reached customers because it arrived transitively and nothing ever
asked what licence it carried.

Denied: AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence. Weak copyleft (LGPL,
MPL, EPL, CDDL) is reported but does not fail — it is a judgement, and the judgement should
be made by someone who knows they are making it. Anything unrecognised fails; a package whose
licence we cannot identify is not one we ship.

⚠️ THE SERVER GATE INSTALLS --omit=dev, AND THAT IS THE POINT. A developer checkout carries
sharp, whose @img/sharp-wasm32 declares LGPL-3.0-or-later. It is a test fixture generator that
never reaches a server, but a scanner pointed at a dev tree reports LGPL and contradicts the
answer we give customers. Auditing the production install is what makes the answer defensible.

SBOM. Every release now publishes screentinker-sbom-<version>.cdx.json — CycloneDX 1.5, every
production dependency with version, purl and licence, generated from a production install. CI
uploads one on every run too. That is what turns the claim into something a customer or an
underwriter can verify themselves.

Neither script takes a dependency: a gate that needs its own supply chain audited is worth
less than one that does not.

Verified by mutation rather than assumed. Injecting GPL-3.0-or-later, AGPL-3.0, the JSON
Licence, SSPL-1.0, and a package with no licence field each fail the server gate; MIT and
LGPL pass (LGPL reported). Removing the org.json exclusion fails the Android gate by name;
dropping a group from the policy fails it as unrecorded. Both restored, both green.

Found and fixed while building it: npm ls exits non-zero for any tree problem — an extraneous
package is enough — which made the gate abort instead of auditing. It now reads the listing
either way and only aborts on genuinely empty output.

docs/licensing.md records the policy, how to run the gates, and the dev-vs-production trap.

1676/1676 pass.
2026-08-14 15:36:38 -05:00

185 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/*
* Licence gate for the dependencies that actually SHIP.
*
* node scripts/license-check.js [--sbom <path>] [--include-dev]
*
* Run from a PRODUCTION install (`npm ci --omit=dev`). That is the whole point: a developer
* checkout carries `sharp`, whose `@img/sharp-wasm32` declares LGPL-3.0-or-later. It is a test
* fixture generator, it is devDependencies-only, and it never reaches a server — but a scanner
* pointed at a dev tree reports LGPL and contradicts the answer we give customers. Auditing the
* installed production tree is what makes the answer defensible.
*
* Exits non-zero on anything denied or unresolved, so CI fails before a licence can arrive
* unnoticed through a transitive bump.
*
* No dependencies, deliberately — a gate that needs its own supply chain audited is worth less.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const args = process.argv.slice(2);
const INCLUDE_DEV = args.includes('--include-dev');
const SBOM_OUT = args.includes('--sbom') ? args[args.indexOf('--sbom') + 1] : null;
const SERVER_DIR = path.join(__dirname, '..', 'server');
/* ── policy ───────────────────────────────────────────────────────────────────
* ALLOW: permissive, no distribution obligation beyond keeping the notice.
* DENY: strong/network copyleft, plus licences we will not ship for other reasons.
* Anything matching neither is REVIEW — it fails, and a human decides. Failing closed
* matters more than being clever: the risk is a licence arriving that nobody looked at.
*/
const ALLOW = [
/^MIT$/i, /^MIT-0$/i, /^ISC$/i, /^0BSD$/i, /^BSD-2-Clause$/i, /^BSD-3-Clause$/i,
/^Apache-2\.0$/i, /^BlueOak-1\.0\.0$/i, /^Unlicense$/i, /^CC0-1\.0$/i, /^Python-2\.0$/i,
/^WTFPL$/i, /^Zlib$/i, /^CC-BY-4\.0$/i,
];
const DENY = [
{ re: /\bAGPL/i, why: 'network copyleft — obligations trigger on serving, not distributing' },
{ re: /\bGPL-[123]|\bGPLv[123]|(^|[^L])\bGPL\b/i, why: 'strong copyleft — links into a product we distribute commercially' },
{ re: /\bSSPL/i, why: 'server-side public licence — not OSI-approved, service-scope obligations' },
{ re: /\bCommons-Clause/i, why: 'commercial-use restriction' },
{ re: /\bBUSL|Business Source/i, why: 'source-available, not open source' },
{ re: /Good, not Evil|^JSON$/i, why: 'JSON Licence — field-of-use clause, Apache Category X, non-free per Debian/Fedora' },
];
// Weak copyleft: file- or library-scoped, generally fine when merely linked, but never silently.
const REVIEW = [/\bLGPL/i, /\bMPL/i, /\bEPL/i, /\bCDDL/i, /\bOSL/i, /\bEUPL/i, /\bCPL/i];
/*
* Packages that ship a real licence FILE but declare no `license` field in package.json.
* Each entry records what was read off disk, so this is a documented finding rather than a
* blanket exemption. Re-verify if the version changes.
*/
const EXCEPTIONS = {
'exif-parser': { license: 'MIT', evidence: 'LICENSE.md — "The MIT License"' },
'thirty-two': { license: 'MIT', evidence: 'LICENSE.txt — MIT, Copyright (c) 2011 Chris Umbel' },
'screentinker': { license: 'MIT', evidence: 'repository root LICENSE' },
};
function classify(id) {
if (!id) return { verdict: 'UNKNOWN' };
for (const d of DENY) if (d.re.test(id)) return { verdict: 'DENY', why: d.why };
// A GPL-with-exception (Classpath, linking) is not the thing we are guarding against.
if (/WITH .*exception/i.test(id)) return { verdict: 'REVIEW', why: 'copyleft with a linking exception' };
for (const r of REVIEW) if (r.test(id)) return { verdict: 'REVIEW', why: 'weak copyleft' };
// Composite expressions: every term must be allowed.
const terms = id.split(/\s+(?:OR|AND)\s+|[()]/).map(s => s.trim()).filter(Boolean);
if (terms.length && terms.every(t => ALLOW.some(a => a.test(t)))) return { verdict: 'ALLOW' };
return { verdict: 'UNKNOWN' };
}
function readLicense(dir) {
let pkg;
try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; }
let lic = pkg.license;
if (lic && typeof lic === 'object') lic = lic.type;
if (!lic && Array.isArray(pkg.licenses)) lic = pkg.licenses.map(l => l.type || l).join(' OR ');
return { name: pkg.name, version: pkg.version, license: lic || null };
}
/*
* `npm ls` exits non-zero for any tree problem — an extraneous package, a peer-dep complaint —
* while still printing the full listing. Treating that as fatal would turn a routine tree quirk
* into an unexplained CI failure, and worse, a licence check that never actually ran. Read the
* output either way; a genuinely empty result is the only thing worth aborting on.
*/
function listInstalled() {
const argv = ['ls', ...(INCLUDE_DEV ? [] : ['--omit=dev']), '--all', '--parseable'];
const opts = { cwd: SERVER_DIR, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' };
try {
return execFileSync('npm', argv, opts);
} catch (e) {
if (e.stdout && e.stdout.trim()) return e.stdout;
console.error('npm ls produced no output:\n' + (e.stderr || e.message));
process.exit(2);
}
}
const dirs = listInstalled().split('\n').filter(Boolean);
const pkgs = [];
const seen = new Set();
for (const d of dirs) {
const info = readLicense(d);
if (!info || !info.name) continue;
const key = `${info.name}@${info.version}`;
if (seen.has(key)) continue;
seen.add(key);
let license = info.license;
let note = null;
if (!license && EXCEPTIONS[info.name]) {
license = EXCEPTIONS[info.name].license;
note = `no license field; ${EXCEPTIONS[info.name].evidence}`;
}
pkgs.push({ ...info, license, note, ...classify(license) });
}
const denied = pkgs.filter(p => p.verdict === 'DENY');
const review = pkgs.filter(p => p.verdict === 'REVIEW');
const unknown = pkgs.filter(p => p.verdict === 'UNKNOWN');
const counts = pkgs.reduce((m, p) => (m[p.license || '(none)'] = (m[p.license || '(none)'] || 0) + 1, m), {});
console.log(`\nScope: ${pkgs.length} packages (${INCLUDE_DEV ? 'INCLUDING dev' : 'production only, --omit=dev'})\n`);
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`));
if (SBOM_OUT) {
// CycloneDX 1.5, hand-built. A standard format customers and underwriters recognise, without
// taking a dependency on a generator to produce it.
const sbom = {
bomFormat: 'CycloneDX',
specVersion: '1.5',
version: 1,
metadata: {
component: {
type: 'application',
name: 'screentinker',
version: fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(),
licenses: [{ license: { id: 'MIT' } }],
},
properties: [{ name: 'screentinker:scope', value: INCLUDE_DEV ? 'all' : 'production' }],
},
components: pkgs
.filter(p => p.name !== 'screentinker')
.sort((a, b) => a.name.localeCompare(b.name))
.map(p => ({
type: 'library',
name: p.name,
version: p.version,
purl: `pkg:npm/${p.name.replace('@', '%40')}@${p.version}`,
licenses: p.license ? [{ license: /[()]| OR | AND /.test(p.license) ? { name: p.license } : { id: p.license } }] : [],
})),
};
fs.mkdirSync(path.dirname(SBOM_OUT), { recursive: true });
fs.writeFileSync(SBOM_OUT, JSON.stringify(sbom, null, 2));
console.log(`\nSBOM: ${SBOM_OUT} (${sbom.components.length} components, CycloneDX 1.5)`);
}
let failed = false;
if (denied.length) {
failed = true;
console.log('\nDENIED');
denied.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license}\n ${p.why}`));
}
if (unknown.length) {
failed = true;
console.log('\nUNRESOLVED — no recognised licence. Read the package, then add it to EXCEPTIONS');
console.log('with the evidence, or remove the dependency.');
unknown.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license || '(no license field)'}`));
}
if (review.length) {
// Not fatal, but never silent — weak copyleft is a judgement call, and the judgement should be
// made by a person who knows it is being made.
console.log('\nREVIEW (not failing)');
review.forEach(p => console.log(` ${p.name}@${p.version} -> ${p.license} ${p.why}`));
}
console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: no denied or unresolved licences.\n');
process.exit(failed ? 1 : 0);