scripts/secret-scan.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { IGNORED_PATHS, findRuleMatches, lineForOffset } from './secret-rules.mjs';
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
// The repository's REAL path. A lexical `relative()` check is defeated by a reparse
// point — a junction or symlink whose path reads as inside the repository while its
// target is elsewhere — and all three input routes have to share one boundary: a named
// path, the recursive walk, and `git ls-files` (what a bare `secret-scan` scans, and
// what `scripts/local-gate.mjs` runs on every push; git lists through a junction too).
// A resolved-path namespace check, not inode provenance or an OS sandbox. Hard links
// may share bytes with another name outside the root. Concurrent replacement after
// validation is outside the one-writer scan contract.
const rootReal = realpathSync(root);
const args = process.argv.slice(2);
// Resolve a path and decide whether it is inside the repository. Returns the real path,
// or null when it escapes / cannot be resolved (broken link, unreadable parent).
function resolveInsideRepo(absPath) {
let real;
try { real = realpathSync(absPath); } catch { return null; }
const rel = relative(rootReal, real);
if (rel === '') return real; // the repository root itself
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return null;
return real;
}
function refuseOutside(what, absPath) {
// Two different failures used to share one sentence, so an operator (or a test
// matching /resolves outside/) could not tell a boundary violation from a path that
// simply could not be resolved. Say which one fired.
const why = existsSync(absPath)
? `resolves outside ${root}`
: `cannot be resolved (nothing at that path)`;
console.error(`secret-scan: ${what} ${why} (${absPath}) — refusing to report a scan that did not cover its input`);
process.exit(2);
}
// `test/` was skipped wholesale until 2026-08. Audit round 10 measured the cost:
// of ~2333 added lines in a typical round, ~1100 were never scanned — and a real key
// pasted into a fixture is exactly as leaked as one in src/. So test/ IS scanned now.
//
// What made the blanket skip tempting is that fixtures legitimately contain key-SHAPED
// strings (8 in the tree when this changed, all of the `sk-ws-01-fixturekey…` /
// `sk-1234567890…` form). Those are allow-listed by SHAPE in scripts/secret-rules.mjs,
// not by path: a fixture
// must look obviously synthetic to pass. A random-looking 20-char secret in a test file
// still fails the scan, which is the point.
//
// `test/_research/` was the last path-based hole. It is four wire-capture fixtures and
// scans clean under the current rules (verified 2026-10-23, exit 0), so there was no
// measured reason to keep exempting it — and a real token pasted into a captured
// response is exactly the leak this scanner exists to catch. Shape markers are the only
// allowance a test file gets.
//
// SCOPE: this scans the WORKING TREE (`git ls-files -co`, plus explicit paths). A
// credential that was committed and later deleted is invisible here — that is
// `scripts/history-secret-scan.mjs`, which scans every blob in the repository's history
// with the SAME rules (scripts/secret-rules.mjs) and runs in the `history-secrets` CI job.
// Both scopes are needed: this one sees the files an operator has now, the other sees the
// ones that were deleted after being leaked.
const IGNORED_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.zip', '.db',
]);
function toRepoPath(file) {
return relative(root, resolve(root, file)).split(sep).join('/');
}
/**
* The spelling a file is reached by is not its identity: a link inside the repo can
* name an ignored file through a path the allow-lists below do not know, and following
* that link would then report the scanner's own source (which carries a key-shaped
* fixture string) as a finding. Decide the allow-lists on the resolved path, so
* ignoring is a property of the file rather than of the route taken to it.
*/
function resolvedRepoPath(file) {
try {
return relative(rootReal, realpathSync(resolve(root, file))).split(sep).join('/');
} catch {
return toRepoPath(file);
}
}
function isIgnored(file) {
const repoPath = toRepoPath(file);
if (!repoPath || (repoPath === '..' || repoPath.startsWith('../')) || repoPath.includes('\0')) return true;
const resolved = resolvedRepoPath(file);
if (IGNORED_PATHS.has(repoPath) || IGNORED_PATHS.has(resolved)) return true;
const lower = repoPath.toLowerCase();
const lowerResolved = resolved.toLowerCase();
return [...IGNORED_EXTENSIONS].some(ext => lower.endsWith(ext) || lowerResolved.endsWith(ext));
}
function trackedFiles() {
const output = execFileSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
});
return [...new Set(output.split('\0').filter(Boolean))];
}
// An explicit path means "scan this", and a directory means "scan what is inside it".
// Before this, a directory argument was silently dropped by scanFile's regular-file
// check, so `secret-scan logs` scanned zero files and exited 0 — a guard that read as
// "checked, clean". The runtime paths that hold raw credentials (.wire-dump/, .trace/,
// logs/) are gitignored, so the default input set can never reach them; naming the
// directory is the only way an operator can ask.
function expandInput(entry) {
const abs = resolve(root, entry);
if (!existsSync(abs)) {
console.error(`secret-scan: ${entry} does not exist — refusing to report a path that was never read as clean`);
process.exit(2);
}
// The scanner's subject is this repository. The check is on the REAL path for the same
// reason the walk below needs it: a junction's own path looks like it is inside while
// its target is not, and a path on another Windows drive makes path.relative return an
// absolute path instead of '..'.
if (!resolveInsideRepo(abs)) refuseOutside(entry, abs);
let isDirEntry;
try {
isDirEntry = statSync(abs).isDirectory();
} catch (e) {
// Removed between the existence check and here: a partial scan is not a clean scan,
// and a race must not masquerade as exit 1 (the code reserved for findings).
console.error(`secret-scan: ${toRepoPath(abs)} disappeared before it could be read (${e?.code || e?.message}) — refusing a partial scan`);
process.exit(2);
}
if (!isDirEntry) return [entry];
const files = [];
const seen = new Set([resolveInsideRepo(abs)]);
const walk = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch (e) {
console.error(`secret-scan: cannot read ${toRepoPath(dir)} (${e?.code || e?.message}) — a partial scan is not a clean scan`);
process.exit(2);
}
for (const dirent of entries) {
const full = join(dir, dirent.name);
const real = resolveInsideRepo(full);
if (real === null) {
// A link out of the repository is not a file to skip quietly: "we never looked
// there" must never read as "we looked and it was clean".
refuseOutside(toRepoPath(full), full);
}
// A link that stays inside is followed, and its real path is remembered so a
// cycle cannot make the walk loop.
if (seen.has(real)) continue;
let isDir = false;
try { isDir = statSync(real).isDirectory(); }
catch { refuseOutside(toRepoPath(full), full); }
if (isDir) { seen.add(real); walk(full); }
else files.push(toRepoPath(full));
}
};
walk(abs);
return files;
}
function inputFiles() {
if (args.length) return args.flatMap(expandInput);
return trackedFiles();
}
function scanFile(file) {
const abs = resolve(root, file);
// A path that is simply absent is nothing to read — git lists tracked files that may
// have been deleted from the working tree — so existence is checked before the
// boundary, which is about where a file lives rather than whether it is there.
if (!existsSync(abs)) {
if (args.length) {
console.error('secret-scan: explicit input disappeared before read; refusing a partial scan');
process.exit(2);
}
return []; // Git may list tracked files deleted in the working tree.
}
// Then the boundary, for every file, whichever route produced it. The default input
// set comes from `git ls-files`, which walks through a junction, so a path that reads
// as inside the repository can still be a file outside it. Checked before
// isIgnored() so nothing can be dropped — and therefore reported as clean — first.
if (!resolveInsideRepo(abs)) refuseOutside(file, abs);
if (isIgnored(file)) return [];
if (!statSync(abs).isFile()) return [];
const text = readFileSync(abs, 'utf8');
const findings = [];
const repoPath = toRepoPath(file);
// Same reasoning as isIgnored(): a test fixture reached through an inside link is
// still a test fixture, so the exemption follows the resolved path. The matcher owns
// the rules AND their exemptions; this scanner only supplies the text and the paths.
const fixturePath = resolvedRepoPath(file);
for (const match of findRuleMatches(text, [repoPath, fixturePath])) {
findings.push({
path: repoPath,
line: lineForOffset(text, match.index),
rule: match.rule,
});
}
return findings;
}
const findings = inputFiles().flatMap(scanFile)
.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.rule.localeCompare(b.rule));
for (const finding of findings) {
console.log(`${finding.path}:${finding.line} ${finding.rule}`);
}
if (findings.length) process.exitCode = 1;