405 lines
13 KiB
JavaScript
405 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* License-compliance gate.
|
|
*
|
|
* Enumerates the complete installed production dependency graph reported by
|
|
* pnpm and classifies every package/version SPDX expression against:
|
|
*
|
|
* - ALLOWED_LICENSES permissive / Apache-style licenses we accept.
|
|
* - BLOCKED_LICENSES copyleft / source-available families we refuse.
|
|
* - scripts/license-baseline.json reviewed exceptions
|
|
* (specific `name@version` pairs).
|
|
*
|
|
* Unknown SPDX strings and blocked-list matches fail closed unless an exact
|
|
* package version has a documented reviewed exception.
|
|
*
|
|
* Output (single line on success):
|
|
* PASS scanned=N allowed=M reviewed=K unknown=L
|
|
*
|
|
* Output on failure (one line per offender + summary):
|
|
* FAIL <name>@<version>: <license>
|
|
* FAIL scanned=N allowed=M reviewed=K unknown=L blocked=B
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
const __filename = url.fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const REPO_ROOT = path.resolve(__dirname, "..");
|
|
const BASELINE_PATH = path.join(__dirname, "license-baseline.json");
|
|
|
|
export const ALLOWED_LICENSES = new Set([
|
|
"MIT",
|
|
"Apache-2.0",
|
|
"BSD-2-Clause",
|
|
"BSD-3-Clause",
|
|
"ISC",
|
|
"0BSD",
|
|
"Unlicense",
|
|
"CC0-1.0",
|
|
"CC-BY-4.0",
|
|
"(MIT OR Apache-2.0)",
|
|
"(MIT OR CC0-1.0)",
|
|
"BlueOak-1.0.0",
|
|
"MIT-0",
|
|
"MPL-2.0",
|
|
"Python-2.0",
|
|
"WTFPL",
|
|
"Zlib",
|
|
]);
|
|
|
|
export const BLOCKED_LICENSE_PATTERNS = [
|
|
/^GPL-/i,
|
|
/^AGPL-/i,
|
|
/^SSPL-/i,
|
|
/Commons-Clause/i,
|
|
/^LGPL-/i,
|
|
];
|
|
|
|
/**
|
|
* Returns the workspace manifests (root + apps/* + packages/*).
|
|
*/
|
|
export function discoverWorkspaceManifests(repoRoot = REPO_ROOT) {
|
|
const roots = [path.join(repoRoot, "package.json")];
|
|
for (const group of ["apps", "packages"]) {
|
|
const dir = path.join(repoRoot, group);
|
|
if (!fs.existsSync(dir)) continue;
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const candidate = path.join(dir, entry.name, "package.json");
|
|
if (fs.existsSync(candidate)) roots.push(candidate);
|
|
}
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
/**
|
|
* Collects unique production deps from a manifest list.
|
|
* Workspace deps (`workspace:*`) are excluded — they're our own code.
|
|
*/
|
|
export function collectProductionDeps(manifestPaths) {
|
|
const seen = new Set();
|
|
for (const manifestPath of manifestPaths) {
|
|
let raw;
|
|
try {
|
|
raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
} catch {
|
|
continue;
|
|
}
|
|
const deps = raw.dependencies ?? {};
|
|
for (const [name, version] of Object.entries(deps)) {
|
|
if (typeof version === "string" && version.startsWith("workspace:"))
|
|
continue;
|
|
seen.add(name);
|
|
}
|
|
}
|
|
return [...seen].sort();
|
|
}
|
|
|
|
/**
|
|
* Resolve a dep's installed package.json. Falls back to walking
|
|
* `node_modules` up the tree because pnpm can hoist or nest packages.
|
|
*/
|
|
export function resolveInstalledManifest(depName, repoRoot = REPO_ROOT) {
|
|
const candidate = path.join(repoRoot, "node_modules", depName, "package.json");
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
// Fallback: shallow scan of .pnpm virtual store for the first match.
|
|
const pnpmDir = path.join(repoRoot, "node_modules", ".pnpm");
|
|
if (fs.existsSync(pnpmDir)) {
|
|
const scoped = depName.startsWith("@");
|
|
const escaped = depName.replace("/", "+");
|
|
for (const entry of fs.readdirSync(pnpmDir)) {
|
|
// pnpm names virtual store entries like `name@version` or
|
|
// `@scope+name@version`. We need an exact prefix match on the
|
|
// package identity so e.g. `react-router` doesn't match
|
|
// `react-router-dom`.
|
|
const head = scoped ? `${escaped}@` : `${depName}@`;
|
|
if (!entry.startsWith(head)) continue;
|
|
const nested = path.join(
|
|
pnpmDir,
|
|
entry,
|
|
"node_modules",
|
|
depName,
|
|
"package.json",
|
|
);
|
|
if (fs.existsSync(nested)) return nested;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Normalize an SPDX license field into a canonical string.
|
|
*
|
|
* Accepts:
|
|
* - plain string ("MIT")
|
|
* - object form ({ type: "MIT", url: "..." })
|
|
* - "licenses" array form ([{ type: "MIT" }, { type: "Apache-2.0" }])
|
|
*
|
|
* Returns null when no license is declared.
|
|
*/
|
|
export function parseLicenseField(pkg) {
|
|
if (!pkg || typeof pkg !== "object") return null;
|
|
const lic = pkg.license;
|
|
if (typeof lic === "string" && lic.trim() !== "") return lic.trim();
|
|
if (lic && typeof lic === "object" && typeof lic.type === "string") {
|
|
return lic.type.trim();
|
|
}
|
|
if (Array.isArray(pkg.licenses) && pkg.licenses.length > 0) {
|
|
const types = pkg.licenses
|
|
.map((entry) =>
|
|
entry && typeof entry === "object" && typeof entry.type === "string"
|
|
? entry.type.trim()
|
|
: null,
|
|
)
|
|
.filter(Boolean);
|
|
if (types.length === 1) return types[0];
|
|
if (types.length > 1) return `(${types.join(" OR ")})`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Split a parenthesised SPDX expression like "(MIT OR Apache-2.0)" into
|
|
* its atoms. Leaves single-token expressions untouched. We only handle
|
|
* the OR conjunction here — AND would mean "all of these apply", which
|
|
* does not benefit from any allowlist atom.
|
|
*/
|
|
export function splitSpdxExpression(expr) {
|
|
if (typeof expr !== "string") return [];
|
|
const trimmed = expr.trim();
|
|
if (trimmed === "") return [];
|
|
// Strip a single matched pair of surrounding parens.
|
|
const unwrapped =
|
|
trimmed.startsWith("(") && trimmed.endsWith(")")
|
|
? trimmed.slice(1, -1).trim()
|
|
: trimmed;
|
|
if (!/\bOR\b/i.test(unwrapped)) return [trimmed];
|
|
return unwrapped
|
|
.split(/\s+OR\s+/i)
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Classify a license string against the allow / block lists.
|
|
*
|
|
* Returns one of: "allowed" | "blocked" | "unknown".
|
|
*
|
|
* SPDX OR grants the distributor a choice, so one allowed branch is enough.
|
|
* SPDX AND requires every branch. Unknown branches remain review-required.
|
|
*/
|
|
export function classifyLicense(
|
|
license,
|
|
{
|
|
allowed = ALLOWED_LICENSES,
|
|
blockedPatterns = BLOCKED_LICENSE_PATTERNS,
|
|
} = {},
|
|
) {
|
|
if (license == null) return "unknown";
|
|
return classifySpdxExpression(license.trim(), allowed, blockedPatterns);
|
|
}
|
|
|
|
function unwrapSpdxParentheses(expression) {
|
|
let value = expression.trim();
|
|
while (value.startsWith("(") && value.endsWith(")")) {
|
|
let depth = 0;
|
|
let wrapsWholeExpression = true;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
if (value[index] === "(") depth += 1;
|
|
if (value[index] === ")") depth -= 1;
|
|
if (depth === 0 && index < value.length - 1) {
|
|
wrapsWholeExpression = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!wrapsWholeExpression) break;
|
|
value = value.slice(1, -1).trim();
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function splitTopLevelSpdx(expression, operator) {
|
|
const parts = [];
|
|
let depth = 0;
|
|
let start = 0;
|
|
const token = ` ${operator} `;
|
|
const upper = expression.toUpperCase();
|
|
for (let index = 0; index < expression.length; index += 1) {
|
|
if (expression[index] === "(") depth += 1;
|
|
else if (expression[index] === ")") depth -= 1;
|
|
else if (depth === 0 && upper.startsWith(token, index)) {
|
|
parts.push(expression.slice(start, index).trim());
|
|
index += token.length - 1;
|
|
start = index + 1;
|
|
}
|
|
}
|
|
if (parts.length === 0) return [expression.trim()];
|
|
parts.push(expression.slice(start).trim());
|
|
return parts.filter(Boolean);
|
|
}
|
|
|
|
function classifySpdxExpression(expression, allowed, blockedPatterns) {
|
|
const value = unwrapSpdxParentheses(expression);
|
|
if (allowed.has(expression) || allowed.has(value)) return "allowed";
|
|
|
|
const alternatives = splitTopLevelSpdx(value, "OR");
|
|
if (alternatives.length > 1) {
|
|
const verdicts = alternatives.map((branch) => classifySpdxExpression(branch, allowed, blockedPatterns));
|
|
if (verdicts.includes("allowed")) return "allowed";
|
|
if (verdicts.every((verdict) => verdict === "blocked")) return "blocked";
|
|
return "unknown";
|
|
}
|
|
|
|
const requirements = splitTopLevelSpdx(value, "AND");
|
|
if (requirements.length > 1) {
|
|
const verdicts = requirements.map((branch) => classifySpdxExpression(branch, allowed, blockedPatterns));
|
|
if (verdicts.includes("blocked")) return "blocked";
|
|
if (verdicts.every((verdict) => verdict === "allowed")) return "allowed";
|
|
return "unknown";
|
|
}
|
|
|
|
for (const pattern of blockedPatterns) {
|
|
if (pattern.test(value)) return "blocked";
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
export function loadBaseline(baselinePath = BASELINE_PATH) {
|
|
if (!fs.existsSync(baselinePath)) return {};
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
|
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function isReviewed(baseline, name, version) {
|
|
if (!baseline) return false;
|
|
const key = `${name}@${version}`;
|
|
return Object.prototype.hasOwnProperty.call(baseline, key);
|
|
}
|
|
|
|
/**
|
|
* Flatten pnpm's license-grouped JSON into unique package/version records.
|
|
* A dependency can occur through many workspace paths; compliance needs one
|
|
* verdict per shipped version, not one verdict per path.
|
|
*/
|
|
export function parsePnpmLicenseReport(report) {
|
|
if (!report || typeof report !== "object" || Array.isArray(report)) {
|
|
throw new Error("pnpm license report must be an object grouped by license");
|
|
}
|
|
|
|
const packages = new Map();
|
|
for (const [groupLicense, entries] of Object.entries(report)) {
|
|
if (!Array.isArray(entries)) continue;
|
|
for (const entry of entries) {
|
|
if (!entry || typeof entry !== "object" || typeof entry.name !== "string") continue;
|
|
const versions = Array.isArray(entry.versions) ? entry.versions : [];
|
|
const license = typeof entry.license === "string" && entry.license.trim() ? entry.license.trim() : groupLicense;
|
|
for (const version of versions) {
|
|
if (typeof version !== "string" || version.length === 0) continue;
|
|
const key = `${entry.name}@${version}`;
|
|
packages.set(key, { name: entry.name, version, license });
|
|
}
|
|
}
|
|
}
|
|
return [...packages.values()].sort((left, right) => `${left.name}@${left.version}`.localeCompare(`${right.name}@${right.version}`));
|
|
}
|
|
|
|
export function collectInstalledProductionPackages(repoRoot = REPO_ROOT) {
|
|
const result = spawnSync("pnpm", ["licenses", "list", "--prod", "--json"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
maxBuffer: 50 * 1024 * 1024,
|
|
});
|
|
if (result.status !== 0) {
|
|
const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? "unknown"}`;
|
|
throw new Error(`pnpm production license inventory failed: ${detail}`);
|
|
}
|
|
try {
|
|
return parsePnpmLicenseReport(JSON.parse(result.stdout));
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`pnpm production license inventory was invalid: ${detail}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the audit. Returns a result object — does not exit or print.
|
|
* The CLI shell at the bottom of this file handles I/O.
|
|
*/
|
|
export function auditLicenses({
|
|
repoRoot = REPO_ROOT,
|
|
baseline = loadBaseline(),
|
|
allowed = ALLOWED_LICENSES,
|
|
blockedPatterns = BLOCKED_LICENSE_PATTERNS,
|
|
installedPackages,
|
|
} = {}) {
|
|
const packages = installedPackages ?? collectInstalledProductionPackages(repoRoot);
|
|
|
|
const counts = { scanned: 0, allowed: 0, reviewed: 0, unknown: 0, blocked: 0 };
|
|
const blocked = [];
|
|
const unknown = [];
|
|
|
|
for (const { name, version, license } of packages) {
|
|
counts.scanned += 1;
|
|
if (isReviewed(baseline, name, version)) {
|
|
counts.reviewed += 1;
|
|
continue;
|
|
}
|
|
const verdict = classifyLicense(license, { allowed, blockedPatterns });
|
|
if (verdict === "allowed") counts.allowed += 1;
|
|
else if (verdict === "blocked") {
|
|
counts.blocked += 1;
|
|
blocked.push({ name, version, license });
|
|
} else {
|
|
counts.unknown += 1;
|
|
unknown.push({ name, version, license });
|
|
}
|
|
}
|
|
|
|
return { counts, blocked, unknown, missing: [] };
|
|
}
|
|
|
|
export function hasLicenseViolations(result) {
|
|
return (result?.blocked?.length ?? 0) > 0 || (result?.unknown?.length ?? 0) > 0;
|
|
}
|
|
|
|
function formatLine(prefix, entry) {
|
|
return `${prefix} ${entry.name}@${entry.version}: ${entry.license ?? "(no license field)"}`;
|
|
}
|
|
|
|
function main() {
|
|
let result;
|
|
try {
|
|
result = auditLicenses();
|
|
} catch (error) {
|
|
console.error(`FAIL ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exit(1);
|
|
}
|
|
const { counts, blocked, unknown } = result;
|
|
for (const entry of blocked) {
|
|
console.error(formatLine("FAIL", entry));
|
|
}
|
|
for (const entry of unknown) {
|
|
console.error(formatLine("FAIL", entry));
|
|
}
|
|
if (hasLicenseViolations(result)) {
|
|
console.error(
|
|
`FAIL scanned=${counts.scanned} allowed=${counts.allowed} reviewed=${counts.reviewed} unknown=${counts.unknown} blocked=${counts.blocked}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log(
|
|
`PASS scanned=${counts.scanned} allowed=${counts.allowed} reviewed=${counts.reviewed} unknown=${counts.unknown}`,
|
|
);
|
|
}
|
|
|
|
if (import.meta.url === url.pathToFileURL(process.argv[1] ?? "").href) {
|
|
main();
|
|
}
|