From 3f9459139fd377b20b8560014ebf16166eb29819 Mon Sep 17 00:00:00 2001 From: screentinker Date: Fri, 14 Aug 2026 15:36:38 -0500 Subject: [PATCH] Gate licences in CI and publish an SBOM (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-.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. --- .github/workflows/ci.yml | 33 ++++++ .github/workflows/release.yml | 11 ++ .gitignore | 4 + android/licenses.json | 45 ++++++++ docs/licensing.md | 103 +++++++++++++++++ scripts/android-license-check.js | 112 +++++++++++++++++++ scripts/license-check.js | 184 +++++++++++++++++++++++++++++++ 7 files changed, 492 insertions(+) create mode 100644 android/licenses.json create mode 100644 docs/licensing.md create mode 100644 scripts/android-license-check.js create mode 100644 scripts/license-check.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95581b6..a615435 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,39 @@ jobs: working-directory: android run: ./gradlew :app:testDebugUnitTest --no-daemon + # Every artifact that can enter the APK must have a licence on file. This runs here + # rather than in its own job because the Gradle cache and Android SDK are already warm. + - name: Licence gate (APK runtime classpath) + run: node scripts/android-license-check.js + + licenses: + name: Licence gate + SBOM (production deps) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '20' + cache: npm + cache-dependency-path: server/package-lock.json + + # --omit=dev on purpose, and it is the whole point of the job. 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. Auditing anything other than a + # production install would report a licence we do not actually ship. + - name: Install production dependencies only + working-directory: server + run: npm ci --omit=dev + + - name: Licence gate + run: node scripts/license-check.js --sbom sbom/screentinker-server.cdx.json + + - uses: actions/upload-artifact@v4 + with: + name: sbom + path: sbom/ + if-no-files-found: error + smoke: name: Boot smoke + version check runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efabb88..015c690 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,15 @@ jobs: ./scripts/build-autorun-zip.sh -o autorun.zip ls -la autorun.zip + # A published SBOM is what turns "we track licences" into something a customer or an + # underwriter can check for themselves. Built from a PRODUCTION install — a dev tree + # would list packages (sharp and its LGPL-bearing wasm variant) that never ship. + - name: Generate SBOM (production dependencies) + run: | + ( cd server && npm ci --omit=dev ) + node scripts/license-check.js --sbom "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" + ls -la screentinker-sbom-*.cdx.json + - name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh) run: | OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz" @@ -154,6 +163,7 @@ jobs: echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)." fi echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)." + echo "- \`screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json\` - CycloneDX 1.5 software bill of materials for the server's production dependencies, with the licence of every component." } > RELEASE_NOTES.md cat RELEASE_NOTES.md @@ -171,6 +181,7 @@ jobs: --notes-file RELEASE_NOTES.md \ "${TARBALL}" \ autorun.zip \ + "screentinker-sbom-${{ steps.ver.outputs.version }}.cdx.json" \ tizen/ScreenTinker.wgt docker: diff --git a/.gitignore b/.gitignore index a1a830d..80dac16 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ audit/ # Local SQLite artifacts (any extension the tooling might produce) *.sqlite *.sqlite3 + +# Generated by scripts/license-check.js --sbom (CI publishes it as a release asset) +sbom/ +*.cdx.json diff --git a/android/licenses.json b/android/licenses.json new file mode 100644 index 0000000..2ee23a2 --- /dev/null +++ b/android/licenses.json @@ -0,0 +1,45 @@ +{ + "_comment": [ + "Licence policy for everything on the Android release runtime classpath — i.e. everything that", + "can end up inside the APK customers install.", + "", + "scripts/android-license-check.js resolves the real classpath and checks it against this file.", + "An artifact that appears in neither 'artifacts' nor 'groups' FAILS: a new transitive dependency", + "must be looked at by a person before it ships, which is exactly how org.json:json:20090211 got", + "into the APK unnoticed in the first place.", + "", + "Record what you verified in 'evidence' — the point is to be able to answer 'how do you know?'" + ], + + "groups": { + "androidx": { "license": "Apache-2.0", "evidence": "AndroidX / Jetpack, Apache-2.0 across the board" }, + "com.google.android.material": { "license": "Apache-2.0", "evidence": "Material Components for Android" }, + "com.google.code.gson": { "license": "Apache-2.0", "evidence": "google/gson LICENSE" }, + "com.google.crypto.tink": { "license": "Apache-2.0", "evidence": "google/tink LICENSE" }, + "com.google.errorprone": { "license": "Apache-2.0", "evidence": "google/error-prone LICENSE" }, + "com.google.guava": { "license": "Apache-2.0", "evidence": "google/guava LICENSE" }, + "com.google.j2objc": { "license": "Apache-2.0", "evidence": "google/j2objc LICENSE" }, + "com.squareup.okhttp3": { "license": "Apache-2.0", "evidence": "square/okhttp LICENSE" }, + "com.squareup.okio": { "license": "Apache-2.0", "evidence": "square/okio LICENSE" }, + "org.checkerframework": { "license": "MIT", "evidence": "checker-framework, MIT for the qualifiers" }, + "org.jetbrains": { "license": "Apache-2.0", "evidence": "JetBrains annotations" }, + "org.jetbrains.kotlin": { "license": "Apache-2.0", "evidence": "Kotlin stdlib" }, + "org.jetbrains.kotlinx": { "license": "Apache-2.0", "evidence": "kotlinx coroutines" }, + "io.socket": { "license": "MIT", "evidence": "socket.io-client-java LICENSE (MIT)" } + }, + + "artifacts": {}, + + "denied": { + "org.json:json": { + "why": "JSON Licence — the 'shall be used for Good, not Evil' clause. Not OSI-approved, non-free per Debian and Fedora, Apache Category X. Arrives transitively via socket.io-client and was previously packaged into the APK in full (19 classes). Excluded in app/build.gradle.kts; Android provides org.json in the platform from API 1 and minSdk is 24, so nothing is lost." + } + }, + + "denied_licenses": [ + { "match": "AGPL", "why": "network copyleft" }, + { "match": "GPL", "why": "strong copyleft in a commercially distributed binary" }, + { "match": "SSPL", "why": "not OSI-approved, service-scope obligations" }, + { "match": "JSON", "why": "field-of-use restriction" } + ] +} diff --git a/docs/licensing.md b/docs/licensing.md new file mode 100644 index 0000000..35eca9b --- /dev/null +++ b/docs/licensing.md @@ -0,0 +1,103 @@ +# Licensing + +ScreenTinker is MIT. This page records how we know what our dependencies are licensed under, +so the answer to "do you track licences?" is something you can check rather than something you +have to take on trust. + +## The short answer + +**No GPL or AGPL anywhere in the product.** Neither the server nor the Android player links, +bundles, or ships anything under strong or network copyleft. + +## Where the answer comes from + +Two gates run in CI on every push, and both fail closed — a dependency whose licence nobody has +recorded fails the build rather than shipping unnoticed. + +| Gate | Covers | Script | +|---|---|---| +| Licence gate + SBOM (production deps) | the server's npm tree | `scripts/license-check.js` | +| Licence gate (APK runtime classpath) | everything that can enter the APK | `scripts/android-license-check.js` | + +Run either locally: + +```sh +cd server && npm ci --omit=dev && cd .. +node scripts/license-check.js # server +node scripts/android-license-check.js # APK +node scripts/license-check.js --sbom sbom/x.json # also write an SBOM +``` + +Neither script has dependencies of its own. A gate that needs its own supply chain audited is +worth less than one that doesn't. + +## ⚠️ Audit the production install, not the checkout + +**A licence scanner pointed at a developer checkout will report LGPL, and it will be wrong about +what we ship.** + +`sharp` is a `devDependency` — a fixture generator for the image tests — and one of its platform +binaries, `@img/sharp-wasm32`, declares `Apache-2.0 AND LGPL-3.0-or-later AND MIT`. It is never +installed on a server: production installs with `npm ci --omit=dev`, which both the CI gate and +`scripts/upgrade.sh` use. + +If someone challenges the answer with a scan of the repo, this is the discrepancy they have found. + +`sharp` is kept deliberately: it is the *independent* implementation used to generate fixtures for +the pure-JavaScript image path that replaced it. Generating those fixtures with the library under +test would mean a decode bug could produce a fixture that hides the same bug. + +## Policy + +**Allowed** — MIT, MIT-0, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, Apache-2.0, BlueOak-1.0.0, +Unlicense, CC0-1.0, Python-2.0, WTFPL, Zlib, CC-BY-4.0. + +**Denied** — AGPL, GPL, SSPL, Commons Clause, BUSL, and the JSON Licence. + +**Reported but not failed** — LGPL, MPL, EPL, CDDL, OSL, EUPL. Weak copyleft is file- or +library-scoped and usually fine when merely linked, but it is a judgement, and the judgement should +be made by someone who knows they are making it. + +**Unrecognised — fails.** A package with no licence we can identify is not a package we ship. Where +a dependency ships a real licence *file* but declares no `license` field, it is recorded as an +exception in the script with the evidence that was read off disk (currently `exif-parser` and +`thirty-two`, both MIT). + +### Why the JSON Licence is denied + +`org.json:json:20090211` arrived transitively through `socket.io-client` and was **packaged into the +APK in full** — 19 classes, including ones nothing referenced. Its licence carries the clause *"The +Software shall be used for Good, not Evil"*: not OSI-approved, treated as non-free by Debian and +Fedora, and Category X at Apache. Not copyleft, but not a term to accept in a binary distributed +commercially. + +It is now excluded in `android/app/build.gradle.kts`. Nothing is lost — Android has provided +`org.json` in the platform since API 1 and `minSdk` is 24 — and `android/licenses.json` denies it by +name so it cannot return quietly. + +## SBOM + +Every release publishes `screentinker-sbom-.cdx.json`: **CycloneDX 1.5**, listing every +production dependency with its version, package URL, and licence. Generated from a production +install, so it describes what actually runs. + +CI also uploads one as a build artifact on every run. + +## Vendored code + +Anything committed under `frontend/vendor/` **ships in the release tarball** and must carry its +licence notice as a separate file — minifiers strip headers, which is exactly when the notice has to +be kept alongside. See `frontend/vendor/README.md`. + +## The GLSL transitions + +The 14 shaders in `shared/Transitions/` are original work. Each carries its author and licence in +the file header, and none derives from Shadertoy, gl-transitions, glslsandbox or similar. "GL +Transitions v1" in those headers refers to the *interface convention* — the function signature the +renderer calls — not to borrowed code. + +## Limits + +These gates identify licences from declared metadata and recorded evidence. They are not a +clean-room provenance review, and they do not detect code copied into the repository without +attribution. diff --git a/scripts/android-license-check.js b/scripts/android-license-check.js new file mode 100644 index 0000000..0aefd6d --- /dev/null +++ b/scripts/android-license-check.js @@ -0,0 +1,112 @@ +#!/usr/bin/env node +'use strict'; + +/* + * Licence gate for the APK. + * + * node scripts/android-license-check.js [--sbom ] + * + * Resolves the real `releaseRuntimeClasspath` — every artifact that can end up inside the APK a + * customer installs, transitive ones included — and checks each against android/licenses.json. + * + * Fails on an artifact nobody has recorded a licence for. That is the case worth catching: + * org.json:json:20090211 reached customers because it arrived as a transitive dependency of + * socket.io-client and nothing ever asked what licence it carried. + */ + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const ROOT = path.join(__dirname, '..'); +const ANDROID = path.join(ROOT, 'android'); +const POLICY = JSON.parse(fs.readFileSync(path.join(ANDROID, 'licenses.json'), 'utf8')); +const SBOM_OUT = process.argv.includes('--sbom') ? process.argv[process.argv.indexOf('--sbom') + 1] : null; + +function resolveClasspath() { + const out = execFileSync('./gradlew', ['-q', 'app:dependencies', '--configuration', 'releaseRuntimeClasspath'], + { cwd: ANDROID, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' }); + const found = new Map(); + for (const raw of out.split('\n')) { + // Gradle prints "group:name:requested -> resolved" when a version is upgraded; the resolved + // one is what ships, so prefer the right-hand side. + const m = raw.match(/([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]+):([0-9][a-zA-Z0-9._-]*)(?:\s*->\s*([0-9][a-zA-Z0-9._-]*))?/); + if (!m) continue; + const [, group, name, requested, upgraded] = m; + found.set(`${group}:${name}`, { group, name, version: upgraded || requested }); + } + return [...found.values()].sort((a, b) => `${a.group}:${a.name}`.localeCompare(`${b.group}:${b.name}`)); +} + +function licenceFor(a) { + const coord = `${a.group}:${a.name}`; + if (POLICY.denied[coord]) return { verdict: 'DENY', why: POLICY.denied[coord].why }; + if (POLICY.artifacts[coord]) return { verdict: 'ALLOW', ...POLICY.artifacts[coord] }; + // Longest matching group prefix wins, so a specific rule beats a broad one. + const groups = Object.keys(POLICY.groups) + .filter(g => a.group === g || a.group.startsWith(g + '.')) + .sort((x, y) => y.length - x.length); + if (groups.length) return { verdict: 'ALLOW', ...POLICY.groups[groups[0]] }; + return { verdict: 'UNKNOWN' }; +} + +const artifacts = resolveClasspath(); +if (!artifacts.length) { + console.error('Resolved no artifacts — the gradle task did not run properly. Refusing to pass.'); + process.exit(2); +} + +const results = artifacts.map(a => ({ ...a, ...licenceFor(a) })); +const denied = results.filter(r => r.verdict === 'DENY'); +const unknown = results.filter(r => r.verdict === 'UNKNOWN'); + +for (const r of results.filter(r => r.verdict === 'ALLOW')) { + for (const d of POLICY.denied_licenses) { + if (new RegExp(d.match, 'i').test(r.license)) { denied.push({ ...r, why: `${r.license}: ${d.why}` }); } + } +} + +const counts = results.reduce((m, r) => (m[r.license || '(unrecorded)'] = (m[r.license || '(unrecorded)'] || 0) + 1, m), {}); +console.log(`\nScope: ${results.length} artifacts on releaseRuntimeClasspath (everything that can enter the APK)\n`); +Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([l, n]) => console.log(` ${String(n).padStart(4)} ${l}`)); + +if (SBOM_OUT) { + const sbom = { + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { + component: { + type: 'application', + name: 'screentinker-android-player', + version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(), + licenses: [{ license: { id: 'MIT' } }], + }, + }, + components: results.map(r => ({ + type: 'library', + name: `${r.group}:${r.name}`, + version: r.version, + purl: `pkg:maven/${r.group}/${r.name}@${r.version}`, + licenses: r.license ? [{ license: { id: r.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(r => console.log(` ${r.group}:${r.name}:${r.version}\n ${r.why}`)); +} +if (unknown.length) { + failed = true; + console.log('\nUNRECORDED — a new dependency reached the APK with no licence on file.'); + console.log('Look it up, then add it to android/licenses.json with evidence, or exclude it.'); + unknown.forEach(r => console.log(` ${r.group}:${r.name}:${r.version}`)); +} +console.log(failed ? '\nFAIL: licence policy violated.\n' : '\nOK: every artifact in the APK has a recorded, permitted licence.\n'); +process.exit(failed ? 1 : 0); diff --git a/scripts/license-check.js b/scripts/license-check.js new file mode 100644 index 0000000..f547302 --- /dev/null +++ b/scripts/license-check.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +'use strict'; + +/* + * Licence gate for the dependencies that actually SHIP. + * + * node scripts/license-check.js [--sbom ] [--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);