Files
suaveplan/scripts/conformance/check-crypto-core-allowlist.ts
2026-05-27 23:47:53 +01:00

185 lines
5.7 KiB
TypeScript

#!/usr/bin/env bun
/**
* Import-allowlist conformance gate.
*
* Scans every workspace package's `src/` for imports of
* `@suaveplan/crypto-core`, `@suaveplan/crypto-cipher`,
* `@suaveplan/crypto-curve`, or `@suaveplan/crypto-hash` MAC subpaths.
* Fails with `error` severity for any package not on the committed
* allowlist (`scripts/conformance/crypto-core-allowlist.yml`).
*
* Open imports (always allowed):
* - `@suaveplan/crypto-utils` (encoding helpers)
* - `@suaveplan/crypto-hash` for sha256 / sha384 / sha512 / hkdf /
* pbkdf2 / argon2id (pure-hash + KDF uses); only the MAC subpath
* `@suaveplan/crypto-hash/mac` is gated, because HMAC is the
* primitive that should travel through Authority instead.
*
* Exit codes:
* 0 — all packages comply
* 1 — at least one package outside the allowlist imports a gated module
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(HERE, "..", "..");
const ALLOWLIST_PATH = join(HERE, "crypto-core-allowlist.yml");
const PACKAGES_ROOT = join(REPO_ROOT, "packages");
const GATED_PATTERNS = [
/@suaveplan\/crypto-core(\/[^"'`]*)?/,
/@suaveplan\/crypto-cipher(\/[^"'`]*)?/,
/@suaveplan\/crypto-curve(\/[^"'`]*)?/,
/@suaveplan\/crypto-hash\/mac(\/[^"'`]*)?/,
];
interface AllowlistDoc {
readonly allowlist: readonly string[];
}
function readAllowlist(path: string): AllowlistDoc {
const text = readFileSync(path, "utf8");
const allowlist: string[] = [];
let inAllowlist = false;
for (const raw of text.split(/\r?\n/)) {
const line = raw.trimEnd();
if (line.startsWith("allowlist:")) {
inAllowlist = true;
continue;
}
if (inAllowlist) {
// Section ends at the next top-level key
if (/^\S/.test(line) && !line.startsWith(" ")) {
inAllowlist = false;
continue;
}
const m = line.match(/^\s*-\s+"([^"]+)"\s*$/);
if (m?.[1]) allowlist.push(m[1]);
}
}
return { allowlist };
}
function findPackageRoots(start: string): string[] {
const out: string[] = [];
function walk(dir: string): void {
for (const entry of readdirSync(dir)) {
if (entry.startsWith(".")) continue;
const full = join(dir, entry);
const st = statSync(full);
if (!st.isDirectory()) continue;
if (entry === "node_modules" || entry === "dist") continue;
const pj = join(full, "package.json");
try {
statSync(pj);
out.push(full);
} catch {
walk(full);
}
}
}
walk(start);
return out;
}
function* walkSrcFiles(packageDir: string): Generator<string> {
const src = join(packageDir, "src");
try {
statSync(src);
} catch {
return;
}
function* recurse(d: string): Generator<string> {
for (const entry of readdirSync(d)) {
const full = join(d, entry);
const st = statSync(full);
if (st.isDirectory()) {
yield* recurse(full);
} else if (
full.endsWith(".ts") &&
!full.endsWith(".test.ts") &&
!full.endsWith(".spec.ts")
) {
yield full;
}
}
}
yield* recurse(src);
}
interface Finding {
readonly packageName: string;
readonly file: string;
readonly line: number;
readonly importPath: string;
}
function packageNameOf(packageDir: string): string {
const pj = JSON.parse(
readFileSync(join(packageDir, "package.json"), "utf8")
) as { name?: string };
return pj.name ?? `(unknown)@${relative(REPO_ROOT, packageDir)}`;
}
function scanFile(file: string, packageName: string): Finding[] {
const findings: Finding[] = [];
const text = readFileSync(file, "utf8");
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (!line.includes("@suaveplan/crypto-")) continue;
for (const pat of GATED_PATTERNS) {
const m = line.match(pat);
if (m?.[0]) {
findings.push({
packageName,
file: relative(REPO_ROOT, file),
line: i + 1,
importPath: m[0],
});
}
}
}
return findings;
}
export interface AllowlistCheckResult {
readonly violations: readonly Finding[];
}
export function checkAllowlist(): AllowlistCheckResult {
const allow = readAllowlist(ALLOWLIST_PATH);
const allowSet = new Set(allow.allowlist);
const violations: Finding[] = [];
for (const packageDir of findPackageRoots(PACKAGES_ROOT)) {
const name = packageNameOf(packageDir);
if (allowSet.has(name)) continue;
for (const file of walkSrcFiles(packageDir)) {
for (const f of scanFile(file, name)) {
violations.push(f);
}
}
}
return { violations };
}
if (import.meta.main) {
const result = checkAllowlist();
if (result.violations.length === 0) {
console.log("crypto-core allowlist: 0 violations");
process.exit(0);
}
console.error(
`crypto-core allowlist: ${result.violations.length} violation(s)`
);
for (const v of result.violations) {
console.error(
` [error] ${v.packageName} at ${v.file}:${v.line} imports ${v.importPath}`
);
}
process.exit(1);
}