← back to Secrets Manager

scan-transcripts.mjs

245 lines

#!/usr/bin/env node
// scan-transcripts.mjs — READ-ONLY live-secret scanner for Claude Code session
// transcripts under ~/.claude/projects/**/*.jsonl  (TK-11683).
//
// WHY: the `secrets` skill's audit (cli.js cmdAudit) sweeps ~/.claude/skills,
// ~/.claude/agents, ~/Projects, ~/cncp-starter — but NOT ~/.claude/projects, and
// not the .jsonl extension. Claude session transcripts are durable, unswept,
// unencrypted, and outside every .gitignore-based control. This closes that gap.
//
// CRITICAL SECURITY RULE (hard): the REPORT must NEVER contain a raw secret value.
// Every finding is emitted as {file, line, pattern, last4, sha256_16} only —
// mirroring how the secrets registry stores "…last4:sha256-prefix". Copying the
// secret out of the transcript into a new file would be a NEW leak, so we don't.
//
// Usage:
//   node scan-transcripts.mjs              # scan ~/.claude/projects, write report + latest.json
//   node scan-transcripts.mjs --json       # also print the full JSON report to stdout
//   node scan-transcripts.mjs --test       # NEGATIVE TEST: fake fixture in a temp dir (no real secrets)
//
// Read-only: opens transcripts for reading, never edits/deletes them. Redaction
// of the transcripts themselves is a DESTRUCTIVE, GATED action handled separately.

import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { loadPatterns as loadPatternsLib, scanText } from './transcript-secret-lib.mjs';

const HOME = os.homedir();
const ROOT = path.dirname(new URL(import.meta.url).pathname);
const ROUTES = path.join(ROOT, 'routes.json');
const OUT_DIR = path.join(ROOT, 'data', 'transcript-scan');

// Detection logic (patterns, placeholder filter, digest, scanText) lives in the
// shared transcript-secret-lib.mjs so the redactor masks EXACTLY what this flags.
const loadPatterns = () => loadPatternsLib(ROUTES);

function listTranscripts(dir) {
  // find, excluding nothing (transcripts aren't in node_modules); bounded buffer.
  try {
    return execSync(`find "${dir}" -type f -name '*.jsonl' 2>/dev/null`, {
      encoding: 'utf8', maxBuffer: 1024 * 1024 * 200,
    }).split('\n').filter(Boolean);
  } catch { return null; }
}

