312 lines
9.5 KiB
JavaScript
312 lines
9.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* License-compliance gate.
|
|
*
|
|
* Enumerates production `dependencies` declared in the root + workspace
|
|
* package.json manifests, locates each dep's installed package.json under
|
|
* `node_modules`, and classifies its SPDX license 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 are surfaced as warnings (manual review required)
|
|
* but do not block. Blocked-list matches always fail with exit code 1.
|
|
*
|
|
* 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";
|
|
|
|
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",
|
|
"(MIT OR Apache-2.0)",
|
|
"(MIT OR CC0-1.0)",
|
|
"BlueOak-1.0.0",
|
|
"WTFPL",
|
|
]);
|
|
|
|
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".
|
|
*
|
|
* A composite OR expression is "allowed" as long as at least one of its
|
|
* atoms is on the allowlist. A blocked atom still wins, because we never
|
|
* want to import a project where the redistributor could pick GPL.
|
|
*/
|
|
export function classifyLicense(
|
|
license,
|
|
{
|
|
allowed = ALLOWED_LICENSES,
|
|
blockedPatterns = BLOCKED_LICENSE_PATTERNS,
|
|
} = {},
|
|
) {
|
|
if (license == null) return "unknown";
|
|
if (allowed.has(license)) return "allowed";
|
|
for (const pat of blockedPatterns) {
|
|
if (pat.test(license)) return "blocked";
|
|
}
|
|
const atoms = splitSpdxExpression(license);
|
|
if (atoms.length > 1) {
|
|
for (const atom of atoms) {
|
|
for (const pat of blockedPatterns) {
|
|
if (pat.test(atom)) return "blocked";
|
|
}
|
|
}
|
|
if (atoms.some((atom) => allowed.has(atom))) return "allowed";
|
|
}
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
} = {}) {
|
|
const manifests = discoverWorkspaceManifests(repoRoot);
|
|
const deps = collectProductionDeps(manifests);
|
|
|
|
const counts = { scanned: 0, allowed: 0, reviewed: 0, unknown: 0, blocked: 0 };
|
|
const blocked = [];
|
|
const unknown = [];
|
|
const missing = [];
|
|
|
|
for (const name of deps) {
|
|
counts.scanned += 1;
|
|
const manifestPath = resolveInstalledManifest(name, repoRoot);
|
|
if (!manifestPath) {
|
|
missing.push(name);
|
|
counts.unknown += 1;
|
|
unknown.push({ name, version: "(not installed)", license: null });
|
|
continue;
|
|
}
|
|
let pkg;
|
|
try {
|
|
pkg = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
} catch {
|
|
counts.unknown += 1;
|
|
unknown.push({ name, version: "(unreadable)", license: null });
|
|
continue;
|
|
}
|
|
const version = typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
const license = parseLicenseField(pkg);
|
|
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 };
|
|
}
|
|
|
|
function formatLine(prefix, entry) {
|
|
return `${prefix} ${entry.name}@${entry.version}: ${entry.license ?? "(no license field)"}`;
|
|
}
|
|
|
|
function main() {
|
|
const result = auditLicenses();
|
|
const { counts, blocked, unknown } = result;
|
|
for (const entry of blocked) {
|
|
console.error(formatLine("FAIL", entry));
|
|
}
|
|
for (const entry of unknown) {
|
|
console.warn(formatLine("WARN", entry));
|
|
}
|
|
if (blocked.length > 0) {
|
|
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();
|
|
}
|