#!/usr/bin/env node /** * scan-credentials.js — 内置凭证泄漏扫描器 * * 替代 gitleaks(Bookworm 零运行时 dep 原则)。 * 检测 history.jsonl / evolution-log.jsonl / route-feedback.jsonl 等可能含凭证的文件。 * * Usage: * node scripts/patches/scan-credentials.js # 扫描默认目标 * node scripts/patches/scan-credentials.js --fix # 扫描后调用 sanitize 重写 * node scripts/patches/scan-credentials.js --file

# 扫描指定文件 */ 'use strict'; const fs = require('fs'); const path = require('path'); const CLAUDE_ROOT = path.join(__dirname, '..', '..'); let sanitize; try { sanitize = require('../sanitize.js').sanitize; } catch { sanitize = (x) => x; } const DEFAULT_TARGETS = [ 'history.jsonl', 'evolution-log.jsonl', 'debug/route-feedback.jsonl', 'logs/', ]; const DETECT_PATTERNS = [ { name: 'OpenAI/Anthropic sk-', re: /\bsk-[A-Za-z0-9_-]{18,}\b/ }, { name: 'Anthropic sk-ant-', re: /\bsk-ant-[A-Za-z0-9_-]{18,}\b/ }, { name: 'GitHub PAT ghp_', re: /\bghp_[A-Za-z0-9]{20,}\b/ }, { name: 'GitHub Fine-grained', re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ }, { name: 'GitHub OAuth gho_', re: /\bgho_[A-Za-z0-9]{20,}\b/ }, { name: 'Slack Bot xoxb-', re: /\bxoxb-[A-Za-z0-9-]{10,}\b/ }, { name: 'Groq gsk_', re: /\bgsk_[A-Za-z0-9_-]{10,}\b/ }, { name: 'Google AIza', re: /\bAIza[0-9A-Za-z\-_]{30,}\b/ }, { name: 'npm npm_', re: /\bnpm_[A-Za-z0-9]{30,}\b/ }, { name: 'Perplexity pplx-', re: /\bpplx-[A-Za-z0-9_-]{30,}\b/ }, { name: 'AWS AKIA', re: /\bAKIA[A-Z0-9]{16}\b/ }, { name: 'JWT eyJ', re: /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/ }, { name: 'Bearer header', re: /Authorization\s*:\s*Bearer\s+[A-Za-z0-9._\-+=]{30,}/i }, { name: 'Telegram bot', re: /\b\d{8,}:[A-Za-z0-9_-]{30,}\b/ }, { name: 'PEM private key', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ }, ]; const args = process.argv.slice(2); const fix = args.includes('--fix'); const fileIdx = args.indexOf('--file'); const targetFile = fileIdx >= 0 ? args[fileIdx + 1] : null; const findings = []; function scanFile(absPath) { if (!fs.existsSync(absPath)) return; const stat = fs.statSync(absPath); if (stat.isDirectory()) { for (const entry of fs.readdirSync(absPath)) scanFile(path.join(absPath, entry)); return; } if (!stat.isFile()) return; if (stat.size > 50 * 1024 * 1024) { process.stderr.write(`[SKIP] ${absPath} too large\n`); return; } const lines = fs.readFileSync(absPath, 'utf8').split(/\r?\n/); for (let i = 0; i < lines.length; i++) { for (const { name, re } of DETECT_PATTERNS) { const m = lines[i].match(re); if (m) { findings.push({ file: path.relative(CLAUDE_ROOT, absPath), line: i + 1, type: name, sample: m[0].slice(0, 30) + (m[0].length > 30 ? '...' : ''), }); } } } } const targets = targetFile ? [targetFile] : DEFAULT_TARGETS; process.stdout.write(`[SCAN] root=${CLAUDE_ROOT} targets=${targets.length}\n`); for (const t of targets) scanFile(path.isAbsolute(t) ? t : path.join(CLAUDE_ROOT, t)); process.stdout.write(`\n[RESULT] findings=${findings.length}\n`); if (findings.length === 0) { process.stdout.write('[OK] no credentials detected\n'); process.exit(0); } process.stdout.write('\n=== FINDINGS ===\n'); for (const f of findings) { process.stdout.write(` ${f.file}:${f.line} [${f.type}] sample="${f.sample}"\n`); } if (fix) { process.stdout.write('\n[FIX MODE] rewriting files with sanitize()...\n'); const fileSet = new Set(findings.map(f => f.file)); for (const rel of fileSet) { const abs = path.join(CLAUDE_ROOT, rel); const cur = fs.readFileSync(abs, 'utf8'); const cleaned = sanitize(cur); if (cleaned !== cur) { const ts = new Date().toISOString().replace(/[:.]/g, '-'); fs.copyFileSync(abs, `${abs}.bak.${ts}`); fs.writeFileSync(abs, cleaned); process.stdout.write(` [FIXED] ${rel} (backup .bak.${ts})\n`); } } } process.exit(2);