storebound/cli/lib/scan/index.ts

188 lines
16 KiB
TypeScript

// `storebound scan` engine: detect the project, run the rejection-risk rules, propose the declarations. Offline and
// read-only (the only write is `--write`, done by the command, never here).
import { pgsFindings, readPgsProject } from '../game-services';
import { withoutTemplate } from '../template-answers';
import { existsSync, readFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { CONFIG_NAMES, loadConfig, type ShipConfig } from '../config';
import { reviewDemoAccount } from '../credentials';
import { AI_FIX_RULES, scanFixAction } from '../fix-actions';
import { proposeDeclarations, type Proposal } from './declare';
import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project';
import type { OriginalityReport } from '../originality';
import { FASTLANE_FIX, findFastlaneTraces } from '../fastlane-traces';
import { APPLE_GUIDELINES, applyIgnore, type ScanFinding, scanRules, type Severity, sortScan, unusedIgnores } from './rules';
import type { SdkCategory } from './sdks';
export interface ScanReport {
root: string;
config: string | null;
configError: string | null;
stacks: Stack[];
platforms: { ios: boolean; android: boolean };
sdks: { id: string; name: string; category: SdkCategory; tracking: boolean; deps: { eco: string; name: string; version: string | null; file: string; line: number }[] }[];
permissions: { key: string; value: string; file: string; line: number }[];
android: { targetSdk: number | null; targetSdkAt: string | null; permissions: string[] };
findings: ScanFinding[];
counts: Record<Severity, number> & { accepted: number };
declarations: Proposal;
scanned: number;
notes: string[];
/** `scan --originality`: the 4.3 / repetitive content comparison with the owner's other apps. */
originality?: { compared: number; flagged: number; threshold: number; pairs: { app: string; folder: string; overall: number; flagged: boolean }[] } | { error: string };
}
export interface ScanRun { report: ScanReport; facts: ProjectFacts; config: ShipConfig | null; configPath: string | null }
/** Find the project's config: --config, else storebound.config.* in `dir` (no upward search: a fixture or a
* sub-project must not pick up a parent's config). */
async function findConfig(dir: string, explicit?: string): Promise<{ config: ShipConfig | null; path: string | null; error: string | null }> {
const path = explicit ? resolve(dir, explicit) : CONFIG_NAMES.map(n => join(dir, n)).find(existsSync);
if (!path) return { config: null, path: null, error: null };
try { return { config: (await loadConfig(path)).config, path, error: null }; } catch (e) { return { config: null, path, error: (e as Error).message.split('\n').slice(0, 4).join('; ') }; }
}
export async function runScan(dir: string, o: { config?: string } = {}): Promise<ScanRun> {
const facts = detectProject(dir);
const { config, path: configPath, error } = await findConfig(dir, o.config);
const rel = (p: string) => { const r = relative(dir, p); return r && !r.startsWith('..') ? r : p; };
const readRel = (p: string | undefined): { path: string; text: string } | undefined => {
if (!p) return undefined;
const abs = join(dir, p);
try { return existsSync(abs) ? { path: rel(abs), text: readFileSync(abs, 'utf8') } : undefined; } catch { return undefined; }
};
const listings = {
apple: (config ? config.apple : true) ? readRel(config?.apple?.listing ?? 'store/appstore.md') : undefined,
play: (config ? config.play : true) ? readRel(config?.play?.listing ?? 'store/listing.md') : undefined,
captions: readRel(config?.shots?.copy ?? 'store/shots/copy.json'),
};
const configFile = configPath ? { path: rel(configPath), text: readFileSync(configPath, 'utf8') } : null;
const declarations = proposeDeclarations(facts, config ? withoutTemplate(config) : null, configFile);
const dataMerges = config?.dataSafety ? declarations.diff.filter(d => d.change === 'merge' && d.before).map(d => {
const b = d.before!;
const missing = [...d.after.collect.filter(x => !b.collect.includes(x)).map(x => `collected for ${x}`), ...(d.after.shared && !b.shared ? ['shared'] : []),
...(d.after.share ?? []).filter(x => !(b.share ?? []).includes(x)).map(x => `shared for ${x}`)];
return { type: d.type, missing: [...new Set(missing)].join(', '), evidence: d.evidence };
}).filter(d => d.missing) : [];
const findings = scanRules({ facts, config, configFile, listings, demoAccount: !!reviewDemoAccount(dir), labelGaps: declarations.labelGaps, dataMerges });
// Play Games Services: the Gradle dependency without the APP_ID meta-data / resource fails sign-in at runtime
if (facts.android) {
try {
const pgs = readPgsProject(dir);
for (const g of pgsFindings(config ?? {}, pgs).filter(x => x.level === 'error' && !(config?.scan?.ignore ?? []).some(i => i.rule === x.id)))
findings.push({ rule: g.id, guideline: 'Play Games Services setup', store: 'play', severity: 'high', title: g.msg, evidence: [{ file: pgs.manifest?.file ?? pgs.gradle[0] ?? 'android', line: 1, text: pgs.manifest?.value ?? 'play-services-games-v2' }],
fix: g.fix ?? '', skill: 'game-services', ref: 'https://developer.android.com/games/pgs/android/android-start' });
} catch { /* unreadable Android project */ }
}
// fastlane still uploading next to storebound (pitfall 96)
const traces = findFastlaneTraces(dir);
for (const store of ['apple', 'play'] as const) {
if (config && !config[store]) continue;
const ev = traces.filter(t => t.store === store || t.store === 'both');
if (ev.length && !(config?.scan?.ignore ?? []).some(i => i.rule === 'fastlane-upload'))
findings.push({ rule: 'fastlane-upload', guideline: 'storebound workflow', store, severity: 'medium', title: `fastlane still in the project (${ev.length} place${ev.length > 1 ? 's' : ''}); uploads there bypass storebound`,
evidence: ev.map(t => ({ file: t.file, line: t.line, text: t.text })), fix: FASTLANE_FIX, skill: 'migrate-from-fastlane', ref: 'https://git.crt.fyi/CRT/storebound/src/branch/main/skills/migrate-from-fastlane/SKILL.md' });
}
for (const f of findings) {
const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed });
if (action) f.action = action;
else if (!f.accepted && AI_FIX_RULES.has(f.rule)) f.aiFix = true;
}
const counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
for (const f of findings) { if (f.accepted) counts.accepted++; else counts[f.severity]++; }
const notes: string[] = [];
if (facts.android && facts.androidInfo.mergedManifest) notes.push(`Android: permissions include the merged manifest of the last local build (${facts.androidInfo.mergedManifest}); rebuild after dependency changes.`);
else if (facts.android && facts.androidInfo.manifests.length) notes.push('Android: libraries add permissions when the manifest is merged and no local build output (build/intermediates) was found; check the merged manifest (Android Studio > Merged Manifest, or the AAB) too.');
if (facts.ios && !facts.plistSources.length) notes.push('iOS: no Info.plist settings found (generated at build time?): purpose string, ATS and export compliance checks were skipped.');
if (!facts.sources.length) notes.push('No app source files found: code checks (sign-in, deletion, restore, ATT prompt) could not run.');
// scan.ignore entries that accept nothing (a renamed title, a typo, a fixed issue) are said, never silently dropped
notes.push(...unusedIgnores(findings, config?.scan?.ignore ?? []));
const report: ScanReport = {
root: dir, config: configPath ? rel(configPath) : null, configError: error,
stacks: facts.stacks, platforms: { ios: facts.ios, android: facts.android },
sdks: facts.sdks.map(s => ({ id: s.sdk.id, name: s.sdk.name, category: s.sdk.category, tracking: s.sdk.tracking, deps: s.deps.map(d => ({ eco: d.eco, name: d.name, version: d.version, file: d.file, line: d.line })) })),
permissions: [...facts.plist.values()].filter(e => /UsageDescription$/.test(e.key)).map(e => ({ key: e.key, value: String(e.value ?? ''), file: e.at.file, line: e.at.line })),
android: { targetSdk: facts.androidInfo.targetSdk?.value ?? null, targetSdkAt: facts.androidInfo.targetSdk ? where(facts.androidInfo.targetSdk.at) : null, permissions: [...new Set(facts.androidInfo.permissions.map(x => x.name.replace(/^android\.permission\./, '')))] },
findings, counts, declarations, scanned: facts.scanned, notes,
};
return { report, facts, config, configPath };
}
const TAG: Record<Severity, string> = { blocker: 'BLOCKER', high: 'HIGH ', medium: 'MEDIUM ', low: 'low ' };
const STACK: Record<Stack, string> = { 'ios-native': 'iOS (native)', 'android-native': 'Android (native)', 'react-native': 'React Native', expo: 'Expo', capacitor: 'Capacitor', cordova: 'Cordova', flutter: 'Flutter', unity: 'Unity', godot: 'Godot', web: 'web' };
/** The report as plain text (the terminal output of `storebound scan`). */
export function formatScan(r: ScanReport, o: { declarations?: boolean } = {}): string {
const L: string[] = [];
L.push(`storebound scan · ${r.config ? r.config : 'no storebound.config.ts (project files only)'}`);
if (r.configError) L.push(`config not loaded: ${r.configError}`);
L.push(`stack: ${r.stacks.map(s => STACK[s]).join(', ') || 'unknown'} · targets: ${[r.platforms.ios && 'iOS', r.platforms.android && 'Android'].filter(Boolean).join(', ') || 'none found'} · ${r.scanned} files`);
if (r.sdks.length) {
L.push('', 'SDKs');
for (const s of r.sdks) {
const d = s.deps[0]!;
L.push(` ${s.name.padEnd(30)} ${s.category}${s.tracking ? ', tracking-capable' : ''} · ${ECO_LABEL[d.eco as keyof typeof ECO_LABEL] ?? d.eco} ${d.name}${d.version ? ` ${d.version}` : ''} (${d.file}:${d.line})${s.deps.length > 1 ? ` +${s.deps.length - 1}` : ''}`);
}
}
if (r.android.targetSdk !== null) L.push(`Android targetSdk ${r.android.targetSdk} (${r.android.targetSdkAt})`);
const open = r.findings.filter(f => !f.accepted);
L.push('', `Rejection risks: ${r.counts.blocker} blocker, ${r.counts.high} high, ${r.counts.medium} medium, ${r.counts.low} low${r.counts.accepted ? ` (+${r.counts.accepted} accepted by the owner)` : ''}`);
if (!open.length) L.push(' none found in the project files and config');
r.findings.forEach((f, i) => {
L.push(`${String(i + 1).padStart(3)}. ${f.accepted ? 'accepted' : TAG[f.severity]} ${f.guideline} · ${f.store === 'apple' ? 'App Store' : 'Google Play'} · ${f.title}`);
for (const e of f.evidence.slice(0, 3)) L.push(` at ${e.file}:${e.line}${e.text ? ` ${e.text}` : ''}`);
if (f.accepted) L.push(` owner: ${f.accepted}`);
else L.push(` fix: ${f.fix}`, ` skill: ${f.skill} · ${f.ref}`);
});
if (o.declarations !== false) {
const d = r.declarations;
L.push('', 'Declarations proposal (detected = proven by a file; ask owner = only the owner knows)');
const areas: [string, string][] = [['dataSafety', 'Data safety'], ['appPrivacy', 'App Privacy'], ['att', 'App Tracking Transparency'], ['privacyManifest', 'Privacy manifest']];
for (const [a, label] of areas) {
const items = d.items.filter(x => x.area === a);
if (!items.length) continue;
L.push(` ${label}`);
for (const x of items) L.push(` ${x.status === 'detected' ? 'detected ' : 'ask owner'} ${x.what}: ${x.value}${x.evidence[0] ? ` (${x.evidence[0].file}:${x.evidence[0].line})` : ''}${x.note ? ` · ${x.note}` : ''}`);
}
const changes = d.diff.filter(x => x.change === 'add' || x.change === 'merge');
if (changes.length || d.appPrivacyChange || d.appPrivacyTypes || d.accountCreationChange) {
L.push('', ` Diff against ${r.config ?? 'the config'} (written only with --write):`);
for (const x of changes) {
if (x.change === 'add') L.push(` + dataSafety.data.${x.type}: { shared: ${x.after.shared}, collect: [${x.after.collect.join(', ')}]${x.after.share?.length ? `, share: [${x.after.share.join(', ')}]` : ''} }`);
else L.push(` ~ dataSafety.data.${x.type}: collect [${x.before!.collect.join(', ')}] -> [${x.after.collect.join(', ')}]${x.after.shared !== x.before!.shared ? `, shared ${x.before!.shared} -> ${x.after.shared}` : ''}${(x.after.share ?? []).join() !== (x.before!.share ?? []).join() ? `, share [${(x.before!.share ?? []).join(', ')}] -> [${(x.after.share ?? []).join(', ')}]` : ''}`);
}
if (d.accountCreationChange) L.push(` ~ dataSafety.accountCreation: ${d.accountCreationChange.from ? JSON.stringify(d.accountCreationChange.from) : 'unset'} -> ${JSON.stringify(d.accountCreationChange.to)}`);
if (d.appPrivacyChange) L.push(` ~ appPrivacy.tracking: ${d.appPrivacyChange.from ?? 'unset'} -> ${d.appPrivacyChange.to}`);
for (const [id, a] of Object.entries(d.appPrivacyTypes ?? {})) L.push(` + appPrivacy.types.${id}: { collected: true, purposes: [${a!.purposes!.join(', ')}] } (linked / tracking: ask owner)`);
} else if (r.config) L.push('', ` No changes to ${r.config}: the dataSafety / appPrivacy blocks already cover what was detected.`);
}
if (r.originality) {
if ('error' in r.originality) L.push('', `Originality (4.3): not run: ${r.originality.error}`);
else L.push('', `Originality (4.3 / Play repetitive content): ${r.originality.compared} app(s) compared, ${r.originality.flagged} near-duplicate(s) at ${r.originality.threshold}${r.originality.pairs.length ? ` · ${r.originality.pairs.map(p => `${p.app} ${Math.round(p.overall * 100)}%`).join(', ')}` : ''}`);
}
if (r.notes.length) L.push('', ...r.notes.map(n => `note: ${n}`));
L.push('', 'Checked at review, not by scan: crashes and bugs on the reviewer\'s device, broken links (storebound validate checks the URLs), screenshots that do not match the app, minimum functionality and spam judgement (4.2, 4.3: scan --originality compares your own apps), local law (5.2). See the review-rejections skill.');
return L.join('\n');
}
/**
* `scan --originality`: the near-duplicates of an originality report as scan findings (App Store 4.3 spam, Google
* Play repetitive content), owner-accepted through `scan.ignore` like every rule (rule id "originality"). Pure.
*/
export function addOriginality(r: ScanReport, o: OriginalityReport, stores: { apple: boolean; play: boolean }, config: ShipConfig | null, rel: (p: string) => string): void {
const ignore = config?.scan?.ignore ?? [];
for (const p of o.pairs.filter(x => x.flagged)) {
const evidence = [{ file: rel(p.other.dir) || '.', line: 1, text: `${p.other.name}: ${Math.round(p.overall * 100)}% overall (${p.reasons.slice(0, 2).join('; ')})` }];
const fix = `${p.suggestions.slice(0, 2).join(' ') || 'Make the app different in mechanic, content and listing.'} Run storebound originality --against ${rel(p.other.dir) || '.'} for every axis; the portfolio-originality skill has the checklist.`;
const base = { rule: 'originality', severity: 'high' as const, title: `Near-duplicate of your app ${p.other.name} (${Math.round(p.overall * 100)}% similar)`, evidence, fix, skill: 'portfolio-originality' };
if (stores.apple) r.findings.push({ ...base, guideline: '4.3', store: 'apple', ref: APPLE_GUIDELINES });
if (stores.play) r.findings.push({ ...base, guideline: 'Play: Repetitive content', store: 'play', ref: 'https://support.google.com/googleplay/android-developer/answer/9898783' });
}
applyIgnore(r.findings.filter(f => f.rule === 'originality'), ignore);
// runScan skipped the originality entries (it cannot produce that rule); now they can be checked
r.notes.push(...unusedIgnores(r.findings, ignore, []).filter(n => n.includes('(rule originality')));
r.findings = sortScan(r.findings);
r.counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
for (const f of r.findings) { if (f.accepted) r.counts.accepted++; else r.counts[f.severity]++; }
r.originality = { compared: o.pairs.length, flagged: o.flagged, threshold: o.threshold, pairs: o.pairs.map(p => ({ app: p.other.name, folder: rel(p.other.dir) || '.', overall: Math.round(p.overall * 1000) / 1000, flagged: p.flagged })) };
}