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.
71 lines
3.6 KiB
TypeScript
71 lines
3.6 KiB
TypeScript
// Writes one top-level block (dataSafety, appPrivacy) into storebound.config.ts / .js / .json text: replaces the
|
|
// existing `key: { ... }` property, or inserts it after the `app: { ... }` block. Pure text in, text out; the rest of
|
|
// the file (comments, order, formatting) stays as it is. Used by `storebound scan --write` only.
|
|
|
|
/** Index of the brace that closes the one at `open`, skipping strings, template literals and comments. -1 if none. */
|
|
export function matchBrace(text: string, open: number): number {
|
|
const pairs: Record<string, string> = { '{': '}', '[': ']', '(': ')' };
|
|
const stack: string[] = [];
|
|
for (let i = open; i < text.length; i++) {
|
|
const ch = text[i]!;
|
|
if (ch === '/' && text[i + 1] === '/') { i = text.indexOf('\n', i); if (i < 0) return -1; continue; }
|
|
if (ch === '/' && text[i + 1] === '*') { i = text.indexOf('*/', i + 2); if (i < 0) return -1; i++; continue; }
|
|
if (ch === '"' || ch === '\'' || ch === '`') {
|
|
for (i++; i < text.length && text[i] !== ch; i++) if (text[i] === '\\') i++;
|
|
continue;
|
|
}
|
|
if (pairs[ch]) stack.push(pairs[ch]!);
|
|
else if (ch === '}' || ch === ']' || ch === ')') {
|
|
if (stack.pop() !== ch) return -1;
|
|
if (!stack.length) return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
const ID = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
|
|
/** A plain value as TypeScript source (single quotes, unquoted keys, trailing commas), indented by `ind`. */
|
|
export function toTs(v: unknown, ind = ''): string {
|
|
const next = ind + ' ';
|
|
if (v === null || v === undefined) return 'undefined';
|
|
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;
|
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
if (Array.isArray(v)) {
|
|
const parts = v.map(x => toTs(x, next));
|
|
const one = `[${parts.join(', ')}]`;
|
|
return one.length <= 100 && !one.includes('\n') ? one : `[\n${parts.map(p => `${next}${p},`).join('\n')}\n${ind}]`;
|
|
}
|
|
const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined);
|
|
if (!entries.length) return '{}';
|
|
const parts = entries.map(([k, x]) => `${ID.test(k) ? k : `'${k}'`}: ${toTs(x, next)}`);
|
|
const one = `{ ${parts.join(', ')} }`;
|
|
return one.length <= 110 && !one.includes('\n') && entries.every(([, x]) => typeof x !== 'object' || Array.isArray(x)) ? one : `{\n${parts.map(p => `${next}${p},`).join('\n')}\n${ind}}`;
|
|
}
|
|
|
|
/** The config text with `key` set to `value` (plus an optional comment line above it). */
|
|
export function upsertBlock(text: string, file: string, key: string, value: unknown, comment?: string): string {
|
|
if (file.endsWith('.json')) {
|
|
const j = JSON.parse(text) as Record<string, unknown>;
|
|
j[key] = value;
|
|
return JSON.stringify(j, null, 2) + '\n';
|
|
}
|
|
const re = new RegExp(`^([ \\t]*)${key}\\s*:\\s*\\{`, 'm');
|
|
const m = re.exec(text);
|
|
if (m) {
|
|
const ind = m[1]!;
|
|
const open = m.index + m[0].length - 1;
|
|
const close = matchBrace(text, open);
|
|
if (close < 0) throw new Error(`${file}: could not find the end of the ${key} block`);
|
|
return `${text.slice(0, m.index)}${ind}${key}: ${toTs(value, ind)}${text.slice(close + 1)}`;
|
|
}
|
|
const app = /^([ \t]*)app\s*:\s*\{/m.exec(text);
|
|
if (!app) throw new Error(`${file}: no app block found to insert ${key} after; add it by hand`);
|
|
const ind = app[1]!;
|
|
const close = matchBrace(text, app.index + app[0].length - 1);
|
|
if (close < 0) throw new Error(`${file}: could not find the end of the app block`);
|
|
let at = close + 1;
|
|
if (text[at] === ',') at++;
|
|
const block = `\n${comment ? `${ind}// ${comment}\n` : ''}${ind}${key}: ${toTs(value, ind)},`;
|
|
return text.slice(0, at) + block + text.slice(at);
|
|
}
|