Ctrl K
ComunidadGuías…

scripts/secret-scan.mjs

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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;