Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/verify-licenses.mjs
T

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();
}