function runScan(projectsDir) {
  const patterns = loadPatterns();
  const findings = [];
  const hcSeen = new Set();          // distinct high-confidence secret digests
  let scanned = 0, unreadable = 0, vanished = 0;
  const unreadableFiles = [];

  const measurable = fs.existsSync(projectsDir);
  const files = measurable ? (listTranscripts(projectsDir) || []) : [];
  const enumOk = measurable && files !== null;

  // Synchronous short sleep (no busy-spin) — used only on the rare retry path.
  const sleepMs = (ms) => {
    try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
    catch { const until = Date.now() + ms; while (Date.now() < until) {} }
  };

  // Read every transcript. A LIVE transcript can be caught mid-write or rotated
  // between enumeration (find) and read (TK-11795): the first read then fails.
  // Two failure modes, handled distinctly so neither NOISES the canary nor opens
  // a false-green:
  //   • transient (file briefly locked / partial) → retry ONCE after a short
  //     delay; a single retry clears the mid-write race.
  //   • VANISHED (ENOENT — the file was deleted/rotated away) → it no longer
  //     exists on disk, so it cannot hold a persistent secret. Excluding it is
  //     NOT a measurement gap (nothing to certify), so it does NOT flip to WARN.
  //     A genuinely-unreadable file (a real, non-ENOENT error surviving the
  //     retry) still counts as unreadable → WARN (fail-safe, cannot certify clean).
  //
  // RECORDED TRADEOFF (codex-check via Grok, 2026-09-16): ENOENT could in theory
  // be an atomic-rename to a DIFFERENT .jsonl not in this run's enumeration, which
  // would let a persistent secret go unscanned this cycle. Accepted deliberately
  // because: (a) Claude Code appends to a stable <uuid>.jsonl and does NOT rename
  // transcripts between .jsonl names — the observed vanish is pure delete/rotate;
  // (b) this is a SCHEDULED durable-state canary, so any file that truly persists
  // is caught on the NEXT run once it is stable in the enumeration (a miss is
  // single-cycle, self-healing); (c) the strict alternative (WARN on every ENOENT)
  // reintroduces the chronic-WARN noise that masks a real FAIL as "just the flaky
  // canary" (TK-11795) — the worse failure. Flip to strict WARN only if Claude
  // Code's transcript write model ever changes to cross-name renames.
  for (const f of files) {
    let text;
    try { text = fs.readFileSync(f, 'utf8'); scanned++; }
    catch (e1) {
      if (e1 && e1.code === 'ENOENT') { vanished++; continue; }
      sleepMs(150);
      try { text = fs.readFileSync(f, 'utf8'); scanned++; }
      catch (e2) {
        if (e2 && e2.code === 'ENOENT') { vanished++; continue; }
        unreadable++; unreadableFiles.push(f); continue;
      }
    }
    scanText(f, text, patterns, findings, hcSeen);
  }

  const hcFindings = findings.filter(x => x.hc);
  const heurFindings = findings.filter(x => !x.hc);
  const affectedFiles = [...new Set(findings.map(x => x.file))];
  const hcFiles = [...new Set(hcFindings.map(x => x.file))];

  // Verdict (fleet-health-rollup vocabulary). Per TK-11431 rule 1: an unmeasured
  // input is NEVER a false PASS.
  //   FAIL  = at least one HIGH-CONFIDENCE live-secret pattern matched (real leak)
  //   WARN  = only heuristic matches, OR the input could not be fully measured
  //           (projects dir missing / enumeration failed / unreadable files)
  //   PASS  = measured, files scanned > 0, zero HC findings, zero unreadable
  let verdict, note;
  if (!measurable || !enumOk) {
    verdict = 'WARN';
    note = !measurable ? 'projects dir not found — NOT MEASURED' : 'transcript enumeration failed — NOT MEASURED';
  } else if (hcFindings.length > 0) {
    verdict = 'FAIL';
    note = `${hcSeen.size} distinct high-confidence live secret(s) in ${hcFiles.length} transcript(s)`;
  } else if (unreadable > 0) {
    verdict = 'WARN';
    note = `${unreadable} transcript(s) unreadable — NOT MEASURED (cannot certify clean)`;
  } else if (scanned === 0) {
    verdict = 'WARN';
    note = 'zero transcripts scanned — NOT MEASURED';
  } else if (heurFindings.length > 0) {
    verdict = 'WARN';
    note = `${heurFindings.length} heuristic (non-high-confidence) match(es) — review`;
  } else {
    verdict = 'PASS';
    note = 'no live secrets detected in transcripts';
  }

  return {
    ts: new Date().toISOString(),
    verdict, status: verdict, note,               // both keys for the rollup normalizer
    projectsDir,
    population: files.length,                      // total transcripts discovered
    scanned,                                       // successfully read
    unreadable,                                    // NOT MEASURED (real read error)
    vanished,                                      // deleted/rotated mid-scan — excluded, not a gap
    findings_total: findings.length,
    high_confidence: hcFindings.length,
    distinct_hc_secrets: hcSeen.size,
    heuristic: heurFindings.length,
    affected_files: affectedFiles.length,
    hc_affected_files: hcFiles.length,
    // REDACTED findings only — never a raw value.
    findings,
    unreadable_sample: unreadableFiles.slice(0, 20),
  };
}

