Files
suaveplan/scripts/ct-lint.ts
2026-05-27 23:47:53 +01:00

155 lines
5.2 KiB
TypeScript

/**
* Constant-time lint. Scans every `packages/universal/crypto-*\/src/primitives/
* <id>/impl/<id>.native.ts` for the obvious class of data-dependent bugs:
*
* (a) secret-indexed array access: `arr[secret]` or `secret[index]` where
* the indexer or the base is a parameter conventionally holding secret
* bytes (key / nonce / secret / priv / msg).
* (b) early returns on secret comparisons: `if (secretByte === x) return …`.
* (c) data-dependent branches on parameters typed as secret / key / nonce:
* `if (key[i] !== …) …`.
*
* This is a regex-grade scan, not a full AST analysis. It catches the
* obvious class of bugs and is cheap enough to run on every precommit and
* in CI. Missing a subtle leak here is not failure; a false positive that
* blocks a commit IS, so the patterns are tight and the names list is
* conservative. Natives should be reviewed by hand regardless.
*
* For Phase 0 every native.ts is a `PrimitiveNotImplemented` stub, so the
* lint passes cleanly. As natives land this file becomes the first gate.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const REPO_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const SECRET_NAME_RE =
/(?:secret|key|priv|private|nonce|iv|msg|message|password|salt|seed)/i;
interface Finding {
readonly file: string;
readonly line: number;
readonly rule: "secret-indexed" | "secret-early-return" | "secret-branch";
readonly evidence: string;
}
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".native.ts")) yield full;
}
}
function listNativeFiles(): string[] {
const out: string[] = [];
const pkgRoot = join(REPO_ROOT, "universal/auth");
for (const pkg of readdirSync(pkgRoot)) {
if (!pkg.startsWith("crypto-")) continue;
const primitivesDir = join(pkgRoot, pkg, "src", "primitives");
try {
statSync(primitivesDir);
} catch {
continue;
}
for (const f of walk(primitivesDir)) out.push(f);
}
return out;
}
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1");
}
function scan(source: string): Finding[] {
const cleaned = stripComments(source);
const lines = cleaned.split("\n");
const raw = source.split("\n");
const findings: Finding[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (!line.trim()) continue;
// Rule (a): base[secret] — indexer name matches a secret convention.
const indexRe =
/([A-Za-z_$][A-Za-z0-9_$]*)\s*\[\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\]/g;
for (const m of line.matchAll(indexRe)) {
const base = m[1]!;
const idx = m[2]!;
if (SECRET_NAME_RE.test(idx) || SECRET_NAME_RE.test(base)) {
findings.push({
file: "",
line: i + 1,
rule: "secret-indexed",
evidence: raw[i]!.trim(),
});
}
}
// Rule (b): early return gated on a comparison involving a
// secret-named identifier.
if (
/\bif\s*\([^)]*\b(?:secret|key|priv|private|nonce|iv|password)[A-Za-z0-9_$]*\s*(?:===|!==|==|!=)/i.test(
line
) &&
/return\b/.test(line)
) {
findings.push({
file: "",
line: i + 1,
rule: "secret-early-return",
evidence: raw[i]!.trim(),
});
}
// Rule (c): `if (secret…)` / `if (priv…)` / `if (nonce…)` branches.
// Exempt `.length` — caller-visible length is not secret.
const branchMatch = /\bif\s*\(([^)]*)\)/.exec(line);
if (branchMatch) {
const cond = branchMatch[1]!;
const condWithoutLength = cond.replace(/\.length\b/g, "");
if (
/\b(?:secret|priv|private|nonce|iv)\w*/i.test(condWithoutLength)
) {
findings.push({
file: "",
line: i + 1,
rule: "secret-branch",
evidence: raw[i]!.trim(),
});
}
}
}
return findings;
}
async function main(): Promise<void> {
const files = listNativeFiles();
const all: Finding[] = [];
for (const file of files) {
const source = readFileSync(file, "utf8");
const rel = relative(REPO_ROOT, file);
for (const f of scan(source)) {
all.push({ ...f, file: rel });
}
}
if (all.length === 0) {
console.log(`ct-lint: OK (${files.length} native files scanned)`);
return;
}
console.error(`ct-lint: ${all.length} finding(s):`);
for (const f of all) {
console.error(` ${f.file}:${f.line} [${f.rule}] ${f.evidence}`);
}
process.exit(1);
}
if (import.meta.main) {
await main();
}