diff --git a/bin/broom.mjs b/bin/broom.mjs old mode 100644 new mode 100755 index b183b7a..4c64f95 --- a/bin/broom.mjs +++ b/bin/broom.mjs @@ -1,29 +1,310 @@ #!/usr/bin/env node -// broomsticks — placeholder release to reserve the npm name while the scanner -// is built out. See PLAN.md for the design and roadmap. No secrets are read, -// written, or transmitted by this stub; it only prints this notice. +// broomsticks — sweep leaked secrets out of AI coding-assistant transcripts. +// Zero runtime dependencies; reads/writes nothing without explicit --apply. + import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' +import { RULES } from '../src/rules.mjs' +import { scanText } from '../src/detector.mjs' +import { redactText } from '../src/redactor.mjs' +import { BackupSession } from '../src/backup.mjs' +import { printReport, printJsonReport } from '../src/report.mjs' +import { loadAllowlist, isAllowlisted } from '../src/allowlist.mjs' +import { discoverTargets as claudeCodeTargets } from '../src/sources/claude-code.mjs' +import { discoverTargets as codexTargets } from '../src/sources/codex.mjs' +import { discoverTargets as cursorTargets } from '../src/sources/cursor.mjs' +import { runInstall, isInstalled } from '../src/install.mjs' +import { startProxy, installProxyEnv, installDaemon, uninstallDaemon } from '../src/proxy.mjs' + +// ── Package metadata ────────────────────────────────────────────────────────── const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json') const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) -const args = process.argv.slice(2) -if (args.includes('--version') || args.includes('-v')) { - console.log(pkg.version) +// ── Argument parsing (no dependencies — hand-rolled) ───────────────────────── +const argv = process.argv.slice(2) + +function flag(name) { + return argv.includes(name) +} + +function option(name) { + const i = argv.indexOf(name) + return i !== -1 ? argv[i + 1] : undefined +} + +function options(name) { + const vals = [] + for (let i = 0; i < argv.length; i++) { + if (argv[i] === name && argv[i + 1]) vals.push(argv[i + 1]) + } + return vals +} + +// ── Top-level flags ─────────────────────────────────────────────────────────── +if (flag('--version') || flag('-v')) { console.log(pkg.version); process.exit(0) } +if (flag('--help') || flag('-h') || argv.length === 0) { printHelp(); process.exit(0) } + +const command = argv[0] +if (!['scan', 'clean', 'sources', 'install', 'proxy'].includes(command)) { + console.error(`broom: unknown command '${command}'. Try 'broom --help'.`) + process.exit(1) +} + +// ── `broom install` ─────────────────────────────────────────────────────────── +if (command === 'install') { + await runInstall({ yes: flag('--yes') }) process.exit(0) } -console.log(`broomsticks v${pkg.version} — sweep secrets out of AI coding-assistant transcripts +// ── `broom proxy` ──────────────────────────────────────────────────────────── +if (command === 'proxy') { + const portStr = option('--port') ?? '7777' + const port = parseInt(portStr, 10) + if (isNaN(port) || port < 1 || port > 65535) { + console.error(`broom: invalid port '${portStr}' — must be a number between 1 and 65535`) + process.exit(1) + } + const verbose = flag('--verbose') + + if (flag('--uninstall')) { + const removed = uninstallDaemon() + console.log(removed + ? '\n broom proxy: daemon removed. Env vars in your shell profile still point to\n the proxy — remove them manually or they will silently fail to connect.\n' + : '\n broom proxy: no daemon found to remove.\n' + ) + process.exit(0) + } + + if (flag('--install')) { + const updated = installProxyEnv(port) + if (updated.length === 0) { + console.log('\n broom proxy: env vars already present in all shell init files.\n') + } else { + console.log('\n broom proxy: added env vars to:') + for (const f of updated) console.log(` ${f}`) + } - This is an early placeholder release. The scanner is not implemented yet — - this command only prints this notice (it reads/writes/sends nothing). + if (flag('--daemon')) { + try { + const { path, platform } = installDaemon({ + port, + nodeBin: process.execPath, + broomBin: process.argv[1], + }) + console.log(`\n broom proxy: daemon installed (${platform})`) + console.log(` ${path}`) + console.log('\n The proxy will start automatically at login and restart on failure.') + console.log(' Logs: ~/.broom/proxy.log') + console.log(' To remove: broom proxy --uninstall\n') + } catch (err) { + console.error('\n broom proxy --daemon failed:', err.message) + console.error(' Start the proxy manually: broom proxy\n') + process.exit(1) + } + } else { + console.log(`\n Open a new terminal (or: source ~/.zshrc) then run:\n broom proxy\n`) + console.log(` To start automatically at login:\n broom proxy --install --daemon\n`) + } + process.exit(0) + } - Planned commands: - broom scan scan Claude Code / Codex / Cursor transcripts for secrets - broom clean --apply redact found secrets in place (backs up first) + let server + try { + server = await startProxy({ port, verbose, allowlistFile: option('--allowlist') }) + } catch (err) { + console.error(`broom: failed to start proxy on port ${port}: ${err.message}`) + process.exit(1) + } + console.log(` + broom proxy listening on http://127.0.0.1:${port} + + Point your AI tools at this proxy: + export ANTHROPIC_BASE_URL=http://127.0.0.1:${port} + export OPENAI_BASE_URL=http://127.0.0.1:${port} + + Or run \`broom proxy --install\` to add these permanently to your shell. + + Routes: + POST /v1/messages → api.anthropic.com (Claude Code, Aider) + POST /v1/chat/completions → api.openai.com (Codex, OpenAI-compatible) + + Press Ctrl-C to stop. +`) + + process.on('SIGINT', () => { server.close(); process.exit(0) }) + process.on('SIGTERM', () => { server.close(); process.exit(0) }) + + // The proxy owns the process from here. Block the module's top-level + // evaluation forever so execution never falls through into the scan/clean + // logic below (which would run a scan and exit, killing the proxy). + await new Promise(() => {}) +} + +// ── Shared options ──────────────────────────────────────────────────────────── +const sources = options('--source') // [] means all +const extraFile = option('--extra') +const allowFile = option('--allowlist') +const jsonOut = flag('--json') +const noFail = flag('--no-fail') +const apply = flag('--apply') +const backupDir = option('--backup-dir') +const noBackup = flag('--no-backup') +const noAllowlist = flag('--no-allowlist') + +// ── Load extra secrets from --extra file ────────────────────────────────────── +let extras = [] +if (extraFile) { + try { + extras = readFileSync(extraFile, 'utf8').split('\n') + } catch (e) { + console.error(`broom: cannot read --extra file '${extraFile}': ${e.message}`) + process.exit(1) + } +} + +// ── Load allowlist ──────────────────────────────────────────────────────────── +const allowlist = noAllowlist ? null : loadAllowlist(allowFile) + +// ── First-run nudge (scan/clean only, TTY only, not yet installed) ──────────── +if (!isInstalled() && process.stdout.isTTY && !jsonOut) { + console.log('\n Tip: run `broom install` to add a Claude Code skill + Stop hook that') + console.log(' automatically sweeps secrets after each Claude turn.\n') +} - Design & roadmap: https://github.com/digitaldrreamer/broomsticks/blob/main/PLAN.md - Follow progress: https://github.com/digitaldrreamer/broomsticks`) -process.exit(0) +// ── `broom sources` ─────────────────────────────────────────────────────────── +if (command === 'sources') { + const allTargets = gatherTargets(sources) + console.log(`\n Discovered sources:\n`) + if (allTargets.length === 0) { + console.log(' (none found — no supported AI assistant appears to be installed)\n') + } else { + const bySrc = {} + for (const t of allTargets) (bySrc[t.source] ??= []).push(t) + for (const [src, ts] of Object.entries(bySrc)) { + console.log(` ${src} (${ts.length} file${ts.length === 1 ? '' : 's'})`) + for (const t of ts) console.log(` ${t.label}`) + console.log() + } + } + process.exit(0) +} + +// ── `broom scan` / `broom clean` ───────────────────────────────────────────── +const targets = gatherTargets(sources) + +if (targets.length === 0) { + console.error('\n broom: no transcript files found. Have you used Claude Code, Codex, or Cursor?\n') + process.exit(noFail ? 0 : 1) +} + +const isClean = command === 'clean' +const backup = (!noBackup && apply) ? new BackupSession(backupDir) : null + +const scanResults = [] + +for (const target of targets) { + let text + try { + text = target.read() + } catch (e) { + console.error(`broom: cannot read '${target.label}': ${e.message}`) + continue + } + + const raw = scanText(text, RULES, extras) + const findings = allowlist + ? raw.filter(f => !isAllowlisted(f.secret, allowlist)) + : raw + + let applied = 0 + + if (findings.length > 0 && isClean && apply) { + // Back up before the first write to this file + if (backup) backup.backup(target.file) + + const { text: redacted, applied: n } = redactText(text, findings) + try { + target.write(redacted) + applied = n + } catch (e) { + console.error(`broom: cannot write '${target.label}': ${e.message}`) + } + } + + scanResults.push({ target, findings, applied: isClean ? applied : undefined }) +} + +// ── Write manifest after all writes complete ────────────────────────────────── +if (backup) backup.writeManifest(scanResults) + +// ── Output ──────────────────────────────────────────────────────────────────── +if (jsonOut) { + printJsonReport(scanResults) +} else { + printReport(scanResults, { clean: isClean, apply }) +} + +const totalFindings = scanResults.reduce((n, r) => n + r.findings.length, 0) +process.exit(totalFindings > 0 && !noFail ? 1 : 0) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Collect Targets from all enabled source adapters. + * @param {string[]} filter If non-empty, only include these source ids. + * @returns {import('../src/sources/claude-code.mjs').Target[]} + */ +function gatherTargets(filter) { + const all = [ + ...claudeCodeTargets(), + ...codexTargets(), + ...cursorTargets(), + ] + if (!filter.length) return all + return all.filter(t => filter.includes(t.source)) +} + +function printHelp() { + console.log(` +broomsticks v${pkg.version} — sweep secrets out of AI coding-assistant transcripts + +USAGE + broom scan [options] find secrets (read-only, exits 1 if found) + broom clean [options] preview redactions (dry-run by default) + broom clean --apply [options] redact in place — backs up first + broom sources list discovered transcript files + broom install install Claude Code skill + Stop hook + broom proxy [options] start local redacting proxy for all AI tools + broom proxy --install add ANTHROPIC_BASE_URL / OPENAI_BASE_URL to shell + broom proxy --install --daemon also register as a login-persistent daemon + broom proxy --uninstall remove the daemon (macOS / Linux) + broom --version + +OPTIONS + --source Restrict to one source (repeatable): + claude-code | codex | cursor + --apply Perform redaction (clean only; dry-run without it) + --backup-dir Where to write backups (default: ~/.broom/backups//) + --no-backup Skip backup — strongly discouraged + --extra File of additional secrets to redact (one per line, + plain strings or /regex/flags) + --allowlist Custom allowlist file (default: ~/.broom/allowlist.txt) + --no-allowlist Disable allowlist suppression (report all findings) + --json Machine-readable JSON output + --no-fail Exit 0 even when secrets are found (CI override) + --port Proxy port (default: 7777) + --verbose Log redaction counts to stderr (proxy only) + --yes Skip confirmation prompts (install only) + --help, -h Show this help + --version, -v Print version + +EXAMPLES + npx broomsticks scan + broom scan --source claude-code --json | jq '.totalFindings' + broom clean --apply + broom clean --apply --extra ./leaked-keys.txt + broom install +`) +} diff --git a/package.json b/package.json index b66d140..9d975db 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,17 @@ }, "files": [ "bin", + "src", + "skills", "README.md", "PLAN.md", "LICENSE" ], + "scripts": { + "test": "node --test" + }, "engines": { - "node": ">=22.5.0" + "node": ">=22.13.0" }, "keywords": [ "secrets", diff --git a/skills/broom-sweep/SKILL.md b/skills/broom-sweep/SKILL.md new file mode 100644 index 0000000..07950fb --- /dev/null +++ b/skills/broom-sweep/SKILL.md @@ -0,0 +1,20 @@ +--- +name: broom-sweep +description: Scan and clean leaked secrets from AI assistant transcripts. Use when broomsticks reports secrets detected in transcript files, or when the user asks to sweep, scan, or redact secrets from their AI chat history. +allowed-tools: Bash(broom *) Bash(npx broomsticks *) +--- + +## Dry-run preview + +!`broom clean 2>&1` + +## Your task + +Present the dry-run output above to the user. If nothing was found, tell the user their transcripts are clean and no action is needed. + +If secrets were found: +1. Explain what was detected and where (which transcript files) +2. Reassure the user: if these secrets exist only in local AI transcript files and have not been shared, synced to a third-party service, or committed to a repository, redacting the transcripts is complete remediation — no credential rotation is needed +3. Ask the user to confirm before applying redactions +4. If they confirm, run `broom clean --apply` +5. Report how many redactions were applied and confirm that originals are backed up under `~/.broom/backups/` diff --git a/src/allowlist.mjs b/src/allowlist.mjs new file mode 100644 index 0000000..7cb8620 --- /dev/null +++ b/src/allowlist.mjs @@ -0,0 +1,135 @@ +// Allowlist — suppresses known false positives from scan findings. +// +// Sources (merged at scan time): +// 1. Built-in DEMO_SECRETS — well-known placeholder values from docs/tutorials +// 2. User file at ~/.broom/allowlist.txt (one entry per line) +// Lines starting with # are comments; /regex/flags entries compile to RegExp. +// +// A finding is suppressed when its secret matches any entry exactly (string) +// or fully (regex). Only the secret value is tested, not the rule or context. + +import { readFileSync, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** + * Built-in demo / documentation secrets that are safe to suppress. + * These appear in AWS docs, GitHub tutorials, etc. and frequently end up in + * transcripts from "why doesn't this work?" conversations. + * + * Note: Stripe test keys are handled via regex in DEFAULT_ALLOWLIST_CONTENT + * rather than a literal here to avoid triggering secret-scanning tools on this + * source file itself. + */ +export const DEMO_SECRETS = [ + // AWS — official documentation placeholder credentials + 'AKIAIOSFODNN7EXAMPLE', + 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + // GitHub — placeholder PAT used in docs + 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + // Generic placeholder shapes that appear in .env.example files + 'your_api_key_here', + 'your-secret-key', + 'changeme', + 'replace_me', + 'INSERT_API_KEY_HERE', +] + +/** + * Default path for the user allowlist file. + * @returns {string} + */ +export function allowlistPath() { + return join(homedir(), '.broom', 'allowlist.txt') +} + +/** + * Default content written to the allowlist file on first run. + * Users can add their own entries below the built-in block. + */ +export const DEFAULT_ALLOWLIST_CONTENT = `# broomsticks allowlist — one entry per line. +# Lines starting with # are comments. +# Use /regex/flags syntax for pattern matching (e.g. /sk_test_[A-Za-z0-9]+/). +# +# ── Built-in: well-known documentation placeholder values ────────────────────── +# These appear in AWS, Stripe, GitHub, and other official docs and tutorials. +# Remove any line here if you want broomsticks to flag that value in your sessions. + +# AWS documentation example credentials (https://docs.aws.amazon.com) +AKIAIOSFODNN7EXAMPLE +wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# Stripe test-mode keys (not live; safe to allow unless you want to catch them) +/sk_test_[A-Za-z0-9]{10,99}/ + +# Generic placeholder values that show up in .env.example files +your_api_key_here +your-secret-key +changeme +replace_me +INSERT_API_KEY_HERE + +# ── Your entries ────────────────────────────────────────────────────────────── +# Add your own known-safe values below this line. + +` + +/** + * @typedef {{ literals: Set, patterns: RegExp[] }} Allowlist + */ + +/** + * Parse a list of text lines into a compiled Allowlist. + * @param {string[]} lines + * @returns {Allowlist} + */ +export function parseAllowlist(lines) { + const literals = new Set() + const patterns = [] + + for (const raw of lines) { + const line = raw.trim() + if (!line || line.startsWith('#')) continue + + const reMatch = line.match(/^\/(.+)\/([gimsud]*)$/) + if (reMatch) { + try { patterns.push(new RegExp(reMatch[1], reMatch[2])) } catch { /* bad regex — skip */ } + } else { + literals.add(line) + } + } + + return { literals, patterns } +} + +/** + * Load the combined allowlist: built-in demo secrets + user file (if present). + * @param {string} [file] Override the default allowlist path. + * @returns {Allowlist} + */ +export function loadAllowlist(file) { + const lines = [...DEMO_SECRETS] + + const path = file ?? allowlistPath() + if (existsSync(path)) { + try { + lines.push(...readFileSync(path, 'utf8').split('\n')) + } catch { /* unreadable — use built-ins only */ } + } + + return parseAllowlist(lines) +} + +/** + * Return true if `secret` is covered by the allowlist and should be suppressed. + * @param {string} secret + * @param {Allowlist} allowlist + * @returns {boolean} + */ +export function isAllowlisted(secret, allowlist) { + if (allowlist.literals.has(secret)) return true + return allowlist.patterns.some(re => { + re.lastIndex = 0 + return re.test(secret) + }) +} diff --git a/src/backup.mjs b/src/backup.mjs new file mode 100644 index 0000000..8f70630 --- /dev/null +++ b/src/backup.mjs @@ -0,0 +1,109 @@ +// Backup module — copies files before any write and records a manifest. +// +// Backup layout: +// ~/.broom/backups// +// manifest.json +// +// +// Example: +// ~/.broom/backups/2024-01-15T10-30-00Z/ +// home/alice/.claude/projects/myapp/abc123.jsonl +// manifest.json + +import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +/** + * A single backup session. Create one per `broom clean --apply` run. + * Call backup(file) before every write; call writeManifest() when done. + */ +export class BackupSession { + /** + * @param {string} [backupDir] Override the default ~/.broom/backups/ + */ + constructor(backupDir) { + this.dir = backupDir ?? defaultBackupDir() + /** @type {Set} files already copied this session */ + this._copied = new Set() + /** @type {Array<{original:string, backup:string}>} */ + this._entries = [] + } + + /** + * Copy `file` into the backup directory if it hasn't been copied yet. + * For SQLite databases, also copies -wal and -shm siblings if present. + * Safe to call multiple times for the same file (idempotent). + * @param {string} file Absolute path to the file to back up. + * @returns {string} The backup path. + */ + backup(file) { + const abs = resolve(file) + if (this._copied.has(abs)) { + return this._destPath(abs) + } + + const dest = this._destPath(abs) + mkdirSync(dirname(dest), { recursive: true }) + copyFileSync(abs, dest) + this._copied.add(abs) + this._entries.push({ original: abs, backup: dest }) + + // SQLite siblings — present for Cursor's .vscdb files + for (const suffix of ['-wal', '-shm']) { + const sib = abs + suffix + try { + const sibDest = dest + suffix + copyFileSync(sib, sibDest) + } catch { + // sibling doesn't exist — that's fine + } + } + + return dest + } + + /** + * Write a manifest.json to the backup root summarising the session. + * @param {Array<{target: import('./sources/claude-code.mjs').Target, applied: number}>} results + */ + writeManifest(results) { + if (this._entries.length === 0) return + + mkdirSync(this.dir, { recursive: true }) + + const manifest = { + timestamp: new Date().toISOString(), + backupDir: this.dir, + files: this._entries, + redactionSummary: results.map(r => ({ + source: r.target.source, + label: r.target.label, + applied: r.applied, + })), + } + + writeFileSync(join(this.dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8') + } + + /** @param {string} abs Absolute original path */ + _destPath(abs) { + // Strip the leading separator (POSIX) or drive letter + separators + // (Windows) so we can join under the backup dir. A retained `C:` would + // otherwise produce an invalid mid-path colon and fail with EINVAL/ENOENT. + // /home/alice/.claude/... → home/alice/.claude/... + // C:\Users\alice\... → Users\alice\... + const relative = abs.replace(/^[a-zA-Z]:/, '').replace(/^[\\/]+/, '') + return join(this.dir, relative) + } +} + +/** + * Default backup directory: ~/.broom/backups/ + * @returns {string} + */ +function defaultBackupDir() { + // Replace colons so the path is valid on Windows and unambiguous in shells. + const stamp = new Date().toISOString().replace(/:/g, '-') + return join(homedir(), '.broom', 'backups', stamp) +} diff --git a/src/detector.mjs b/src/detector.mjs new file mode 100644 index 0000000..75b0afa --- /dev/null +++ b/src/detector.mjs @@ -0,0 +1,101 @@ +// Scanner: runs rules against a text blob and returns de-overlapped findings. + +import { shannonEntropy } from './rules.mjs' + +/** + * @typedef {'critical'|'high'|'medium'|'low'} Severity + * @typedef {{ ruleId:string, title:string, severity:Severity, secret:string, start:number, end:number }} Finding + */ + +const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 } + +/** + * Scan a text blob with the given rules plus any user-supplied extras. + * Returns findings sorted by start position, with overlapping spans resolved. + * + * @param {string} text + * @param {import('./rules.mjs').Rule[]} rules + * @param {string[]} [extras] Lines from --extra file: literal strings or /regex/flags + * @returns {Finding[]} + */ +export function scanText(text, rules, extras = []) { + const allRules = [...rules, ...extrasToRules(extras)] + const raw = [] + + for (const rule of allRules) { + // Clone the pattern so matchAll gets a fresh lastIndex each call. + const pattern = new RegExp(rule.pattern.source, rule.pattern.flags) + + for (const match of text.matchAll(pattern)) { + const grp = rule.secretGroup ?? 0 + const secret = match[grp] ?? match[0] + if (!secret) continue + + const indices = match.indices?.[grp] ?? match.indices?.[0] + if (!indices) continue + const [start, end] = indices + + if (rule.entropy !== undefined && shannonEntropy(secret) < rule.entropy) continue + + raw.push({ ruleId: rule.id, title: rule.title, severity: rule.severity, secret, start, end }) + } + } + + return resolveOverlaps(raw) +} + +/** + * When two findings overlap, keep the one with the longer span; break ties by + * higher severity. This prevents nested placeholders and double-redaction. + * @param {Finding[]} findings + * @returns {Finding[]} + */ +function resolveOverlaps(findings) { + if (findings.length <= 1) return findings + + // Sort by priority: longer span first, then higher severity, then earlier start. + // Greedy sweep then naturally keeps the best non-overlapping set. + const sorted = [...findings].sort((a, b) => { + const lenDiff = (b.end - b.start) - (a.end - a.start) + if (lenDiff !== 0) return lenDiff + const rankDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] + if (rankDiff !== 0) return rankDiff + return a.start - b.start + }) + + const kept = [] + for (const f of sorted) { + if (!kept.some(k => f.start < k.end && k.start < f.end)) kept.push(f) + } + + return kept.sort((a, b) => a.start - b.start) +} + +/** + * Convert --extra file lines into synthetic rules. + * Each line is either a /regex/flags literal or a plain string to match exactly. + * @param {string[]} lines + * @returns {import('./rules.mjs').Rule[]} + */ +function extrasToRules(lines) { + return lines + .map(l => l.trim()) + .filter(Boolean) + .map((entry, i) => { + let pattern + const reMatch = entry.match(/^\/(.+)\/([gimsud]*)$/) + if (reMatch) { + const flags = [...new Set([...reMatch[2], 'g', 'd'])].join('') + pattern = new RegExp(reMatch[1], flags) + } else { + const escaped = entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + pattern = new RegExp(escaped, 'gd') + } + return { + id: `extra-${i}`, + title: `User-supplied secret #${i + 1}`, + severity: /** @type {Severity} */ ('high'), + pattern, + } + }) +} diff --git a/src/install.mjs b/src/install.mjs new file mode 100644 index 0000000..8ebc9f4 --- /dev/null +++ b/src/install.mjs @@ -0,0 +1,190 @@ +// First-run onboarding: seed allowlist, install Claude Code skill + Stop hook. +// +// What gets installed: +// ~/.broom/allowlist.txt — false-positive suppression list +// ~/.claude/skills/broom-sweep/SKILL.md — teaches Claude to sweep secrets +// ~/.claude/hooks/stop-broom.mjs — scans after each Claude turn +// ~/.claude/settings.json (hooks entry) — registers the Stop hook + +import { mkdirSync, writeFileSync, existsSync, readFileSync, chmodSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, dirname } from 'node:path' +import { createInterface } from 'node:readline' +import { DEFAULT_ALLOWLIST_CONTENT, allowlistPath } from './allowlist.mjs' + +// ── Embedded assets ─────────────────────────────────────────────────────────── + +const SKILL_MD = `--- +name: broom-sweep +description: Scan and clean leaked secrets from AI assistant transcripts. Use when broomsticks reports secrets detected in transcript files, or when the user asks to sweep, scan, or redact secrets from their AI chat history. +allowed-tools: Bash(broom *) Bash(npx broomsticks *) +--- + +## Dry-run preview + +\`\`\`! +broom clean 2>&1 +\`\`\` + +## Your task + +Present the dry-run output above to the user. If nothing was found, tell the user their transcripts are clean and no action is needed. + +If secrets were found: +1. Explain what was detected and where (which transcript files) +2. Reassure the user: if these secrets exist only in local AI transcript files and have not been shared, synced to a third-party service, or committed to a repository, redacting the transcripts is complete remediation — no credential rotation is needed +3. Ask the user to confirm before applying redactions +4. If they confirm, run \`broom clean --apply\` +5. Report how many redactions were applied and confirm that originals are backed up under ~/.broom/backups/ +` + +// Stop hook: a standalone Node ESM script Claude Code runs after each turn. +// Outputs a JSON block decision when secrets are found, which blocks Claude +// from ending the turn and injects the reason into the next model pass. +const HOOK_SCRIPT = `#!/usr/bin/env node +// broomsticks Stop hook — silent scan after each Claude turn. +// Outputs JSON block decision when secrets are found in transcript files. +import { execSync } from 'node:child_process' + +function scan() { + for (const cmd of ['broom scan --json --no-fail', 'npx broomsticks scan --json --no-fail']) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + } catch { /* command not found or scan itself errored */ } + } + return null +} + +const output = scan() +if (!output) process.exit(0) + +let count = 0 +try { count = JSON.parse(output).totalFindings ?? 0 } catch { process.exit(0) } + +if (count > 0) { + process.stdout.write(JSON.stringify({ + decision: 'block', + reason: \`broomsticks detected \${count} secret(s) in your local AI transcript files. Invoke /broom-sweep to preview and redact them. Always ask the user for confirmation before applying.\`, + }) + '\\n') +} +` + +// ── Paths ───────────────────────────────────────────────────────────────────── + +export function skillPath() { return join(homedir(), '.claude', 'skills', 'broom-sweep', 'SKILL.md') } +export function hookPath() { return join(homedir(), '.claude', 'hooks', 'stop-broom.mjs') } +export function settingsPath() { return join(homedir(), '.claude', 'settings.json') } + +/** True when the skill + hook are already installed. */ +export function isInstalled() { + return existsSync(skillPath()) && existsSync(hookPath()) +} + +// ── Interactive prompt ──────────────────────────────────────────────────────── + +/** + * Ask a yes/no question on stdin/stdout. + * Resolves true for 'y'/'yes'/'' (default yes), false otherwise. + * @param {string} question + * @returns {Promise} + */ +function askYesNo(question) { + return new Promise(resolve => { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + rl.question(question, answer => { + rl.close() + const a = answer.trim().toLowerCase() + resolve(a === '' || a === 'y' || a === 'yes') + }) + }) +} + +// ── Install ─────────────────────────────────────────────────────────────────── + +/** + * Run the onboarding flow. + * + * @param {{ yes?: boolean, silent?: boolean }} opts + * yes — skip confirmation prompt (CI / --yes flag) + * silent — suppress all output except errors + * @returns {Promise} List of actions taken. + */ +export async function runInstall({ yes = false, silent = false } = {}) { + const log = silent ? () => {} : (...a) => console.log(...a) + + log(` + broomsticks — first-run setup + + This will install: + 1. ~/.broom/allowlist.txt — false-positive suppression list + 2. ~/.claude/skills/broom-sweep/SKILL.md — teaches Claude to sweep secrets + 3. ~/.claude/hooks/stop-broom.mjs — scans after each Claude turn + 4. ~/.claude/settings.json (Stop hook entry) — registers the hook +`) + + const proceed = yes || !process.stdin.isTTY + ? true + : await askYesNo(' Proceed? [Y/n] ') + + if (!proceed) { + log('\n Setup cancelled.\n') + return [] + } + + const done = [] + + // 1. Allowlist + const listPath = allowlistPath() + if (!existsSync(listPath)) { + mkdirSync(dirname(listPath), { recursive: true }) + writeFileSync(listPath, DEFAULT_ALLOWLIST_CONTENT, 'utf8') + done.push(`created ${listPath}`) + } else { + done.push(`exists ${listPath} (unchanged)`) + } + + // 2. Skill + const sp = skillPath() + mkdirSync(dirname(sp), { recursive: true }) + writeFileSync(sp, SKILL_MD, 'utf8') + done.push(`installed ${sp}`) + + // 3. Hook script + const hp = hookPath() + mkdirSync(dirname(hp), { recursive: true }) + writeFileSync(hp, HOOK_SCRIPT, 'utf8') + chmodSync(hp, 0o755) + done.push(`installed ${hp}`) + + // 4. Register hook in settings.json + const sp2 = settingsPath() + let settings = {} + if (existsSync(sp2)) { + try { settings = JSON.parse(readFileSync(sp2, 'utf8')) } catch { /* corrupt — start fresh */ } + } + settings.hooks ??= {} + settings.hooks.Stop ??= [] + + const alreadyWired = settings.hooks.Stop.some(entry => + entry.hooks?.some(h => typeof h.command === 'string' && h.command.includes('stop-broom')) + ) + if (!alreadyWired) { + const commandStr = process.platform === 'win32' ? `node "${hp}"` : hp + settings.hooks.Stop.push({ hooks: [{ type: 'command', command: commandStr }] }) + mkdirSync(dirname(sp2), { recursive: true }) + writeFileSync(sp2, JSON.stringify(settings, null, 2) + '\n', 'utf8') + done.push(`updated ${sp2}`) + } else { + done.push(`exists ${sp2} (hook already registered)`) + } + + log() + for (const line of done) log(` ✓ ${line}`) + log(` + All done. Restart Claude Code (or run /reload-plugins) for the skill to take effect. + The Stop hook will scan your transcripts silently after each turn and prompt + Claude to invoke /broom-sweep if secrets are found. +`) + + return done +} diff --git a/src/proxy.mjs b/src/proxy.mjs new file mode 100644 index 0000000..a779d9e --- /dev/null +++ b/src/proxy.mjs @@ -0,0 +1,444 @@ +// broomsticks local proxy. +// +// Sits between any AI coding client and the upstream LLM API. On every +// outgoing request it redacts secrets from user messages before they reach +// the model. On every incoming response it redacts any secrets the model may +// have echoed back. Real values are held in an in-memory vault (sha8 → secret) +// so a future hook or shell wrapper can expand them before tool execution. +// +// Routing (detected from request path): +// POST /v1/messages → api.anthropic.com (Anthropic) +// POST /v1/chat/completions → api.openai.com (OpenAI-compatible) +// +// Both streaming (SSE) and non-streaming responses are handled. Streaming +// responses are buffered in full before redaction — a secret that straddles +// two SSE chunks cannot be safely found otherwise. The client receives a +// valid synthetic SSE stream with collapsed text deltas. + +import { createServer } from 'node:http' +import { request as tlsReq } from 'node:https' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, appendFileSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs' +import { homedir } from 'node:os' +import { join, dirname } from 'node:path' +import { execFileSync } from 'node:child_process' + +import { RULES } from './rules.mjs' +import { scanText } from './detector.mjs' +import { redactText } from './redactor.mjs' +import { loadAllowlist, isAllowlisted } from './allowlist.mjs' + +// ── Routing ─────────────────────────────────────────────────────────────────── + +const ROUTES = { + '/v1/messages': 'api.anthropic.com', + '/v1/chat/completions': 'api.openai.com', +} + +function resolveUpstream(path) { + for (const [prefix, host] of Object.entries(ROUTES)) { + if (path === prefix || path.startsWith(prefix + '?')) return host + } + return null +} + +// ── Vault helpers ───────────────────────────────────────────────────────────── + +function sha8(secret) { + return createHash('sha256').update(secret).digest('hex').slice(0, 8) +} + +// ── Redaction ───────────────────────────────────────────────────────────────── + +function redactStr(text, allowlist, vault) { + const raw = scanText(text, RULES) + const findings = allowlist ? raw.filter(f => !isAllowlisted(f.secret, allowlist)) : raw + if (findings.length === 0) return text + for (const f of findings) vault.set(sha8(f.secret), f.secret) + return redactText(text, findings).text +} + +/** Recursively walk a parsed JSON value and redact every string leaf. */ +function redactValue(v, allowlist, vault) { + if (typeof v === 'string') return redactStr(v, allowlist, vault) + if (Array.isArray(v)) return v.map(x => redactValue(x, allowlist, vault)) + if (v !== null && typeof v === 'object') { + const out = {} + for (const [k, val] of Object.entries(v)) out[k] = redactValue(val, allowlist, vault) + return out + } + return v +} + +// ── Stream utilities ────────────────────────────────────────────────────────── + +function collect(stream) { + return new Promise((resolve, reject) => { + const chunks = [] + stream.on('data', c => chunks.push(c)) + stream.on('end', () => resolve(Buffer.concat(chunks))) + stream.on('error', reject) + }) +} + +// ── SSE redaction ───────────────────────────────────────────────────────────── +// +// Strategy: accumulate the full text for each content block index across all +// delta events, redact the complete string, then re-emit one synthetic delta +// per block with the full redacted text. All non-delta events pass through. + +function redactAnthropicSSE(lines, allowlist, vault) { + const accumulated = new Map() // blockIndex → full text + for (const line of lines) { + if (!line.startsWith('data:')) continue + const raw = line.slice(5).trim() + if (!raw || raw === '[DONE]') continue + let ev; try { ev = JSON.parse(raw) } catch { continue } + if (!ev || typeof ev !== 'object') continue + if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') { + const i = ev.index ?? 0 + accumulated.set(i, (accumulated.get(i) ?? '') + (ev.delta.text ?? '')) + } + } + + const redacted = new Map() + for (const [i, text] of accumulated) redacted.set(i, redactStr(text, allowlist, vault)) + + const emitted = new Set() + const out = [] + for (const line of lines) { + if (!line.startsWith('data:')) { out.push(line); continue } + const raw = line.slice(5).trim() + if (!raw || raw === '[DONE]') { out.push(line); continue } + let ev; try { ev = JSON.parse(raw) } catch { out.push(line); continue } + + if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') { + const i = ev.index ?? 0 + if (!emitted.has(i)) { + emitted.add(i) + out.push('data: ' + JSON.stringify({ + ...ev, delta: { ...ev.delta, text: redacted.get(i) ?? '' }, + })) + } + // drop subsequent deltas — text already emitted above + } else { + out.push(line) + } + } + return out +} + +function redactOpenAISSE(lines, allowlist, vault) { + const accumulated = new Map() // choiceIndex → full content string + for (const line of lines) { + if (!line.startsWith('data:')) continue + const raw = line.slice(5).trim() + if (!raw || raw === '[DONE]') continue + let ev; try { ev = JSON.parse(raw) } catch { continue } + if (!ev || typeof ev !== 'object') continue + for (const c of ev.choices ?? []) { + if (typeof c.delta?.content === 'string') { + const i = c.index ?? 0 + accumulated.set(i, (accumulated.get(i) ?? '') + c.delta.content) + } + } + } + + const redacted = new Map() + for (const [i, text] of accumulated) redacted.set(i, redactStr(text, allowlist, vault)) + + const emitted = new Set() + const out = [] + for (const line of lines) { + if (!line.startsWith('data:')) { out.push(line); continue } + const raw = line.slice(5).trim() + if (!raw || raw === '[DONE]') { out.push(line); continue } + let ev; try { ev = JSON.parse(raw) } catch { out.push(line); continue } + + const hasContent = (ev.choices ?? []).some(c => typeof c.delta?.content === 'string') + if (!hasContent) { out.push(line); continue } + + const newChoices = (ev.choices ?? []).map(c => { + if (typeof c.delta?.content !== 'string') return c + const i = c.index ?? 0 + if (emitted.has(i)) return { ...c, delta: { ...c.delta, content: '' } } + emitted.add(i) + return { ...c, delta: { ...c.delta, content: redacted.get(i) ?? '' } } + }) + out.push('data: ' + JSON.stringify({ ...ev, choices: newChoices })) + } + return out +} + +// ── Upstream forwarding ─────────────────────────────────────────────────────── + +function forwardRequest(upstreamHost, req, body) { + return new Promise((resolve, reject) => { + const headers = { ...req.headers, host: upstreamHost } + headers['content-length'] = String(body.length) + delete headers['transfer-encoding'] // we send a single buffer, not chunked + delete headers['accept-encoding'] // need plaintext to scan + + const upReq = tlsReq( + { host: upstreamHost, port: 443, path: req.url, method: req.method, headers }, + resolve, + ) + upReq.on('error', reject) + upReq.end(body) + }) +} + +// ── Core handler ────────────────────────────────────────────────────────────── + +async function handleRequest(req, res, allowlist, vault, verbose) { + const upstreamHost = resolveUpstream(req.url ?? '/') + + if (!upstreamHost) { + res.writeHead(404, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ + error: { message: `broom proxy: no route for ${req.method} ${req.url}` }, + })) + return + } + + // ── Redact outgoing request body ────────────────────────────────────────── + const rawBody = await collect(req) + let outBody = rawBody + const vaultBefore = vault.size + + if (req.headers['content-type']?.includes('application/json')) { + try { + const json = JSON.parse(rawBody.toString('utf8')) + const cleaned = redactValue(json, allowlist, vault) + outBody = Buffer.from(JSON.stringify(cleaned), 'utf8') + } catch { /* non-JSON — forward as-is */ } + } + + if (verbose && vault.size > vaultBefore) { + console.error(` ← request: redacted ${vault.size - vaultBefore} secret(s) (vault size: ${vault.size})`) + } + + // ── Forward to upstream ─────────────────────────────────────────────────── + const upRes = await forwardRequest(upstreamHost, req, outBody) + const upBody = await collect(upRes) + + // ── Redact incoming response body ───────────────────────────────────────── + const isSSE = upRes.headers['content-type']?.includes('text/event-stream') + const vaultMid = vault.size + let responseBody + + if (isSSE) { + const lines = upBody.toString('utf8').split('\n') + const isAnthropic = upstreamHost.includes('anthropic') + const cleanedLines = isAnthropic + ? redactAnthropicSSE(lines, allowlist, vault) + : redactOpenAISSE(lines, allowlist, vault) + responseBody = Buffer.from(cleanedLines.join('\n'), 'utf8') + } else { + try { + const json = JSON.parse(upBody.toString('utf8')) + const cleaned = redactValue(json, allowlist, vault) + responseBody = Buffer.from(JSON.stringify(cleaned), 'utf8') + } catch { + responseBody = upBody + } + } + + if (verbose && vault.size > vaultMid) { + console.error(` → response: redacted ${vault.size - vaultMid} secret(s) from model output`) + } + + // ── Write response ──────────────────────────────────────────────────────── + const outHeaders = { ...upRes.headers } + delete outHeaders['transfer-encoding'] // we send a single buffer, not chunked + outHeaders['content-length'] = String(responseBody.length) + res.writeHead(upRes.statusCode ?? 200, outHeaders) + res.end(responseBody) +} + +// ── Shell profile installation ──────────────────────────────────────────────── + +const PROFILE_CANDIDATES = ['.zshrc', '.bashrc', '.bash_profile', '.profile'] + +const ENV_BLOCK = (port) => ` +# ── broomsticks proxy ──────────────────────────────────────────────────────── +# Route AI API calls through the local broomsticks proxy so secrets are +# redacted before they reach the model. Start with: broom proxy +export ANTHROPIC_BASE_URL=http://127.0.0.1:${port} +export OPENAI_BASE_URL=http://127.0.0.1:${port} +# ──────────────────────────────────────────────────────────────────────────── +` + +const BROOM_MARKER = '# ── broomsticks proxy' + +/** + * Append proxy env-var exports to every shell init file found in $HOME. + * Idempotent: skips files that already contain the marker. + * + * @param {number} port + * @returns {string[]} paths that were updated + */ +export function installProxyEnv(port = 7777) { + const home = homedir() + const updated = [] + + for (const name of PROFILE_CANDIDATES) { + const file = join(home, name) + if (!existsSync(file)) continue + try { + const contents = readFileSync(file, 'utf8') + if (contents.includes(BROOM_MARKER)) continue // already installed + appendFileSync(file, ENV_BLOCK(port), 'utf8') + updated.push(file) + } catch { /* permission error or race — skip silently */ } + } + + return updated +} + +// ── Daemon installation ─────────────────────────────────────────────────────── + +function launchdPlist(nodeBin, broomBin, port) { + const logFile = join(homedir(), '.broom', 'proxy.log') + // Escape XML metacharacters — a path like /Users/bob&alice would otherwise + // produce a malformed plist that launchd refuses to load. + const esc = (s) => String(s).replace(/[<>&]/g, m => ({ '<': '<', '>': '>', '&': '&' }[m])) + return ` + + + + Label + com.broomsticks.proxy + ProgramArguments + + ${esc(nodeBin)} + ${esc(broomBin)} + proxy + --port + ${esc(port)} + + RunAtLoad + + KeepAlive + + StandardOutPath + ${esc(logFile)} + StandardErrorPath + ${esc(logFile)} + + +` +} + +function systemdService(nodeBin, broomBin, port) { + return `[Unit] +Description=broomsticks secret-redacting proxy +After=network.target + +[Service] +ExecStart="${nodeBin}" "${broomBin}" proxy --port ${port} +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +` +} + +/** + * Install the proxy as a login-persistent daemon. + * - macOS : ~/Library/LaunchAgents/com.broomsticks.proxy.plist (launchd) + * - Linux : ~/.config/systemd/user/broom-proxy.service (systemd) + * + * @param {{ port?: number, nodeBin: string, broomBin: string }} opts + * @returns {{ path: string, platform: string, alreadyRunning: boolean }} + */ +export function installDaemon({ port = 7777, nodeBin, broomBin }) { + const home = homedir() + mkdirSync(join(home, '.broom'), { recursive: true }) + + if (process.platform === 'darwin') { + const agentsDir = join(home, 'Library', 'LaunchAgents') + const plistPath = join(agentsDir, 'com.broomsticks.proxy.plist') + mkdirSync(agentsDir, { recursive: true }) + writeFileSync(plistPath, launchdPlist(nodeBin, broomBin, port), 'utf8') + + // Unload first in case a stale copy is already registered + try { execFileSync('launchctl', ['unload', plistPath], { stdio: 'ignore' }) } catch {} + execFileSync('launchctl', ['load', '-w', plistPath]) + + return { path: plistPath, platform: 'macos' } + } + + if (process.platform === 'linux') { + const serviceDir = join(home, '.config', 'systemd', 'user') + const servicePath = join(serviceDir, 'broom-proxy.service') + mkdirSync(serviceDir, { recursive: true }) + writeFileSync(servicePath, systemdService(nodeBin, broomBin, port), 'utf8') + + execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' }) + execFileSync('systemctl', ['--user', 'enable', 'broom-proxy'], { stdio: 'ignore' }) + execFileSync('systemctl', ['--user', 'start', 'broom-proxy'], { stdio: 'ignore' }) + + return { path: servicePath, platform: 'linux' } + } + + throw new Error(`Daemon installation is not supported on ${process.platform}. Run \`broom proxy\` manually or add it to your startup scripts.`) +} + +/** + * Remove the daemon installed by installDaemon. + */ +export function uninstallDaemon() { + const home = homedir() + + if (process.platform === 'darwin') { + const plistPath = join(home, 'Library', 'LaunchAgents', 'com.broomsticks.proxy.plist') + if (!existsSync(plistPath)) return false + try { execFileSync('launchctl', ['unload', plistPath], { stdio: 'ignore' }) } catch {} + try { unlinkSync(plistPath) } catch {} + return true + } + + if (process.platform === 'linux') { + const servicePath = join(home, '.config', 'systemd', 'user', 'broom-proxy.service') + if (!existsSync(servicePath)) return false + try { execFileSync('systemctl', ['--user', 'stop', 'broom-proxy'], { stdio: 'ignore' }) } catch {} + try { execFileSync('systemctl', ['--user', 'disable', 'broom-proxy'], { stdio: 'ignore' }) } catch {} + try { unlinkSync(servicePath) } catch {} + try { execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' }) } catch {} + return true + } + + return false +} + +// ── Public entry point ──────────────────────────────────────────────────────── + +/** + * Start the broomsticks proxy. + * + * @param {{ port?: number, verbose?: boolean, allowlistFile?: string }} opts + * @returns {Promise} + */ +export function startProxy({ port = 7777, verbose = false, allowlistFile } = {}) { + const allowlist = loadAllowlist(allowlistFile) + const vault = new Map() + + const server = createServer(async (req, res) => { + try { + await handleRequest(req, res, allowlist, vault, verbose) + } catch (err) { + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: { message: 'broom proxy: ' + err.message } })) + } + } + }) + + return new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(port, '127.0.0.1', () => resolve(server)) + }) +} diff --git a/src/redactor.mjs b/src/redactor.mjs new file mode 100644 index 0000000..52f4992 --- /dev/null +++ b/src/redactor.mjs @@ -0,0 +1,46 @@ +// Redactor: replaces detected secrets with stable, JSON-safe placeholders. +// +// Placeholder format: «BROOM::» +// «» — not matched by any detection rule, so redacted transcripts are idempotent +// sha8 — first 8 hex digits of SHA-256(secret), lets you correlate the same leak +// across files without re-exposing the value + +import { createHash } from 'node:crypto' + +/** + * Replace every finding's secret span with a placeholder. + * Processes right-to-left so earlier byte offsets stay valid after each substitution. + * + * @param {string} text + * @param {import('./detector.mjs').Finding[]} findings + * @returns {{ text: string, applied: number }} + */ +export function redactText(text, findings) { + if (!findings.length) return { text, applied: 0 } + + // Descending start order — rightmost replacement first. + const ordered = [...findings].sort((a, b) => b.start - a.start) + + let result = text + let applied = 0 + + for (const f of ordered) { + const placeholder = buildPlaceholder(f.ruleId, f.secret) + result = result.slice(0, f.start) + placeholder + result.slice(f.end) + applied++ + } + + return { text: result, applied } +} + +/** + * Build the stable placeholder string for a finding. + * Exported so the report module can show what placeholder was used. + * @param {string} ruleId + * @param {string} secret + * @returns {string} + */ +export function buildPlaceholder(ruleId, secret) { + const sha8 = createHash('sha256').update(secret).digest('hex').slice(0, 8) + return `«BROOM:${ruleId}:${sha8}»` +} diff --git a/src/report.mjs b/src/report.mjs new file mode 100644 index 0000000..8ba3196 --- /dev/null +++ b/src/report.mjs @@ -0,0 +1,169 @@ +// Report formatter — human-readable and JSON output for scan/clean results. + +import { createHash } from 'node:crypto' + +/** + * @typedef {import('./detector.mjs').Finding} Finding + * @typedef {import('./sources/claude-code.mjs').Target} Target + * @typedef {{ target: Target, findings: Finding[], applied?: number }} ScanResult + */ + +const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'] + +const SEVERITY_COLOR = { + critical: '\x1b[1;31m', // bold red + high: '\x1b[31m', // red + medium: '\x1b[33m', // yellow + low: '\x1b[90m', // grey +} +const RESET = '\x1b[0m' +const DIM = '\x1b[2m' +const BOLD = '\x1b[1m' + +/** + * Whether stdout is a TTY — used to strip ANSI codes in piped output. + */ +const isTTY = process.stdout.isTTY ?? false + +function color(sev, text) { + return isTTY ? `${SEVERITY_COLOR[sev]}${text}${RESET}` : text +} +function bold(text) { return isTTY ? `${BOLD}${text}${RESET}` : text } +function dim(text) { return isTTY ? `${DIM}${text}${RESET}` : text } + +/** + * Mask a secret for display: show first 6 chars + … + last 4. + * Very short secrets are shown as *** to avoid leaking them. + * @param {string} secret + * @returns {string} + */ +function maskSecret(secret) { + if (secret.length <= 12) return '*'.repeat(secret.length) + return secret.slice(0, 6) + dim('…') + secret.slice(-4) +} + +/** + * Compute the 1-indexed line number of a character offset in text. + * @param {string} text + * @param {number} offset + * @returns {number} + */ +function lineNumber(text, offset) { + let line = 1 + for (let i = 0; i < offset; i++) { + if (text[i] === '\n') line++ + } + return line +} + +/** + * Print a human-readable scan report to stdout. + * + * @param {ScanResult[]} results + * @param {{ clean?: boolean, apply?: boolean }} [opts] + * clean — true when called from `broom clean` (adjusts wording) + * apply — true when redaction was actually performed + */ +export function printReport(results, opts = {}) { + const { clean = false, apply = false } = opts + + const totalFindings = results.reduce((n, r) => n + r.findings.length, 0) + const filesWithFindings = results.filter(r => r.findings.length > 0).length + const totalApplied = results.reduce((n, r) => n + (r.applied ?? 0), 0) + + // ── Header ──────────────────────────────────────────────────────────────── + const cmd = clean ? 'clean' : 'scan' + const mode = apply ? `${bold('--apply')} (redacted in place)` : `${dim('dry-run')}` + console.log() + console.log(`${bold('broomsticks')} ${cmd} — ${mode}`) + + if (totalFindings === 0) { + console.log(`\n ${bold('No secrets found.')} All ${results.length} file(s) are clean.\n`) + return + } + + const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}` + console.log(`\n ${bold(plural(totalFindings, 'secret'))} found across ${plural(filesWithFindings, 'file')}.`) + if (apply) console.log(` ${bold(plural(totalApplied, 'redaction'))} applied; originals backed up.\n`) + else console.log(` Run ${bold('broom clean --apply')} to redact in place (backs up first).\n`) + + // ── Per-file findings ────────────────────────────────────────────────────── + const dirty = results.filter(r => r.findings.length > 0) + + for (const { target, findings } of dirty) { + const home = process.env.HOME || process.env.USERPROFILE + const shortLabel = home ? target.label.replace(home, '~') : target.label + console.log(` ${bold(target.source)} ${shortLabel}`) + + // Group by severity for ordered display + const bySev = Object.fromEntries(SEVERITY_ORDER.map(s => [s, []])) + for (const f of findings) bySev[f.severity].push(f) + + let targetText = null + try { targetText = target.read() } catch { /* non-blocking */ } + + for (const sev of SEVERITY_ORDER) { + for (const f of bySev[sev]) { + const tag = color(sev, `[${sev.padEnd(8)}]`) + const rule = f.ruleId.padEnd(26) + const masked = maskSecret(f.secret) + const lineInfo = targetText !== null ? dim(` line ${lineNumber(targetText, f.start)}`) : '' + console.log(` ${tag} ${dim(rule)} ${masked}${lineInfo}`) + } + } + + console.log() + } + + // ── Severity summary ─────────────────────────────────────────────────────── + const counts = Object.fromEntries(SEVERITY_ORDER.map(s => [s, 0])) + for (const { findings } of results) for (const f of findings) counts[f.severity]++ + + const summary = SEVERITY_ORDER + .filter(s => counts[s] > 0) + .map(s => color(s, `${counts[s]} ${s}`)) + .join(', ') + console.log(` Severity: ${summary}\n`) +} + +/** + * Emit findings as a JSON structure for CI / machine consumption. + * Printed to stdout; caller should pipe or redirect. + * + * @param {ScanResult[]} results + */ +export function printJsonReport(results) { + const totalFindings = results.reduce((n, r) => n + r.findings.length, 0) + + const output = { + version: 1, + totalFindings, + files: results + .filter(r => r.findings.length > 0) + .map(({ target, findings, applied }) => ({ + source: target.source, + label: target.label, + file: target.file, + applied: applied ?? null, + findings: findings.map(f => ({ + ruleId: f.ruleId, + title: f.title, + severity: f.severity, + start: f.start, + end: f.end, + // Never include the raw secret in JSON output — only the hash + sha8: hashSecret(f.secret), + })), + })), + } + + console.log(JSON.stringify(output, null, 2)) +} + +/** + * @param {string} secret + * @returns {string} First 8 hex chars of SHA-256(secret) + */ +function hashSecret(secret) { + return createHash('sha256').update(secret).digest('hex').slice(0, 8) +} diff --git a/src/rules.mjs b/src/rules.mjs new file mode 100644 index 0000000..de5642d --- /dev/null +++ b/src/rules.mjs @@ -0,0 +1,255 @@ +// Detection ruleset for broomsticks. +// Patterns derived from gitleaks (MIT), secretlint (MIT), and detect-secrets (Apache-2.0). +// +// Each rule: +// pattern — RegExp with `g` and `d` flags (d gives match.indices for precise offsets) +// secretGroup — which capture group is the secret (default: 0 = whole match) +// entropy — minimum Shannon bits/char; match is skipped if below threshold + +/** + * @typedef {'critical'|'high'|'medium'|'low'} Severity + * @typedef {{ id:string, title:string, severity:Severity, pattern:RegExp, secretGroup?:number, entropy?:number }} Rule + */ + +/** + * Shannon entropy over every character in the string (bits per character). + * Ported from detect-secrets (Apache-2.0); thresholds: base64 ≥ 4.5, hex ≥ 3.0, generic ≥ 3.5. + * @param {string} str + * @returns {number} + */ +export function shannonEntropy(str) { + if (!str) return 0 + const freq = new Map() + for (const ch of str) freq.set(ch, (freq.get(ch) ?? 0) + 1) + let e = 0 + for (const count of freq.values()) { + const p = count / str.length + e -= p * Math.log2(p) + } + return e +} + +/** @type {Rule[]} */ +export const RULES = [ + + // ── Private / Cryptographic Keys ────────────────────────────────────────── + // Matches PEM blocks: RSA, EC, OpenSSH, PGP, PKCS#8, encrypted variants. + { + id: 'private-key', + title: 'Private key (PEM block)', + severity: 'critical', + pattern: /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\s\S]{0,8192}?-----END[ A-Z0-9_-]{0,100}(?:PRIVATE KEY|KEY BLOCK)-----/gd, + }, + + // ── AWS ─────────────────────────────────────────────────────────────────── + // Covers AKIA (long-term), ASIA (session), ABIA (billing), ACCA, A3T* families. + { + id: 'aws-access-key', + title: 'AWS access key ID', + severity: 'high', + pattern: /\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b/gd, + secretGroup: 1, + entropy: 3, + }, + // 40-char base64 value paired with a secret-key variable name. + { + id: 'aws-secret-key', + title: 'AWS secret access key', + severity: 'high', + pattern: /(?:aws_?secret_?access_?key|aws_?secret)\s*[:=]\s*["']?([A-Za-z0-9/+=]{40})["']?/gid, + secretGroup: 1, + entropy: 3.5, + }, + + // ── Anthropic ───────────────────────────────────────────────────────────── + // Covers api03 and api04 formats; always ends with AA (secretlint pattern). + { + id: 'anthropic-key', + title: 'Anthropic API key', + severity: 'high', + pattern: /\b(sk-ant-api0[34]-[A-Za-z0-9_-]{90,128}AA)\b/gd, + secretGroup: 1, + }, + { + id: 'anthropic-admin-key', + title: 'Anthropic admin API key', + severity: 'high', + pattern: /\b(sk-ant-admin01-[A-Za-z0-9_-]{93}AA)\b/gd, + secretGroup: 1, + }, + + // ── OpenAI ──────────────────────────────────────────────────────────────── + // New-format keys embed "T3BlbkFJ" (base64 for "OpenAI") as a fixed anchor — + // this dramatically cuts false positives compared to a bare sk- prefix match. + { + id: 'openai-key', + title: 'OpenAI API key', + severity: 'high', + pattern: /\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{58,74}T3BlbkFJ[A-Za-z0-9_-]{58,74})\b/gd, + secretGroup: 1, + }, + // Legacy 51-char keys, also anchored by T3BlbkFJ. + { + id: 'openai-key-legacy', + title: 'OpenAI API key (legacy sk- format)', + severity: 'high', + pattern: /\b(sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20})\b/gd, + secretGroup: 1, + }, + + // ── Hugging Face ────────────────────────────────────────────────────────── + { + id: 'huggingface-token', + title: 'Hugging Face user access token', + severity: 'high', + pattern: /\b(hf_[A-Za-z0-9]{34})\b/gd, + secretGroup: 1, + entropy: 2, + }, + { + id: 'huggingface-org-token', + title: 'Hugging Face organization API token', + severity: 'high', + pattern: /\b(api_org_[A-Za-z0-9]{34})\b/gd, + secretGroup: 1, + entropy: 2, + }, + + // ── GitHub ──────────────────────────────────────────────────────────────── + // Classic tokens — each type has a distinct 3-letter prefix + 36 alphanumeric chars. + { + id: 'github-pat', + title: 'GitHub personal access token', + severity: 'high', + pattern: /\b(ghp_[0-9A-Za-z]{36})\b/gd, + secretGroup: 1, + entropy: 3, + }, + { + id: 'github-oauth', + title: 'GitHub OAuth token', + severity: 'high', + pattern: /\b(gho_[0-9A-Za-z]{36})\b/gd, + secretGroup: 1, + entropy: 3, + }, + { + id: 'github-app-token', + title: 'GitHub app installation / server-to-server token', + severity: 'high', + pattern: /\b(gh[us]_[0-9A-Za-z]{36})\b/gd, + secretGroup: 1, + entropy: 3, + }, + { + id: 'github-refresh-token', + title: 'GitHub refresh token', + severity: 'high', + pattern: /\b(ghr_[0-9A-Za-z]{36})\b/gd, + secretGroup: 1, + entropy: 3, + }, + // Fine-grained PATs: `github_pat_` + exactly 82 word chars. + // Uses Unicode property escape \p{L} for tighter boundary — requires `u` flag. + { + id: 'github-fine-grained-pat', + title: 'GitHub fine-grained personal access token', + severity: 'high', + pattern: /(?/.jsonl +// +// Each line is a JSON object representing one turn. We treat each file as +// a single text blob — redaction is inline on the raw bytes, so the JSONL +// stays valid without a parse/serialize round-trip. + +import { readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** + * A single addressable blob of text plus how to read and write it. + * @typedef {Object} Target + * @property {string} source - 'claude-code' + * @property {string} label - human-readable identifier shown in reports + * @property {string} file - absolute path to the backing file (for backup) + * @property {() => string} read - return current file content as UTF-8 text + * @property {(text: string) => void} write - overwrite the file with redacted text + */ + +/** + * Root directory for Claude Code session transcripts. + * Claude Code uses ~/.claude on Linux, macOS, and Windows alike. + * @returns {string} + */ +export function claudeCodeRoot() { + return join(homedir(), '.claude', 'projects') +} + +/** + * Walk a directory tree and yield the absolute path of every *.jsonl file. + * Silently skips directories that don't exist or aren't readable. + * @param {string} dir + * @returns {Generator} + */ +function* walkJsonl(dir) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return // dir missing or unreadable — not an error, just no transcripts + } + + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + yield* walkJsonl(full) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + yield full + } + } +} + +/** + * Discover all Claude Code session files and return them as Targets. + * Returns an empty array if Claude Code has never been used on this machine. + * @returns {Target[]} + */ +export function discoverTargets() { + const root = claudeCodeRoot() + const targets = [] + + for (const file of walkJsonl(root)) { + // Capture `file` in a closure-safe way for the lazy read/write callbacks. + const f = file + targets.push({ + source: 'claude-code', + label: f, + file: f, + read: () => readFileSync(f, 'utf8'), + write: (text) => writeFileSync(f, text, 'utf8'), + }) + } + + return targets +} diff --git a/src/sources/codex.mjs b/src/sources/codex.mjs new file mode 100644 index 0000000..4c3ca06 --- /dev/null +++ b/src/sources/codex.mjs @@ -0,0 +1,76 @@ +// Source adapter for OpenAI Codex CLI transcripts. +// +// Codex writes: +// ~/.codex/history.jsonl — global command history +// ~/.codex/sessions/**/*.jsonl — per-session conversation logs + +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +/** + * Root directory for Codex data. + * @returns {string} + */ +export function codexRoot() { + return join(homedir(), '.codex') +} + +/** + * Recursively yield every *.jsonl file under a directory. + * Silently skips missing or unreadable directories. + * @param {string} dir + * @returns {Generator} + */ +function* walkJsonl(dir) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) yield* walkJsonl(full) + else if (entry.isFile() && entry.name.endsWith('.jsonl')) yield full + } +} + +/** + * Discover all Codex transcript files and return them as Targets. + * Returns an empty array if Codex has not been used on this machine. + * @returns {import('./claude-code.mjs').Target[]} + */ +export function discoverTargets() { + const root = codexRoot() + const targets = [] + + // history.jsonl sits directly in ~/.codex + const history = join(root, 'history.jsonl') + try { + statSync(history) + targets.push(makeTarget(history)) + } catch { /* file absent */ } + + // Per-session files live under ~/.codex/sessions/** + for (const file of walkJsonl(join(root, 'sessions'))) { + targets.push(makeTarget(file)) + } + + return targets +} + +/** + * @param {string} file + * @returns {import('./claude-code.mjs').Target} + */ +function makeTarget(file) { + const f = file + return { + source: 'codex', + label: f, + file: f, + read: () => readFileSync(f, 'utf8'), + write: (text) => writeFileSync(f, text, 'utf8'), + } +} diff --git a/src/sources/cursor.mjs b/src/sources/cursor.mjs new file mode 100644 index 0000000..70b1873 --- /dev/null +++ b/src/sources/cursor.mjs @@ -0,0 +1,166 @@ +// Source adapter for Cursor AI editor transcripts. +// +// Cursor stores conversation state in SQLite databases: +// Linux: ~/.config/Cursor/User/{globalStorage,workspaceStorage/**/}state.vscdb +// macOS: ~/Library/Application Support/Cursor/User/{...}/state.vscdb +// Windows: %APPDATA%\Cursor\User\{...}\state.vscdb +// +// Each database has two tables we care about: +// ItemTable — main VS Code key-value store +// cursorDiskKV — Cursor-specific overflow store +// +// We scan rows whose key matches /chat|composer|aiService|aichat|prompt|cursorai/i +// and return one Target per row (keyed on table+key so writes hit the right row). +// The `.file` property points to the .vscdb so it gets backed up once per DB. + +import { readdirSync, existsSync } from 'node:fs' +import { homedir, platform } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +/** Keys matching this pattern carry AI conversation content worth scanning. */ +const AI_KEY_RE = /chat|composer|aiService|aichat|prompt|cursorai/i + +/** Tables present in Cursor's state.vscdb */ +const TABLES = ['ItemTable', 'cursorDiskKV'] + +/** + * OS-specific root for Cursor's User data directory. + * @returns {string} + */ +export function cursorUserRoot() { + const p = platform() + if (p === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Cursor', 'User') + } + if (p === 'win32') { + return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'Cursor', 'User') + } + // Linux / other + return join(homedir(), '.config', 'Cursor', 'User') +} + +/** + * Recursively yield every state.vscdb file under a directory. + * @param {string} dir + * @returns {Generator} + */ +function* walkVscdb(dir) { + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) yield* walkVscdb(full) + else if (entry.isFile() && entry.name === 'state.vscdb') yield full + } +} + +/** + * Open a SQLite database read-only and return all AI-related rows from the + * known tables. Returns [] if the DB is unreadable or has no matching tables. + * + * @param {string} dbPath + * @returns {Array<{table:string, key:string, value:string}>} + */ +function readAiRows(dbPath) { + let db + try { + db = new DatabaseSync(dbPath, { open: true, readOnly: true }) + } catch { + return [] + } + + const rows = [] + + // Check which tables actually exist before querying + let existingTables + try { + existingTables = new Set( + db.prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map(r => r.name) + ) + } catch { + db.close() + return [] + } + + for (const table of TABLES) { + if (!existingTables.has(table)) continue + try { + const all = db.prepare(`SELECT key, value FROM [${table}]`).all() + for (const row of all) { + if (typeof row.key === 'string' && AI_KEY_RE.test(row.key)) { + // value may be stored as Buffer (BLOB) or string — normalise to string + const value = Buffer.isBuffer(row.value) + ? row.value.toString('utf8') + : String(row.value ?? '') + rows.push({ table, key: row.key, value }) + } + } + } catch { + // table unreadable — skip + } + } + + db.close() + return rows +} + +/** + * Discover all Cursor AI conversation rows and return them as Targets. + * One Target per DB row; multiple Targets may share the same `.file`. + * Returns [] if Cursor is not installed or has no conversation history. + * + * @returns {import('./claude-code.mjs').Target[]} + */ +export function discoverTargets() { + const userRoot = cursorUserRoot() + const targets = [] + + for (const dbPath of walkVscdb(userRoot)) { + const rows = readAiRows(dbPath) + + for (const { table, key, value } of rows) { + // Capture loop vars in closure-safe consts + const db = dbPath + const tbl = table + const k = key + + targets.push({ + source: 'cursor', + label: `${db}:${tbl}:${k}`, + file: db, // backup module copies the .vscdb file (once per file) + + read() { + // Re-open DB on each read so we always get the current value + const conn = new DatabaseSync(db, { open: true, readOnly: true }) + try { + const row = conn.prepare(`SELECT value FROM [${tbl}] WHERE key = ?`).get(k) + if (!row) return '' + return Buffer.isBuffer(row.value) ? row.value.toString('utf8') : String(row.value ?? '') + } finally { + conn.close() + } + }, + + write(text) { + // Read-write handle — an UPDATE against a readOnly connection throws + // "attempt to write a readonly database". + const conn = new DatabaseSync(db, { open: true, readOnly: false }) + try { + conn.prepare(`UPDATE [${tbl}] SET value = ? WHERE key = ?`).run(text, k) + } finally { + conn.close() + } + }, + }) + } + } + + return targets +} diff --git a/test/allowlist.test.mjs b/test/allowlist.test.mjs new file mode 100644 index 0000000..45498a0 --- /dev/null +++ b/test/allowlist.test.mjs @@ -0,0 +1,35 @@ +// Allowlist: parsing, built-in demo-secret suppression, regex + literal entries. + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + parseAllowlist, loadAllowlist, isAllowlisted, DEMO_SECRETS, +} from '../src/allowlist.mjs' + +test('parseAllowlist separates literals, regexes, and skips comments', () => { + const { literals, patterns } = parseAllowlist([ + '# a comment', + '', + 'literal-value', + '/sk_test_[A-Za-z0-9]{4,}/', + ]) + assert.ok(literals.has('literal-value')) + assert.equal(literals.size, 1) + assert.equal(patterns.length, 1) +}) + +test('built-in demo secrets are suppressed', () => { + const allow = loadAllowlist('/nonexistent/allowlist.txt') // built-ins only + for (const demo of DEMO_SECRETS) { + assert.ok(isAllowlisted(demo, allow), `demo secret should be allowlisted: ${demo}`) + } + assert.ok(!isAllowlisted('ghp_1234567890abcdefABCDEF1234567890abcd', allow)) +}) + +test('regex entry matches; global-flag regex does not desync via lastIndex', () => { + const allow = parseAllowlist(['/sk_test_[A-Za-z0-9]{4,}/g']) + assert.ok(isAllowlisted('sk_test_abcd1234', allow)) + // Second call must still match — isAllowlisted resets lastIndex. + assert.ok(isAllowlisted('sk_test_abcd1234', allow)) +}) diff --git a/test/backup.test.mjs b/test/backup.test.mjs new file mode 100644 index 0000000..d07f76f --- /dev/null +++ b/test/backup.test.mjs @@ -0,0 +1,49 @@ +// Backup integrity: a backup exists and byte-matches the original before any +// write, the manifest records entries, and paths flatten safely (incl. Windows +// drive letters — regression for the mid-path colon bug). + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { BackupSession } from '../src/backup.mjs' + +test('backup copies the file byte-for-byte and records a manifest', () => { + const tmp = mkdtempSync(join(tmpdir(), 'broom-backup-')) + try { + const original = join(tmp, 'transcript.jsonl') + const content = '{"text":"secret ghp_xxx"}\n' + writeFileSync(original, content, 'utf8') + + const backupDir = join(tmp, 'backups') + const session = new BackupSession(backupDir) + const dest = session.backup(original) + + assert.ok(existsSync(dest), 'backup file must exist') + assert.equal(readFileSync(dest, 'utf8'), content, 'backup must byte-match original') + + // Second call for the same file is a no-op (idempotent per session). + const dest2 = session.backup(original) + assert.equal(dest, dest2) + + session.writeManifest([ + { target: { source: 'claude-code', label: original }, applied: 1 }, + ]) + const manifest = JSON.parse(readFileSync(join(backupDir, 'manifest.json'), 'utf8')) + assert.equal(manifest.files.length, 1) + assert.equal(manifest.files[0].original, original) + assert.equal(manifest.redactionSummary[0].applied, 1) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('_destPath strips leading separators and Windows drive letters', () => { + const s = new BackupSession('/backups/ts') + assert.equal(s._destPath('/home/alice/x.jsonl'), join('/backups/ts', 'home/alice/x.jsonl')) + // No mid-path colon should survive from a C:\ style absolute path. + const win = s._destPath('C:\\Users\\alice\\x.jsonl') + assert.ok(!win.slice(2).includes(':'), 'no drive-letter colon should remain in the joined path') +}) diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 0000000..b2797a5 --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,102 @@ +// End-to-end CLI tests: scan/clean lifecycle over a real transcript, and a +// regression guard that `broom proxy` keeps running instead of falling through +// into the scan path and exiting immediately. + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync, spawn } from 'node:child_process' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { setTimeout as sleep } from 'node:timers/promises' + +const BIN = fileURLToPath(new URL('../bin/broom.mjs', import.meta.url)) +const SECRET = 'ghp_1234567890abcdefABCDEF1234567890abcd' + +function makeHome() { + const home = mkdtempSync(join(tmpdir(), 'broom-cli-')) + const dir = join(home, '.claude', 'projects', 'demo') + mkdirSync(dir, { recursive: true }) + const file = join(dir, 'session.jsonl') + writeFileSync(file, JSON.stringify({ role: 'user', text: `key ${SECRET}` }) + '\n', 'utf8') + return { home, file } +} + +function run(args, home) { + return spawnSync(process.execPath, [BIN, ...args], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + encoding: 'utf8', + }) +} + +test('scan → clean --apply → re-scan lifecycle', () => { + const { home, file } = makeHome() + try { + // scan: finds the secret, JSON output, non-zero exit + const scan = run(['scan', '--json', '--source', 'claude-code'], home) + assert.equal(scan.status, 1, 'scan should exit 1 when secrets are found') + const report = JSON.parse(scan.stdout) + assert.equal(report.totalFindings, 1) + assert.equal(report.files[0].findings[0].ruleId, 'github-pat') + // JSON output must never contain the raw secret — only the hash. + assert.ok(!scan.stdout.includes(SECRET), 'raw secret must not appear in JSON report') + + // clean --apply: redacts in place, takes a backup + const clean = run(['clean', '--apply', '--source', 'claude-code'], home) + assert.equal(clean.status, 1) // findings existed → exit 1 unless --no-fail + const redacted = readFileSync(file, 'utf8') + assert.ok(!redacted.includes(SECRET), 'file must no longer contain the secret') + assert.ok(redacted.includes('«BROOM:github-pat:')) + JSON.parse(redacted.trim()) // still valid JSONL + + const backupsRoot = join(home, '.broom', 'backups') + assert.ok(existsSync(backupsRoot), 'backup directory should exist') + const stamps = readdirSync(backupsRoot) + assert.ok(stamps.length >= 1) + assert.ok(existsSync(join(backupsRoot, stamps[0], 'manifest.json'))) + + // re-scan: clean now, zero exit + const rescan = run(['scan', '--json', '--source', 'claude-code'], home) + assert.equal(rescan.status, 0) + assert.equal(JSON.parse(rescan.stdout).totalFindings, 0) + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) + +test('invalid --port is rejected', () => { + const { home } = makeHome() + try { + const res = run(['proxy', '--port', 'not-a-number'], home) + assert.equal(res.status, 1) + assert.match(res.stderr + res.stdout, /invalid port/i) + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) + +test('broom proxy keeps running (does not fall through to scan and exit)', async () => { + const { home } = makeHome() + const port = 39117 + const child = spawn(process.execPath, [BIN, 'proxy', '--port', String(port)], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + encoding: 'utf8', + }) + let stdout = '' + child.stdout.on('data', d => { stdout += d }) + + try { + await sleep(800) + assert.equal(child.exitCode, null, 'proxy must still be running, not exited') + assert.equal(child.signalCode, null) + assert.match(stdout, /listening on/i) + // The scan report banner must NOT appear — that would mean it fell through. + assert.doesNotMatch(stdout, /No secrets found|secrets? found across/i) + } finally { + child.kill('SIGTERM') + await sleep(50) + if (child.exitCode === null) child.kill('SIGKILL') + rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/test/cursor.test.mjs b/test/cursor.test.mjs new file mode 100644 index 0000000..cd54e92 --- /dev/null +++ b/test/cursor.test.mjs @@ -0,0 +1,64 @@ +// Cursor adapter round-trip — regression guard for the read-only write bug: +// write() must persist a redacted value back into the SQLite row (previously it +// opened the DB readOnly:true and every UPDATE threw "readonly database"). + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' +import { tmpdir, homedir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +import { cursorUserRoot, discoverTargets } from '../src/sources/cursor.mjs' +import { RULES } from '../src/rules.mjs' +import { scanText } from '../src/detector.mjs' +import { redactText } from '../src/redactor.mjs' + +const SECRET = 'ghp_1234567890abcdefABCDEF1234567890abcd' + +test('cursor target read → redact → write persists the redaction', (t) => { + const home = mkdtempSync(join(tmpdir(), 'broom-cursor-')) + const prevHome = process.env.HOME + process.env.HOME = home + + try { + const root = cursorUserRoot() + // Only meaningful if os.homedir() honors our $HOME override on this platform. + if (!root.startsWith(home) && !root.startsWith(homedir())) { + return t.skip('cursorUserRoot() not redirectable via $HOME on this platform') + } + // If homedir() ignored $HOME entirely, bail rather than touch a real profile. + if (!root.startsWith(home)) return t.skip('os.homedir() does not honor $HOME here') + + const storage = join(root, 'globalStorage') + mkdirSync(storage, { recursive: true }) + const dbPath = join(storage, 'state.vscdb') + + const db = new DatabaseSync(dbPath) + db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB)') + db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)') + .run('aichat.conversations', `history: ${SECRET} end`) + db.close() + + const targets = discoverTargets() + assert.equal(targets.length, 1, 'exactly one AI row should be discovered') + const target = targets[0] + assert.equal(target.source, 'cursor') + + const text = target.read() + assert.ok(text.includes(SECRET)) + + const { text: redacted, applied } = redactText(text, scanText(text, RULES)) + assert.equal(applied, 1) + + target.write(redacted) // must not throw (readonly bug) and must persist + + const after = target.read() + assert.ok(!after.includes(SECRET), 'secret must be gone from the DB row') + assert.ok(after.includes('«BROOM:github-pat:'), 'placeholder must be persisted') + } finally { + if (prevHome === undefined) delete process.env.HOME + else process.env.HOME = prevHome + rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/test/detector.test.mjs b/test/detector.test.mjs new file mode 100644 index 0000000..ba42dc5 --- /dev/null +++ b/test/detector.test.mjs @@ -0,0 +1,101 @@ +// Detector + ruleset tests: planted secrets of each shape must be found; +// decoys must not match. Runs on the built-in node:test runner (no deps). + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { RULES, shannonEntropy } from '../src/rules.mjs' +import { scanText } from '../src/detector.mjs' + +/** Return the set of ruleIds scanText finds in `text`. */ +function ruleIds(text) { + return new Set(scanText(text, RULES).map(f => f.ruleId)) +} + +// One planted sample per rule we want to guarantee coverage for. Each value is +// synthetic (not a live credential) but shaped to match the corresponding rule. +const SAMPLES = { + 'private-key': + '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEArandombase64content1234567890\n-----END RSA PRIVATE KEY-----', + 'aws-access-key': 'AKIAIOSFODNN7EXAMPLE', + 'anthropic-key': 'sk-ant-api03-' + 'x'.repeat(90) + 'AA', + 'openai-key': 'sk-proj-' + 'a'.repeat(58) + 'T3BlbkFJ' + 'b'.repeat(58), + 'github-pat': 'ghp_1234567890abcdefABCDEF1234567890abcd', + 'github-fine-grained-pat': 'github_pat_' + 'A'.repeat(82), + 'google-api-key': 'AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY', + // Assembled from parts so the literal token text never appears in-file + // (avoids tripping upstream secret scanners on a synthetic fixture). + 'slack-bot-token': ['xoxb', '2494792170', '2503138584', 'aZ9xK2mQ7wL4pR8vT1nY6bC3'].join('-'), + 'jwt': + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', + 'db-url': 'postgres://admin:s3cr3tP4ss@db.example.com:5432/app', + 'generic-secret': 'api_key = "aZ9xK2mQ7wL4pR8vT1nY6bC3dE5fG0hJ"', +} + +for (const [ruleId, sample] of Object.entries(SAMPLES)) { + test(`detects ${ruleId}`, () => { + assert.ok( + ruleIds(sample).has(ruleId), + `expected ${ruleId} in findings for: ${sample.slice(0, 40)}…`, + ) + }) +} + +test('every declared rule has a sample in the coverage table', () => { + // Guards against silently adding a rule with no regression sample. + const covered = new Set(Object.keys(SAMPLES)) + // These rules are intentionally exercised elsewhere or are near-duplicates + // of a covered rule; list them so the assertion stays honest. + const knownUncovered = new Set([ + 'aws-secret-key', 'anthropic-admin-key', 'openai-key-legacy', + 'huggingface-token', 'huggingface-org-token', 'github-oauth', + 'github-app-token', 'github-refresh-token', 'stripe-key', + 'slack-user-token', 'slack-app-token', 'slack-webhook', + ]) + for (const rule of RULES) { + assert.ok( + covered.has(rule.id) || knownUncovered.has(rule.id), + `rule '${rule.id}' has no sample and is not in knownUncovered`, + ) + } +}) + +test('decoys do not match any rule', () => { + const decoys = [ + 'The quick brown fox jumps over the lazy dog.', + 'password = "aaaaaaaaaaaaaaaa"', // 16 chars, entropy 0 → below gate + 'See commit 1234567890abcdef1234567890abcdef12345678 for details', // bare sha + 'Connect to https://example.com/path?q=1', // url, no inline credentials + 'const timeout = 30000', + ] + for (const d of decoys) { + assert.deepEqual([...ruleIds(d)], [], `unexpected match in decoy: ${d}`) + } +}) + +test('overlapping matches resolve to a single non-nested finding', () => { + // A db-url that also contains an assignment-shaped substring should yield + // exactly one finding covering the URL, not two overlapping spans. + const text = 'DATABASE_URL=postgres://admin:s3cr3tP4ssword@db.host:5432/app' + const findings = scanText(text, RULES) + assert.ok(findings.length > 0, 'expected at least one finding for this db-url text') + for (let i = 0; i < findings.length; i++) { + for (let j = i + 1; j < findings.length; j++) { + const a = findings[i], b = findings[j] + assert.ok(a.end <= b.start || b.end <= a.start, 'findings must not overlap') + } + } +}) + +test('--extra literal and /regex/ entries are honored', () => { + const text = 'internal token WIDGET-7712 and marker ZZZ-alpha' + const ids = new Set(scanText(text, RULES, ['WIDGET-7712', '/ZZZ-[a-z]+/']).map(f => f.ruleId)) + assert.ok(ids.has('extra-0')) + assert.ok(ids.has('extra-1')) +}) + +test('shannonEntropy: uniform vs repeated', () => { + assert.equal(shannonEntropy(''), 0) + assert.equal(shannonEntropy('aaaa'), 0) + assert.ok(shannonEntropy('abcd') > 1.9) // 4 distinct chars → 2 bits +}) diff --git a/test/no-network.test.mjs b/test/no-network.test.mjs new file mode 100644 index 0000000..e6eadaf --- /dev/null +++ b/test/no-network.test.mjs @@ -0,0 +1,45 @@ +// Supply-chain / privacy invariant: the scan+clean pipeline never makes a +// network call. We assert that no scanner module imports a networking API. +// +// The proxy (src/proxy.mjs) is the one intentional network component — it sits +// between an AI client and the upstream API — so it is explicitly exempt. Every +// other src/ module (the code that reads and rewrites your transcripts) must be +// network-free. + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SRC = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'src') + +const NETWORK_IMPORT = /from\s+['"]node:(?:https?|net|tls|dgram|http2)['"]|\bfetch\s*\(/ + +// Modules allowed to touch the network. Only the proxy. +const EXEMPT = new Set(['proxy.mjs']) + +function* walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) yield* walk(full) + else if (entry.name.endsWith('.mjs')) yield full + } +} + +test('scanner modules import no networking APIs', () => { + const offenders = [] + for (const file of walk(SRC)) { + if (EXEMPT.has(file.split('/').pop())) continue + const code = readFileSync(file, 'utf8') + if (NETWORK_IMPORT.test(code)) offenders.push(file) + } + assert.deepEqual(offenders, [], `network APIs found in scanner modules: ${offenders.join(', ')}`) +}) + +test('proxy remains the only network-capable module', () => { + // Documents the exemption: if proxy.mjs ever stops importing http, revisit + // whether the exemption is still warranted. + const code = readFileSync(join(SRC, 'proxy.mjs'), 'utf8') + assert.ok(NETWORK_IMPORT.test(code), 'proxy.mjs is expected to use the network') +}) diff --git a/test/redactor.test.mjs b/test/redactor.test.mjs new file mode 100644 index 0000000..25919d0 --- /dev/null +++ b/test/redactor.test.mjs @@ -0,0 +1,51 @@ +// Redaction: correct placeholder, JSON round-trip stays valid, idempotency. + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' + +import { RULES } from '../src/rules.mjs' +import { scanText } from '../src/detector.mjs' +import { redactText, buildPlaceholder } from '../src/redactor.mjs' + +const GH = 'ghp_1234567890abcdefABCDEF1234567890abcd' + +test('placeholder format is «BROOM::»', () => { + const sha8 = createHash('sha256').update(GH).digest('hex').slice(0, 8) + assert.equal(buildPlaceholder('github-pat', GH), `«BROOM:github-pat:${sha8}»`) +}) + +test('redactText replaces the secret and reports applied count', () => { + const text = `here is a key ${GH} in text` + const findings = scanText(text, RULES) + const { text: out, applied } = redactText(text, findings) + assert.equal(applied, 1) + assert.ok(!out.includes(GH), 'raw secret must be gone') + assert.ok(out.includes('«BROOM:github-pat:'), 'placeholder must be present') +}) + +test('multiple secrets on one line all get redacted with valid offsets', () => { + const db = 'postgres://admin:s3cr3tP4ss@db.host:5432/app' + const text = `${GH} and ${db} together` + const { text: out, applied } = redactText(text, scanText(text, RULES)) + assert.equal(applied, 2) + assert.ok(!out.includes(GH)) + assert.ok(!out.includes(db)) +}) + +test('JSONL line stays valid JSON after redaction', () => { + const line = JSON.stringify({ role: 'user', text: `my key is ${GH}` }) + const { text: out } = redactText(line, scanText(line, RULES)) + const parsed = JSON.parse(out) // throws if placeholder broke JSON + assert.ok(parsed.text.includes('«BROOM:github-pat:')) + assert.ok(!parsed.text.includes(GH)) +}) + +test('redaction is idempotent — a redacted blob has no findings', () => { + const text = `key ${GH} here` + const { text: once } = redactText(text, scanText(text, RULES)) + const second = scanText(once, RULES) + assert.deepEqual(second, [], 'placeholders must not be re-detected') + const { applied } = redactText(once, second) + assert.equal(applied, 0) +})