storebound/tools/scrub.ts
CRT ae58948abd storebound scan: offline rejection-risk scan of the project itself
Detects the stack (native iOS / Android, React Native, Expo, Capacitor, Cordova,
Flutter, Unity, Godot) and the SDKs from a maintained table with versions,
file:line and each vendor's disclosure page, then reports App Store and Google
Play rejection risks with guideline, evidence, fix, severity and skill: 2.1(a)
demo account and placeholder text, 5.1.1(v) account deletion, 4.8 login
services, 3.1.1 restore and outside payments, 3.1.2 subscription info, 5.1.2(i)
ATT, purpose strings, privacy link, privacy manifest, 2.3.10, 2.3.7, 2.3.8, 4.2
web wrappers, 2.5.1, 2.5.5, ATS, export compliance, 1.3 kids ads, review
prompts, privacy labels vs SDKs; Play target SDK 36, delete-account URL, App
access, foreground services, exact alarms, QUERY_ALL_PACKAGES, SMS / call log,
com.example package name.

Proposes Data safety / App Privacy / ATT / privacy manifest answers (detected
file:line or ask owner) as a diff against the config; --write adds only the
detected ones. scan.ignore records owner-accepted findings. MCP tool scan
(read-only), validate includes open findings, desktop Checks tab shows them.
Fixtures for iOS, Android, Capacitor, Unity, Flutter, clean and teaser.
2026-10-11 23:20:55 +08:00

62 lines
3.7 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,
allow: m => /^0{8}-0{4}-0{4}-0{4}-0{12}$/.test(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);
}