155 lines
5.2 KiB
TypeScript
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();
|
|
}
|