72 lines
5.1 KiB
TypeScript
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;
|
|
}
|