185 lines
5.7 KiB
TypeScript
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);
|
|
}
|