// ─── NEGATIVE TEST (TK-11431 amendment 3) ────────────────────────────────────
// Prove the detector FLAGS an injected FAKE secret and does NOT flag a clean line.
// Uses a throwaway temp dir + FAKE values — never a real secret, never the real
// transcript tree.
function runTest() {
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tk11683-negtest-'));
  const sub = path.join(tmp, '-Users-fake-Projects-demo');
  fs.mkdirSync(sub, { recursive: true });
  const dirty = path.join(sub, 'dirty.jsonl');
  const clean = path.join(sub, 'clean.jsonl');

  // FAKE secrets — syntactically valid shapes, not real credentials.
  const FAKE_GOOGLE = 'AIza' + 'B'.repeat(35);                 // google-api-key
  const FAKE_AWS = 'AKIA' + 'ABCDEFGHIJKLMNOP';                // aws-access-key-id (AKIA + 16)
  const FAKE_GHP = 'ghp_' + 'a'.repeat(36);                    // github-pat
  fs.writeFileSync(dirty, [
    JSON.stringify({ type: 'user', text: `here is a key ${FAKE_GOOGLE} in a transcript` }),
    JSON.stringify({ type: 'assistant', text: `aws id ${FAKE_AWS} and ${FAKE_GHP}` }),
  ].join('\n') + '\n');
  // Clean file: prose that must NOT match (placeholder + ordinary text).
  fs.writeFileSync(clean, [
    JSON.stringify({ type: 'user', text: 'set GOOGLE_API_KEY=<YOUR_KEY_HERE> and move on' }),
    JSON.stringify({ type: 'assistant', text: 'The quick brown fox discussed AIza but printed no key.' }),
  ].join('\n') + '\n');

  const r = runScan(tmp);
  const dirtyFlagged = r.findings.some(f => f.file === dirty && f.hc);
  const cleanFlagged = r.findings.some(f => f.file === clean && f.hc);
  const rawLeaked = JSON.stringify(r).includes(FAKE_GOOGLE) || JSON.stringify(r).includes(FAKE_AWS) || JSON.stringify(r).includes(FAKE_GHP);

  fs.rmSync(tmp, { recursive: true, force: true });

  // Scenario 2 (TK-11795 fail-safe direction) — a GENUINELY unreadable transcript
  // (EACCES, exists but can't be read) with NO secret must STILL yield WARN, never
  // a silent PASS. Proves the retry-then-count-unreadable path preserves the
  // "cannot certify clean" fail-safe after the mid-write-race fix.
  let unreadableIsWarn = true, unreadableCounted = true;
  try {
    const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'tk11683-negtest2-'));
    const sub2 = path.join(tmp2, '-Users-fake-Projects-perm');
    fs.mkdirSync(sub2, { recursive: true });
    fs.writeFileSync(path.join(sub2, 'ok.jsonl'), JSON.stringify({ type: 'user', text: 'nothing secret here' }) + '\n');
    const locked = path.join(sub2, 'locked.jsonl');
    fs.writeFileSync(locked, JSON.stringify({ type: 'user', text: 'placeholder' }) + '\n');
    fs.chmodSync(locked, 0o000);   // EACCES on read (non-ENOENT) — a real read error
    const r2 = runScan(tmp2);
    unreadableIsWarn = r2.verdict === 'WARN';
    unreadableCounted = r2.unreadable === 1 && (r2.vanished ?? 0) === 0;
    try { fs.chmodSync(locked, 0o644); } catch {}
    fs.rmSync(tmp2, { recursive: true, force: true });
  } catch { unreadableIsWarn = false; unreadableCounted = false; }

  const pass = dirtyFlagged && !cleanFlagged && r.verdict === 'FAIL' && !rawLeaked &&
    unreadableIsWarn && unreadableCounted;
  console.log('NEGATIVE TEST');
  console.log(`  flags injected fake secret:      ${dirtyFlagged ? 'YES ✓' : 'NO ✗'}`);
  console.log(`  ignores clean/placeholder line:  ${!cleanFlagged ? 'YES ✓' : 'NO ✗ (false positive!)'}`);
  console.log(`  verdict on injected fault:       ${r.verdict} ${r.verdict === 'FAIL' ? '✓' : '✗'}`);
  console.log(`  report contains NO raw secret:   ${!rawLeaked ? 'YES ✓' : 'NO ✗ (LEAK!)'}`);
  console.log(`  genuinely-unreadable → WARN:     ${unreadableIsWarn ? 'YES ✓' : 'NO ✗ (false green!)'}`);
  console.log(`  unreadable counted (not vanished): ${unreadableCounted ? 'YES ✓' : 'NO ✗'}`);
  console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL');
  process.exit(pass ? 0 : 1);
}

// ─── main ────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
if (args.includes('--test')) { runTest(); }
else {
  const projectsDir = path.join(HOME, '.claude', 'projects');
  const report = runScan(projectsDir);

  fs.mkdirSync(OUT_DIR, { recursive: true });
  const latest = path.join(OUT_DIR, 'latest.json');
  const stamped = path.join(OUT_DIR, `report-${report.ts.replace(/[:.]/g, '-')}.json`);
  fs.writeFileSync(latest, JSON.stringify(report, null, 2));
  fs.writeFileSync(stamped, JSON.stringify(report, null, 2));

  console.log(`transcript-scan: ${report.verdict} — ${report.note}`);
  console.log(`  population(discovered)=${report.population} scanned=${report.scanned} unreadable=${report.unreadable}`);
  console.log(`  findings: total=${report.findings_total} high_confidence=${report.high_confidence} (distinct=${report.distinct_hc_secrets}) heuristic=${report.heuristic}`);
  console.log(`  affected transcripts: ${report.affected_files} (high-confidence: ${report.hc_affected_files})`);
  if (report.high_confidence) {
    console.log('  high-confidence findings (REDACTED — digest only):');
    for (const f of report.findings.filter(x => x.hc)) {
      console.log(`    ${f.pattern.padEnd(22)} ${f.file}:${f.line}  …${f.last4}:${f.sha256_16}`);
    }
  }
  console.log(`  report → ${latest}`);
  if (args.includes('--json')) console.log(JSON.stringify(report, null, 2));
}