← back to Secrets Manager

transcript-secret-lib.mjs

84 lines

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