[object Object]

← 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

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 →