← back to Secrets Manager
TK-11683: read-only live-secret scanner for Claude session transcripts
079581137bf56405845935e719d4dc27454bda54 · 2026-09-14 01:05:52 -0700 · Steve Abrams
Sweeps ~/.claude/projects/**/*.jsonl for high-confidence live-secret patterns
(reuses the secrets skill's routes.json leak_patterns + adds AKIA/private-key/
gitlab/sendgrid/twilio/bearer/env-key). Closes the gap where cli.js cmdAudit
never scanned the transcript tree. Emits DIGEST-ONLY findings
{file,line,pattern,last4,sha256_16} — never a raw secret value — and a
data/latest.json verdict in the fleet-health PASS/WARN/FAIL vocabulary
(unmeasured input is never a false PASS). Ships --test negative test proving it
FLAGS an injected fake secret + ignores a clean/placeholder line + goes FAIL +
leaks no raw value. Detection logic shared via transcript-secret-lib.mjs so the
(gated) redactor masks exactly what the scanner flags. Scan output dir gitignored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreA scan-transcripts.mjsA transcript-secret-lib.mjs
Diff
commit 079581137bf56405845935e719d4dc27454bda54
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Sep 14 01:05:52 2026 -0700
TK-11683: read-only live-secret scanner for Claude session transcripts
Sweeps ~/.claude/projects/**/*.jsonl for high-confidence live-secret patterns
(reuses the secrets skill's routes.json leak_patterns + adds AKIA/private-key/
gitlab/sendgrid/twilio/bearer/env-key). Closes the gap where cli.js cmdAudit
never scanned the transcript tree. Emits DIGEST-ONLY findings
{file,line,pattern,last4,sha256_16} — never a raw secret value — and a
data/latest.json verdict in the fleet-health PASS/WARN/FAIL vocabulary
(unmeasured input is never a false PASS). Ships --test negative test proving it
FLAGS an injected fake secret + ignores a clean/placeholder line + goes FAIL +
leaks no raw value. Detection logic shared via transcript-secret-lib.mjs so the
(gated) redactor masks exactly what the scanner flags. Scan output dir gitignored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 5 ++
scan-transcripts.mjs | 183 ++++++++++++++++++++++++++++++++++++++++++++++
transcript-secret-lib.mjs | 83 +++++++++++++++++++++
3 files changed, 271 insertions(+)
diff --git a/.gitignore b/.gitignore
index d0e0c4b..33717f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,8 @@ gmc-sa-146735262.json
# dedicated prod Browserbase creds — plaintext, never commit
stage/new-bb-prod.env
stage/*.env
+
+# TK-11683 transcript scanner/redactor outputs — backups hold ORIGINAL transcript
+# bytes (raw secrets); reports/restore-maps are redacted but regenerated. Never commit.
+data/transcript-redact/
+data/transcript-scan/
diff --git a/scan-transcripts.mjs b/scan-transcripts.mjs
new file mode 100644
index 0000000..22920ee
--- /dev/null
+++ b/scan-transcripts.mjs
@@ -0,0 +1,183 @@
+#!/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;
+ const unreadableFiles = [];
+
+ const measurable = fs.existsSync(projectsDir);
+ const files = measurable ? (listTranscripts(projectsDir) || []) : [];
+ const enumOk = measurable && files !== null;
+
+ for (const f of files) {
+ let text;
+ try { text = fs.readFileSync(f, 'utf8'); scanned++; }
+ catch { 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
+ 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 });
+
+ const pass = dirtyFlagged && !cleanFlagged && r.verdict === 'FAIL' && !rawLeaked;
+ 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(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));
+}
diff --git a/transcript-secret-lib.mjs b/transcript-secret-lib.mjs
new file mode 100644
index 0000000..697bca0
--- /dev/null
+++ b/transcript-secret-lib.mjs
@@ -0,0 +1,83 @@
+// transcript-secret-lib.mjs — shared high-confidence secret detection for the
+// Claude Code transcript scanner + redactor (TK-11683).
+//
+// WHY A SHARED LIB: the redactor MUST mask exactly what the scanner FLAGS. If the
+// two carried independent copies of the pattern list they would drift, and the
+// gated sweep memo's count would no longer match what redaction actually touches
+// (the producer/consumer false-green class). One source of truth prevents that.
+//
+// HARD SECURITY RULE: nothing here ever emits a raw secret value. Findings carry
+// {file, line, pattern, hc, last4, sha256_16} only.
+
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+
+// ─── high-confidence patterns ────────────────────────────────────────────────
+// Reuse the `secrets` skill's leak_patterns (routes.json) verbatim, then add the
+// provider prefixes the ticket calls out that aren't already there. Each pattern
+// carries capture group 1 = the token where the regex has a var-name prefix, else
+// the whole match. `hc: true` = high-confidence (drives the FAIL verdict / is what
+// the redactor masks); the heuristic env-leak/bearer patterns are hc:false
+// (reported, but do NOT alone FAIL and are NOT masked by the redactor).
+export function loadPatterns(routesPath) {
+ let base = [];
+ try {
+ const routes = JSON.parse(fs.readFileSync(routesPath, 'utf8'));
+ base = (routes.leak_patterns || []).map(p => ({ ...p, hc: true }));
+ } catch { /* routes.json missing → still run with the additions below */ }
+
+ const additions = [
+ // provider prefixes explicitly named in TK-11683, not in routes.json
+ { name: 'aws-access-key-id', regex: 'AKIA[0-9A-Z]{16}', hc: true },
+ { name: 'aws-secret-access-key', regex: 'aws_secret_access_key\\s*[=:]\\s*["\']?([A-Za-z0-9/+]{40})', hc: true },
+ { name: 'gitlab-pat', regex: 'glpat-[A-Za-z0-9_-]{20,}', hc: true },
+ { name: 'sendgrid-key', regex: 'SG\\.[A-Za-z0-9_-]{22}\\.[A-Za-z0-9_-]{43}', hc: true },
+ { name: 'twilio-sid', regex: 'AC[0-9a-fA-F]{32}', hc: true },
+ { name: 'private-key-block', regex: '-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----', hc: true },
+ // heuristic (reported, redacted in the report, but not a sole FAIL trigger) — noisier classes
+ { name: 'bearer-token', regex: 'Bearer\\s+([A-Za-z0-9._~+/-]{24,}=*)', hc: false },
+ { name: 'env-key-assignment', regex: '(?:[A-Z0-9_]*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_?KEY|ACCESS_?KEY))\\s*[=:]\\s*["\']?([A-Za-z0-9_\\-./+]{20,})', hc: false },
+ ];
+
+ // de-dupe by name (routes.json wins if a name collides)
+ const seen = new Set(base.map(p => p.name));
+ for (const a of additions) if (!seen.has(a.name)) base.push(a);
+ return base;
+}
+
+// Values that are obviously placeholders / examples — never a real live secret.
+// Filtering these BEFORE hashing keeps the count honest; we still never emit a value.
+export function isPlaceholder(v) {
+ const s = String(v);
+ if (/^x+$/i.test(s)) return true;
+ if (/[<>${}]/.test(s)) return true; // <TOKEN>, ${VAR}, {{ }}
+ if (/^(your|my|the|example|sample|placeholder|changeme|redacted|xxx|test123|dummy|fake|none|null|undefined)/i.test(s)) return true;
+ if (/(example|placeholder|redacted|xxxxxxxx|your_?key|your_?token|dummy|abcdef123456)/i.test(s)) return true;
+ if (/^0+$/.test(s)) return true;
+ return false;
+}
+
+export function digest(value) {
+ return {
+ last4: String(value).slice(-4),
+ sha256_16: crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16),
+ };
+}
+
+// Scan one file's text; push redacted findings. Mutates `findings` and `hcSeen`.
+export function scanText(file, text, patterns, findings, hcSeen) {
+ for (const p of patterns) {
+ let re;
+ try { re = new RegExp(p.regex, 'g'); } catch { continue; }
+ let m;
+ while ((m = re.exec(text)) !== null) {
+ const raw = (m[1] !== undefined ? m[1] : m[0]);
+ if (m[0].length === 0) { re.lastIndex++; continue; } // guard zero-width
+ if (isPlaceholder(raw)) continue;
+ const line = text.slice(0, m.index).split('\n').length;
+ const d = digest(raw);
+ findings.push({ file, line, pattern: p.name, hc: !!p.hc, last4: d.last4, sha256_16: d.sha256_16 });
+ if (p.hc) hcSeen.add(d.sha256_16);
+ }
+ }
+}
← 4930415 auto-data-snapshot: 2026-09-13T03:09:46 (1 data files) — rou
·
back to Secrets Manager
·
TK-11683: reversible dry-run redactor for Claude session tra 9bbbe67 →