storebound/cli/scan.ts

72 lines
5.1 KiB
TypeScript

// Offline, read-only scan of the project itself for App Store and Google Play rejection risks, before you submit.
// storebound scan [--dir <project>] [--config <file>] [--store apple|play] [--json] [--no-declarations]
// storebound scan --write also write the detected dataSafety / appPrivacy answers into the config
// storebound scan --originality [--against <folders>] [--threshold 0.7] also the 4.3 / Play repetitive content
// check against your other apps (storebound originality; folders default to originality.against in the config)
// Reads the project files (Xcode project, Info.plist, entitlements, privacy manifests, Podfile.lock, Package.resolved,
// Gradle files, AndroidManifest.xml and a local build's merged manifest, res/xml and strings.xml, CMake / ndk-build
// files, package.json, app.json, capacitor.config, pubspec, Unity and Godot settings and ProjectVersion.txt), the
// config and the listing files; detects the stack and the SDKs; reports each risk with the guideline, the evidence
// (file:line), the fix, a severity and the skill; and proposes the Data safety / App Privacy / ATT / privacy manifest
// answers as a diff against the config. Nothing is written without --write (and --dry always wins). No network.
// Exit 1 while an open blocker is left.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { log, printJson, warn } from './lib/log';
import { upsertBlock } from './lib/scan/config-edit';
import { addOriginality, formatScan, runScan } from './lib/scan';
import { compareAgainst } from './originality';
/** Project folder: --dir, else the folder of the nearest storebound.config.* above the working directory, else it. */
function projectDir(args: Args): string {
if (args.get('dir')) return resolve(args.get('dir')!);
for (let d = process.cwd(); ; d = dirname(d)) {
if (CONFIG_NAMES.some(n => existsSync(join(d, n)))) return d;
if (dirname(d) === d) return process.cwd();
}
}
export async function run(args: Args): Promise<void> {
const dir = projectDir(args);
if (!existsSync(dir)) throw new Error(`${dir} does not exist`);
const { report, configPath, config } = await runScan(dir, { config: args.get('config') });
if (args.has('originality')) {
if (!config || !configPath) report.originality = { error: 'no storebound.config.ts loaded in this project' };
else {
const base = dirname(configPath);
try {
const { report: o } = await compareAgainst({ config, root: base, resolve: p => resolve(base, p) }, args.list('against'), args.has('threshold') ? args.num('threshold', 0.7) : undefined, m => warn('scan', m));
addOriginality(report, o, { apple: !!config.apple, play: !!config.play }, config, p => relative(dir, p));
} catch (e) { report.originality = { error: (e as Error).message }; }
}
}
const store = args.get('store');
if (store === 'apple' || store === 'play') {
report.findings = report.findings.filter(f => f.store === store);
report.counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
for (const f of report.findings) { if (f.accepted) report.counts.accepted++; else report.counts[f.severity]++; }
} else if (store) throw new Error('--store apple | play');
if (args.has('json')) printJson(report);
else console.log(formatScan(report, { declarations: !args.has('no-declarations') }));
if (args.has('write')) {
const d = report.declarations;
if (!configPath) warn('scan', '--write: no storebound.config.ts in this project (storebound init creates one)');
else if (!d.changed) log('scan', `--write: nothing to change in ${report.config}`);
else if (args.has('dry')) log('scan', `--dry: would write ${[(d.diff.some(x => x.change !== 'kept' && x.change !== 'same') || d.accountCreationChange) && 'dataSafety', (d.appPrivacyChange || d.appPrivacyTypes) && 'appPrivacy'].filter(Boolean).join(' + ')} into ${report.config}`);
else {
let text = readFileSync(configPath, 'utf8');
const ds = d.dataSafety!;
const missing = (['encryptedInTransit'] as const).filter(k => typeof ds[k] !== 'boolean');
if (d.diff.some(x => x.change === 'add' || x.change === 'merge') || d.accountCreationChange)
text = upsertBlock(text, configPath, 'dataSafety', ds, missing.length ? `storebound scan: answer ${missing.join(' and ')} (ask the owner; store-declarations skill)` : undefined);
if (d.appPrivacyChange || d.appPrivacyTypes) text = upsertBlock(text, configPath, 'appPrivacy', d.appPrivacy,
d.appPrivacyTypes ? 'storebound scan: types proposed from the detected SDKs; answer linked / tracking per type (ask the owner; store-declarations skill)' : undefined);
writeFileSync(configPath, text);
log('scan', `wrote the detected answers into ${report.config}${missing.length ? `; still to answer: dataSafety.${missing.join(', dataSafety.')}` : ''}. Review the diff (git diff) before pushing it to a store.`);
}
}
if (report.counts.blocker) process.exitCode = 1;
}