63 lines
3.8 KiB
TypeScript
63 lines
3.8 KiB
TypeScript
#!/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: /(?<![\w.\-]|[A-Za-z]\/)(?:\d{1,3}\.){3}\d{1,3}(?![\w.\-])/g, // RFC 5737 documentation ranges (192.0.2.x, 198.51.100.x, 203.0.113.x) are fine in fixtures and docs
|
|
allow: m => ['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);
|
|
}
|