#!/usr/bin/env bun // Secrets / personal data scrub over every tracked (and staged-new) text file, the desktop app (apps/desktop) // included. Fails on any hit. // bun tools/scrub.ts // Patterns are built from parts so this file does not match itself. import { readFileSync } from 'node:fs'; import { join } from 'node:path'; export interface Rule { id: string; re: RegExp; files?: RegExp; allow?: (m: string, line: string) => boolean; wholeFile?: boolean } const at = '@'; export const RULES: Rule[] = [ { id: 'email', re: new RegExp(`[A-Za-z0-9._%+-]+${at}[A-Za-z0-9.-]+\\.[A-Za-z]{2,}`, 'g'), allow: m => /@example\.(com|org|net)$/i.test(m) || /@users\.noreply\./.test(m) || /@[0-9]x\.(png|jpe?g|webp)$/.test(m) }, { id: 'private-key', re: new RegExp('-----BEGIN [A-Z ]*PRIVATE ' + 'KEY-----\\s*[A-Za-z0-9+/=]{20,}', 'g'), wholeFile: true }, { id: 'private_key-json', re: new RegExp('"private' + '_key"\\s*:\\s*"-----BEGIN', 'g') }, { id: 'asc-key-file', re: new RegExp('AuthKey' + '_[A-Z0-9]{10}\\.p8', 'g') }, { id: 'apple-id-like', re: /\b(?=[A-Z0-9]{10}\b)(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])[A-Z0-9]{10}\b/g, files: /^(templates\/|.*\.config\.(ts|js|json)$|.*\.json$|.*\.toml$|.*\.plist$)/, // Google's public code signing team, which the desktop app checks Chrome for Testing against allow: (_m, line) => /"macTeamId":/.test(line) }, { id: 'issuer-uuid', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/g, // a site stats script's website id is public by design (it is in every page view) allow: (m, line) => /^0{8}-0{4}-0{4}-0{4}-0{12}$/.test(m) || line.includes(`data-website-id="${m}"`) }, { id: 'ip-address', re: /(? ['127.0.0.1', '0.0.0.0'].includes(m) || /^(192\.0\.2|198\.51\.100|203\.0\.113)\.\d{1,3}$/.test(m) }, { id: 'private-name', re: new RegExp(['Yon', 'ga'].join(''), 'gi') }, { id: 'google-api-key', re: new RegExp('AI' + 'za[0-9A-Za-z_-]{35}', 'g') }, // absolute home folders leak a machine's account name (placeholders like /Users/you are fine) { id: 'home-path', re: /(?:\/Users\/|\/home\/|[A-Z]:\\Users\\)(?!(?:you|me|user|runner|name|example)\b)[A-Za-z0-9._-]+/g }, ]; const SKIP = /(^|\/)(bun\.lock|Cargo\.lock|LICENSE)$|\.(png|jpe?g|webp|gif|ico|icns|woff2?|ttf|otf|p8|p12|aab|ipa|zip)$/i; export function scan(files: { path: string; text: string }[]): { file: string; line: number; rule: string; match: string }[] { const hits: { file: string; line: number; rule: string; match: string }[] = []; for (const f of files) { if (SKIP.test(f.path)) continue; const lines = f.text.split('\n'); for (const r of RULES) { if (r.files && !r.files.test(f.path)) continue; if (r.wholeFile) { for (const m of f.text.matchAll(r.re)) hits.push({ file: f.path, line: f.text.slice(0, m.index).split('\n').length, rule: r.id, match: m[0] }); continue; } lines.forEach((line, i) => { for (const m of line.matchAll(r.re)) if (!r.allow?.(m[0], line)) hits.push({ file: f.path, line: i + 1, rule: r.id, match: m[0] }); }); } } return hits; } if (import.meta.main) { const root = join(import.meta.dir, '..'); const ls = Bun.spawnSync(['git', 'ls-files', '--cached', '--others', '--exclude-standard'], { cwd: root }); const paths = ls.stdout.toString().split('\n').filter(Boolean); const files = paths.flatMap(p => { try { return [{ path: p, text: readFileSync(join(root, p), 'utf8') }]; } catch { return []; } }); const hits = scan(files); for (const h of hits) console.error(`${h.file}:${h.line} ${h.rule} ${h.match.slice(0, 60)}`); console.log(`scrub: ${files.length} files, ${hits.length} hits`); if (hits.length) process.exit(1); }