Initial Commit
This commit is contained in:
154
scripts/ct-lint.ts
Normal file
154
scripts/ct-lint.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user