#!/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 { const src = join(packageDir, "src"); try { statSync(src); } catch { return; } function* recurse(d: string): Generator { 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); }