← 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));
}