scan, privacy answers and credentials from a second game's first release (lessons 35-37): review-prompt needs rating context, not a bare "rate"; account signals skip import lines and module paths, config-only accounts say "from config"; accounts.inApp without deletion code stays a medium 5.1.1(v) finding; privacy-manifest --propose builds NSPrivacyCollectedDataTypes from appPrivacy.types and check warns on an empty list; appPrivacy.types is authoritative in the App Privacy sheet (unlisted = not collected), ad / support / AR types default to not collected without tracking or ad SDKs; Play advertising ID from SDKs, the AD_ID permission, app-ads.txt or the flag, never from a Device ID alone; scan.ignore[].evidence and warnings for entries that match nothing; empty credentials template, placeholder values count as missing (check, doctor)

This commit is contained in:
CRTkafa 2026-10-13 05:39:09 +08:00
parent 86cd79dd45
commit 1366e7e231
34 changed files with 881 additions and 71 deletions

View file

@ -6,7 +6,8 @@
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources } from './lib/app-privacy';
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources, typeIdOf } from './lib/app-privacy';
import { privacyTypes } from './lib/console-answers';
import { type BundleManifest, bundleTrackingIssues, ipaManifests, projectSdkManifests, sdkOfProjectPath } from './lib/privacy-files';
import { trackingIssue } from './lib/privacy-manifest';
import { type AsoFinding, auditAso } from './lib/aso';
@ -17,7 +18,7 @@ import { playConsoleReminders } from './lib/console-plan';
import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy';
import type { Ctx } from './lib/ctx';
import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety';
import { reviewDemoAccount } from './lib/credentials';
import { credentialStatus, reviewDemoAccount } from './lib/credentials';
import { log, printJson, warn } from './lib/log';
import { profileIssues } from './lib/profile';
import { detectProject } from './lib/scan/project';
@ -99,6 +100,11 @@ export async function run(ctx: Ctx): Promise<void> {
const apTypes = config.appPrivacy?.types;
const hasTypes = !!apTypes && Object.keys(apTypes).length > 0;
const facts = config.apple ? detectProject(ctx.loaded.root) : null;
// appPrivacy.types is authoritative (an unlisted type is answered "not collected"): name dataSafety types it leaves out
if (hasTypes && config.apple && config.dataSafety?.collectsData) {
const left = [...privacyTypes(config)].filter(([name]) => { const id = typeIdOf(name); return id && id !== 'OTHER_DATA_TYPES' && !apTypes![id]; });
if (left.length) note(`appPrivacy.types does not list ${left.map(([name, g]) => `${typeIdOf(name)} (dataSafety ${g.from.join(', ')})`).join(', ')}: App Privacy answers ${left.length === 1 ? 'it' : 'them'} "not collected". Add ${left.length === 1 ? 'it' : 'them'} to appPrivacy.types if the app or an SDK collects ${left.length === 1 ? 'it' : 'them'}`);
}
if (hasTypes && facts && !facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
if (facts?.ios) {
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
@ -127,6 +133,14 @@ export async function run(ctx: Ctx): Promise<void> {
const typesTrack = hasTypes && Object.values(apTypes!).some(a => a?.tracking === true);
if (config.appPrivacy?.tracking === true && !typesTrack && own) trackingDeclaredIssues('appPrivacy.tracking is true', own, tracking).forEach(say);
}
// template placeholders in the credentials ("YOUR_KEY_ID", "/absolute/path/to/...", "<...>"): missing, never a credential
const ph = credentialStatus(ctx.loaded.root).filter(c => c.status === 'placeholder').map(c => c.key);
if (ph.length) note(`credentials: ${ph.join(', ')} still hold template placeholders (env or .storebound/credentials.json): treated as missing; put the real value in or leave it empty`);
// scan.ignore entries that accept no finding (a typo, a title that changed, a fixed issue) are never silent
if (config.scan?.ignore?.length) {
const { runScan } = await import('./lib/scan');
try { for (const n of (await runScan(ctx.loaded.root, { config: ctx.loaded.path })).report.notes) if (n.startsWith('scan.ignore[')) note(n); } catch (e) { note(`scan.ignore not checked: ${(e as Error).message}`); }
}
if (config.dataSafety) tryDo('data safety', () => {
const rows = dataSafetyRows(config.dataSafety!, config.accounts);
// conditional required rows (e.g. the delete-account URL once accounts exist): Play answers 400 without them

View file

@ -37,6 +37,13 @@ export async function run(ctx: Ctx): Promise<void> {
// without --lookup only the cache is read: the lookup refuses, so no request is made
ids.appleAppId = await appleAppId(root, cfg, { say: () => { /* the plan's start says what it found */ }, ...(args.has('lookup') ? {} : { lookup: async () => { throw new Error('offline (add --lookup to ask the API)'); } }) });
}
// project signals (ad / attribution SDKs, the AD_ID permission, support and AR code) for the App Privacy and
// Advertising ID answers; a scan that fails leaves them unknown (asked)
try {
const { detectProject } = await import('../lib/scan/project');
const { projectSignals } = await import('../lib/project-signals');
ids.signals = projectSignals(detectProject(root));
} catch { /* not scanned */ }
const plan = consolePlan(form, cfg, ids);
if (json) return printJson(plan);
console.log(planText(plan));

View file

@ -312,35 +312,62 @@
"unity"
],
"console fill": [
"capacitor",
"cdp",
"chrome-profile",
"cordova",
"cordova-android",
"cordova-ios",
"debug-dump",
"expo",
"flutter",
"fresh-profile",
"godot",
"profile",
"react-native",
"start-url",
"step-timeout",
"store",
"unity",
"use-chrome-session"
],
"console plan": [
"capacitor",
"cdp",
"chrome-profile",
"cordova",
"cordova-android",
"cordova-ios",
"debug-dump",
"expo",
"flutter",
"fresh-profile",
"godot",
"lookup",
"name",
"profile",
"react-native",
"sku",
"start-url",
"step-timeout",
"store",
"unity",
"use-chrome-session"
],
"console verify": [
"capacitor",
"cordova",
"cordova-android",
"cordova-ios",
"expo",
"flutter",
"from-text",
"godot",
"name",
"react-native",
"sku",
"text"
"text",
"unity"
],
"desktop": [
"channel",

View file

@ -7,6 +7,7 @@ import { join, resolve } from 'node:path';
import pkg from '../package.json' with { type: 'json' };
import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { credentialStatus } from './lib/credentials';
import { log } from './lib/log';
import { textTable } from './lib/table';
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
@ -28,6 +29,9 @@ export interface DoctorResult {
storebound: string; bun: string | null; runtime: string; platform: string; project: string | null; tools: DoctorRow[]; missing: number;
/** The Android SDK `play build` hands to Gradle, where it came from, and every place looked at. */
androidSdk: AndroidSdkLookup;
/** Store credentials of the project folder (env, .storebound/credentials.json, profile): set, missing or
* placeholder (a template value, treated as missing). Never the values. */
credentials: { key: string; status: 'set' | 'missing' | 'placeholder' }[];
}
export function doctor(cwd: string, env: Record<string, string | undefined> = process.env): DoctorResult {
@ -38,7 +42,7 @@ export function doctor(cwd: string, env: Record<string, string | undefined> = pr
usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' });
const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null;
const sdk = androidSdk([join(cwd, 'android'), cwd], env);
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk };
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk, credentials: credentialStatus(cwd) };
}
export async function run(args: Args): Promise<void> {
@ -48,6 +52,8 @@ export async function run(args: Args): Promise<void> {
console.log(textTable(['tool', 'status', 'version', 'used for'], r.tools.map(t => [t.name, t.found ? (t.source === 'app' ? 'ok (app copy)' : 'ok') : 'missing', t.version ?? '', t.usedFor])));
log('doctor', r.androidSdk.path ? `Android SDK: ${r.androidSdk.path} (from ${r.androidSdk.source})`
: `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`);
const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key);
log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`);
const missing = r.tools.filter(t => !t.found);
if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; }
console.log('\nTo install what is missing (pick one per tool; storebound never runs these for you):');

View file

@ -160,7 +160,10 @@ export function appPrivacyMismatches(types: Partial<Record<AppleDataTypeId, Appl
if (a?.collected === false) out.push({ level: 'error', msg: `${m.path} declares ${k} (${typeName(id)}) as collected, but appPrivacy.types.${id}.collected is false` });
else if (!a || a.collected === undefined) out.push({ level: 'warn', msg: `${m.path} declares ${k} (${typeName(id)}) as collected, but appPrivacy.types has no answer for ${id}` });
}
for (const [id, a] of entries) if (a.collected === true && !inManifest.has(id))
const missing = entries.filter(([id, a]) => a.collected === true && !inManifest.has(id)).map(([id]) => id);
if (missing.length && !m.collectedTypes.length)
out.push({ level: 'warn', msg: `${m.path} has an empty NSPrivacyCollectedDataTypes, but appPrivacy.types marks ${missing.join(', ')} as collected: \`storebound privacy-manifest --ios <ios dir> --propose\` builds the list from appPrivacy.types (types, linked, tracking, purposes)` });
else for (const id of missing)
out.push({ level: 'warn', msg: `appPrivacy.types.${id} is collected, but ${m.path} has no ${APPLE_PRIVACY_TYPES[id][2]} in NSPrivacyCollectedDataTypes (fine if only an SDK collects it and declares it in its own manifest)` });
return out;
}

View file

@ -641,7 +641,17 @@ export interface ShipConfig {
* them in App Store Connect). `storebound scan` proposes them from the detected SDKs and code. */
appPrivacy?: AppPrivacyConfig;
/** `storebound scan`: findings the owner has looked at and accepts, by rule id (with the reason, shown in the report). */
scan?: { ignore?: { rule: string; store?: 'apple' | 'play'; /** Only findings whose title contains this text (one check of a rule that has several). */ match?: string; reason: string }[] };
scan?: {
ignore?: {
rule: string; store?: 'apple' | 'play';
/** Only findings whose TITLE contains this text (one check of a rule that has several), case-insensitive. */
match?: string;
/** Only findings with an evidence line ("file:line text", as the report prints it) containing this text, e.g.
* "src/main.ts:12" or a code fragment. Combine with match to narrow further. */
evidence?: string;
reason: string;
}[];
};
/** Ads: `validate` checks that <app.website>/app-ads.txt is reachable and contains every line listed here. */
ads?: { appAdsTxt?: string[] };
/** Rendered store graphics dir (<renders>/<languageId>/<size>/NN.png|jpg). Defaults to shots.out. */
@ -1069,8 +1079,8 @@ export function validateConfig(c: unknown): string[] {
e.push(...validateAppPrivacyTypes(cfg.appPrivacy.types));
}
for (const [i, x] of (cfg.scan?.ignore ?? []).entries())
if (!x || typeof x.rule !== 'string' || !x.rule || typeof x.reason !== 'string' || !x.reason.trim() || (x.store !== undefined && x.store !== 'apple' && x.store !== 'play') || (x.match !== undefined && (typeof x.match !== 'string' || !x.match.trim())))
e.push(`scan.ignore[${i}]: { rule: '<rule id from storebound scan>', reason: 'why this is fine', store?: 'apple' | 'play', match?: '<text in the finding title>' }`);
if (!x || typeof x.rule !== 'string' || !x.rule || typeof x.reason !== 'string' || !x.reason.trim() || (x.store !== undefined && x.store !== 'apple' && x.store !== 'play') || (x.match !== undefined && (typeof x.match !== 'string' || !x.match.trim())) || (x.evidence !== undefined && (typeof x.evidence !== 'string' || !x.evidence.trim())))
e.push(`scan.ignore[${i}]: { rule: '<rule id from storebound scan>', reason: 'why this is fine', store?: 'apple' | 'play', match?: '<text in the finding title>', evidence?: '<text in an evidence line (file:line text)>' }`);
if (cfg.ads?.appAdsTxt && !cfg.ads.appAdsTxt.every(l => typeof l === 'string' && l.split(',').length >= 3))
e.push('ads.appAdsTxt: lines like "example-network.com, pub-123, DIRECT, abc123"');
const seen = new Set<string>();

View file

@ -20,6 +20,7 @@ import { type AgeGroup, AGE_GROUPS, appleName, IARC_DETAIL_KEYS, IARC_DETAIL_SEC
import { APPLE_PRIVACY, applePurpose } from './declarations';
import { APPLE_DATA_TYPE_IDS, APPLE_PRIVACY_TYPES, APPLE_PURPOSE_IDS, type AppleDataTypeId, type AppleTypeAnswer, purposeName } from './app-privacy';
import { maskUser } from './credentials';
import type { ProjectSignals } from './project-signals';
export const FORM_IDS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] as const;
export type FormId = typeof FORM_IDS[number];
@ -218,8 +219,22 @@ export function privacyTypes(config: ShipConfig): Map<string, { from: string[];
return m;
}
function appPrivacy(config: ShipConfig): Answer[] {
/** App Privacy types with no Play Data safety equivalent that an app without tracking and without ad / attribution
* SDKs does not collect unless a project signal says so: type -> the signal that keeps it a question. */
const QUIET_TYPES: Partial<Record<AppleDataTypeId, (s: ProjectSignals) => string | null>> = {
ADVERTISING_DATA: () => null,
CUSTOMER_SUPPORT: s => s.support,
ENVIRONMENT_SCANNING: s => s.ar,
HANDS: s => s.ar,
HEAD: s => s.ar,
};
function appPrivacy(config: ShipConfig, signals?: ProjectSignals): Answer[] {
const ds = config.dataSafety, ov: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = config.appPrivacy?.types ?? {};
// appPrivacy.types is authoritative: a type it does not list is not collected (not derived from dataSafety, not asked)
const authoritative = Object.keys(ov).length > 0;
// no tracking, no ad / attribution SDK, no app-ads.txt: the ad-related and AR types default to not collected
const quiet = !!signals && config.appPrivacy?.tracking === false && !signals.adSdks.length && !signals.attributionSdks.length && !config.ads?.appAdsTxt?.length;
const label = 'Do you or your third-party partners collect data from this app?';
const ovIds = Object.keys(ov) as AppleDataTypeId[];
if (!ds && !ovIds.length) return [ask('collects', label, 'dataSafety', 'The config has no dataSafety answers and no appPrivacy.types: answer what the app and every SDK collect (store-declarations skill), then rerun.')];
@ -237,6 +252,12 @@ function appPrivacy(config: ShipConfig): Answer[] {
const [cat, name] = APPLE_PRIVACY_TYPES[id];
const g = got.get(name), o = ov[id], aid = `type:${name}`, l = `Data type: ${cat} > ${name}`;
if (o?.collected !== undefined) { out.push(ok(aid, l, o.collected, `appPrivacy.types.${id}.collected`)); if (o.collected) collected.push(id); continue; }
if (authoritative && !o) {
out.push(ok(aid, l, false, 'appPrivacy.types (not listed: not collected)', g ? `dataSafety.data.${g.from.join(', ')} maps here, but appPrivacy.types does not list ${id}: add it there if the app or an SDK collects it.` : undefined));
continue;
}
const quietWhy = quiet && QUIET_TYPES[id] ? QUIET_TYPES[id]!(signals!) : undefined;
if (quietWhy === null) { out.push(ok(aid, l, false, 'appPrivacy.tracking false, no ad / attribution SDK and no code signal in the project')); continue; }
const set = `set appPrivacy.types.${id}.collected to have it filled`;
if (g && name === 'Other Data Types') out.push(ask(aid, l, `dataSafety.data.${g.from.join(', ')}`, `${g.from.join(', ')} has no exact App Store type: decide where it belongs (${set}).`));
else if (g) { out.push(ok(aid, l, true, `dataSafety.data.${g.from.join(', ')}`)); collected.push(id); }
@ -378,11 +399,12 @@ function targetAudience(config: ShipConfig): Answer[] {
return out;
}
export function consoleAnswers(id: FormId, config: ShipConfig): Answer[] {
/** `signals`: what the project scan found (console plan passes it); without it nothing is derived from the project. */
export function consoleAnswers(id: FormId, config: ShipConfig, signals?: ProjectSignals): Answer[] {
switch (id) {
case 'apple-app-record': return appRecord(config);
case 'apple-age-rating': return ageRating(config);
case 'apple-app-privacy': return appPrivacy(config);
case 'apple-app-privacy': return appPrivacy(config, signals);
case 'play-app-record': return playAppRecord(config);
case 'play-content-rating': return contentRating(config);
case 'play-target-audience': return targetAudience(config);

View file

@ -6,6 +6,7 @@
// UI facts marked "verified" were read on a real console (App Privacy, 2026-10-12, /apps/<id>/distribution/privacy);
// everything else is modeled on Apple's and Google's help pages (sources in console-answers.ts): the agent adapts.
import { playCategoryOf, type ShipConfig } from './config';
import type { ProjectSignals } from './project-signals';
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, isPlanOnly, type PlanFormId, type Store, valueText } from './console-answers';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
@ -87,6 +88,8 @@ export interface PlanIds {
appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null;
/** reviewDemoAccount exists in .storebound/credentials.json (only whether it exists: the values are never read here). */
demoAccount?: boolean;
/** What the project scan found (ad / attribution SDKs, the AD_ID permission, support and AR code); undefined: not scanned. */
signals?: ProjectSignals;
}
const ASC = 'https://appstoreconnect.apple.com';
@ -456,7 +459,7 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
};
}
const meta = formMeta(form, cfg);
const raw = consoleAnswers(form, cfg);
const raw = consoleAnswers(form, cfg, p.signals);
const answers = sheet(form, raw);
const part = form === 'apple-app-privacy' ? appPrivacyPlan(answers, cfg.apple?.appId) : otherPlan(form, cfg, answers, p);
const rules = FORM_BUTTONS[form];

View file

@ -18,6 +18,7 @@ import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES, playCategoryOf, profileOf }
import type { PlanFormId, PlanOnlyFormId } from './console-answers';
import { NEVER_PRESS, playAppList } from './console-forms';
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan';
import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals';
const PLAY = 'https://play.google.com/console';
@ -236,13 +237,34 @@ const AD_ID_PURPOSES: Record<string, string> = {
fraud: 'Fraud prevention, security, and compliance', personalization: 'Personalization', account: 'Account management',
};
/**
* "Does your app use advertising ID?" from real signals: console.appContent.advertisingId, ad / attribution SDKs (scan),
* ads.appAdsTxt, the AD_ID permission in the source or merged AndroidManifest (or its tools:node="remove"). A Data
* safety Device ID (PSL_DEVICE_ID) alone is not the advertising ID (a random install id is not). No signal: No
* (certain) when the project was scanned; signals that disagree: ask.
*/
export function advertisingIdUse(config: ShipConfig, s: ProjectSignals | undefined): { v: boolean | null; src: string; note?: string } {
const flag = config.console?.appContent?.advertisingId;
const yes: string[] = [], no: string[] = [];
if (flag === true) yes.push('console.appContent.advertisingId'); else if (flag === false) no.push('console.appContent.advertisingId');
if (config.ads?.appAdsTxt?.length) yes.push('ads.appAdsTxt (ad SDKs read the advertising ID)');
if (s) {
if (s.adSdks.length || s.attributionSdks.length) yes.push(`ad / attribution SDKs: ${[...s.adSdks, ...s.attributionSdks].join(', ')}`);
if (s.adIdRemoved) no.push(`AD_ID removed (tools:node="remove") at ${s.adIdRemoved}`);
else if (s.adIdPermission) yes.push(`${AD_ID_PERMISSION} at ${s.adIdPermission}`);
}
if (yes.length && no.length) return { v: null, src: [...yes, ...no].join('; '), note: `The signals disagree (yes: ${yes.join('; ')}; no: ${no.join('; ')}). Decide and set console.appContent.advertisingId (an SDK that reads it needs the AD_ID permission in the merged manifest).` };
if (yes.length) return { v: true, src: yes.join('; ') };
if (no.length) return { v: false, src: no.join('; ') };
if (s) return { v: false, src: `no ad / attribution SDK, no ${AD_ID_PERMISSION} in the manifests, no ads.appAdsTxt (a Device ID in dataSafety is not the advertising ID)` };
return { v: null, src: 'console.appContent.advertisingId' };
}
function advertisingId(config: ShipConfig, p: PlanIds): Part {
const ac = config.console?.appContent, dev = config.dataSafety?.data?.PSL_DEVICE_ID;
const uses: { v: boolean; src: string } | null = ac?.advertisingId !== undefined ? { v: ac.advertisingId, src: 'console.appContent.advertisingId' }
: config.ads?.appAdsTxt?.length ? { v: true, src: 'ads.appAdsTxt (ad SDKs read the advertising ID)' }
: dev ? { v: true, src: 'dataSafety.data.PSL_DEVICE_ID' } : null;
const out: PlanAnswer[] = [uses ? ans('uses', 'Does your app use advertising ID?', uses.v, 'certain', uses.src, { control: 'radio', action: 'pick', label: uses.v ? 'Yes' : 'No', where: 'Advertising ID' })
: ans('uses', 'Does your app use advertising ID?', null, 'ask-owner', 'console.appContent.advertisingId', { control: 'radio', where: 'Advertising ID', note: 'Yes when the app or any SDK (ads, analytics, attribution) reads it; set console.appContent.advertisingId.' })];
const dev = config.dataSafety?.data?.PSL_DEVICE_ID;
const uses = advertisingIdUse(config, p.signals);
const out: PlanAnswer[] = [uses.v !== null ? ans('uses', 'Does your app use advertising ID?', uses.v, 'certain', uses.src, { control: 'radio', action: 'pick', label: uses.v ? 'Yes' : 'No', where: 'Advertising ID' })
: ans('uses', 'Does your app use advertising ID?', null, 'ask-owner', uses.src, { control: 'radio', where: 'Advertising ID', note: uses.note ?? 'Yes when the app or any SDK (ads, analytics, attribution) reads it; set console.appContent.advertisingId.' })];
if (uses?.v) {
if (dev) {
const purposes = new Set([...dev.collect, ...(dev.shared ? dev.share ?? [] : [])]);
@ -255,7 +277,7 @@ function advertisingId(config: ShipConfig, p: PlanIds): Part {
steps: steps([
{ text: 'Open App content > Advertising ID (/app-content/ad-id-declaration).' },
{ text: 'Pick Yes or No (material radio: JS click on the inner [role=radio]).', answers: ['uses'] },
uses?.v && { text: 'Tick exactly the purposes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back).', answers: out.filter(a => a.id.startsWith('purpose')).map(a => a.id) },
uses.v === true && { text: 'Tick exactly the purposes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back).', answers: out.filter(a => a.id.startsWith('purpose')).map(a => a.id) },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-advertising-id --from-text -`.' },
]),

View file

@ -72,13 +72,49 @@ export function profileStatus(profile: string): { profile: string; source: 'file
}
}
function value(root: string, name: string): string | undefined {
/**
* A value that only looks like a credential: a template placeholder ("your-…", "YOUR_KEY_ID", "<KEY_ID>", "xxx",
* "/absolute/path/to/…", the all-zero Apple Ads client id, "example" as a word unless it is a file that exists). Treated as
* missing everywhere (check, apps check, doctor and every command), never sent to a store. `example: false` for the
* review contact and demo account (an app's own backend can have a real demo@example.com account).
*/
export function isPlaceholder(v: string, o: { example?: boolean } = {}): boolean {
const s = v.trim();
if (!s || /BEGIN PRIVATE KEY/.test(s) || /^\s*\{/.test(s)) return false;
if (/(^|[_\-\s/])your([_\-\s]|$)/i.test(s) || /<[^<>\n]+>/.test(s) || /(^|[^a-z0-9])x{3,}([^a-z0-9]|$)/i.test(s) || /(^|\/)path\/to\//.test(s)) return true;
if (/^SEARCHADS\.0{8}-0{4}-0{4}-0{4}-0{12}$/i.test(s)) return true;
if (o.example !== false && /(^|[^a-z0-9])example([^a-z0-9]|$)/i.test(s)) {
try { return !existsSync(s.replace(/^~(?=\/)/, homedir())); } catch { return true; }
}
return false;
}
/** Credential names storebound reads (env, .storebound/credentials.json, a profile), for status reports. */
export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID',
'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY', 'APPLE_ADS_ACCOUNT_ID', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD'] as const;
/** Where each known credential stands: set, missing, or placeholder (a template value, treated as missing). Never
* the values. */
export function credentialStatus(root: string): { key: string; status: 'set' | 'missing' | 'placeholder' }[] {
let file: Store = {};
try { file = fileStore(root); } catch { /* unreadable: reported by the commands that read it */ }
const profile = process.env.STOREBOUND_PROFILE;
if (profile) { const v = profileEnv(profile)[name]; if (v) return v; }
let prof: Record<string, string> = {};
try { if (profile) prof = profileEnv(profile); } catch { /* invalid profile */ }
return CREDENTIAL_KEYS.map(key => {
const raw = [prof[key], process.env[key], typeof file[key] === 'string' ? file[key] as string : undefined].filter((x): x is string => !!x && !!x.trim());
return { key, status: raw.some(x => !isPlaceholder(x)) ? 'set' as const : raw.length ? 'placeholder' as const : 'missing' as const };
});
}
function value(root: string, name: string): string | undefined {
const real = (v: unknown): v is string => typeof v === 'string' && !!v && !isPlaceholder(v);
const profile = process.env.STOREBOUND_PROFILE;
if (profile) { const v = profileEnv(profile)[name]; if (real(v)) return v; }
const env = process.env[name];
if (env) return env;
if (real(env)) return env;
const v = fileStore(root)[name];
return typeof v === 'string' && v ? v : undefined;
return real(v) ? v : undefined;
}
/** A path or the inline content itself. */
@ -109,7 +145,7 @@ export function ascKey(root: string): AscKey {
/** App Review contact from .storebound/credentials.json (personal data, so never in the config). */
export function reviewContact(root: string): ReviewContact | null {
const c = fileStore(root).reviewContact as Partial<ReviewContact> | undefined;
if (!c?.email) return null;
if (!c?.email || isPlaceholder(c.email, { example: false })) return null;
return { firstName: c.firstName ?? '', lastName: c.lastName ?? '', phone: c.phone ?? '', email: c.email };
}
@ -121,7 +157,7 @@ export function reviewDemoAccount(root: string): DemoAccount | null {
const eu = process.env.REVIEW_DEMO_USER, ep = process.env.REVIEW_DEMO_PASSWORD;
if (eu && ep) return { username: eu, password: ep };
const d = fileStore(root).reviewDemoAccount as Partial<DemoAccount> | undefined;
if (typeof d?.username === 'string' && d.username && typeof d.password === 'string' && d.password) return { username: d.username, password: d.password };
if (typeof d?.username === 'string' && d.username && !isPlaceholder(d.username, { example: false }) && typeof d.password === 'string' && d.password) return { username: d.username, password: d.password };
return null;
}

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,7 @@
// What Apple requires (since May 2024): the app and every third-party SDK that uses a "required reason" API declare
// the API category and an approved reason in a PrivacyInfo.xcprivacy; SDKs on Apple's list of commonly used SDKs
// must ship their own manifest (and a signature when linked as a binary).
import { APPLE_DATA_TYPE_IDS, APPLE_PRIVACY_PURPOSES, APPLE_PRIVACY_TYPES, type AppleDataTypeId, type AppleTypeAnswer } from './app-privacy';
/** Required-reason API categories and the symbols that typically mean the code uses one. Heuristic, not a compiler. */
export const REQUIRED_REASON_APIS: Record<string, { symbols: RegExp; reasons: string[]; note: string }> = {
@ -103,20 +104,51 @@ const TYPICAL_REASON: Record<string, string> = {
* of app-level tracking domains (appPrivacy.trackingDomains); tracking done by an SDK is declared in the SDK's own
* manifest, so `appPrivacy.tracking: true` alone writes false (true with an empty list is ITMS-91064).
* `apis`: declared categories and reasons to keep; `used`: categories found in the sources (typical reason added);
* `collected`: the existing NSPrivacyCollectedDataTypes entries, kept as they are (the owner's answers).
* `collected`: the existing NSPrivacyCollectedDataTypes entries, kept as they are when there is no `types`.
* `types`: appPrivacy.types; when given, NSPrivacyCollectedDataTypes is built from it (collectedFromAppPrivacy).
*/
export function proposeAppManifest(o: { trackingDomains?: string[]; apis?: Record<string, string[]>; used?: Iterable<string>; collected?: unknown[] }): Record<string, unknown> {
export function proposeAppManifest(o: { trackingDomains?: string[]; apis?: Record<string, string[]>; used?: Iterable<string>; collected?: unknown[]; types?: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> }): Record<string, unknown> {
const domains = [...new Set((o.trackingDomains ?? []).map(s => s.trim()).filter(Boolean))];
const apis: Record<string, string[]> = { ...(o.apis ?? {}) };
for (const cat of o.used ?? []) if (!apis[cat]?.length && TYPICAL_REASON[cat]) apis[cat] = [TYPICAL_REASON[cat]!];
return {
NSPrivacyTracking: domains.length > 0,
NSPrivacyTrackingDomains: domains,
NSPrivacyCollectedDataTypes: o.collected ?? [],
NSPrivacyCollectedDataTypes: o.types && Object.keys(o.types).length ? collectedFromAppPrivacy(o.types, o.collected).entries : o.collected ?? [],
NSPrivacyAccessedAPITypes: Object.entries(apis).sort(([a], [b]) => a.localeCompare(b)).map(([cat, reasons]) => ({ NSPrivacyAccessedAPIType: cat, NSPrivacyAccessedAPITypeReasons: reasons })),
};
}
/**
* NSPrivacyCollectedDataTypes built from `appPrivacy.types` (the owner's App Privacy answers, authoritative): one entry
* per type marked collected, with Apple's manifest keys for the type and purposes, linked and tracking as answered
* (per-type tracking is allowed without NSPrivacyTrackingDomains; only NSPrivacyTracking needs app-level domains).
* Existing manifest entries are replaced; notes say what was dropped or is still unanswered.
*/
export function collectedFromAppPrivacy(types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>>, existing: unknown[] = []): { entries: Record<string, unknown>[]; notes: string[] } {
const entries: Record<string, unknown>[] = [];
const notes: string[] = [];
for (const id of APPLE_DATA_TYPE_IDS) {
const a = types[id];
if (!a || a.collected !== true) continue;
if (a.linked === undefined) notes.push(`appPrivacy.types.${id}.linked is not answered: wrote false`);
if (a.tracking === undefined) notes.push(`appPrivacy.types.${id}.tracking is not answered: wrote false`);
if (!a.purposes?.length) notes.push(`appPrivacy.types.${id}.purposes is empty: Apple needs at least one purpose`);
entries.push({
NSPrivacyCollectedDataType: APPLE_PRIVACY_TYPES[id][2],
NSPrivacyCollectedDataTypeLinked: a.linked === true,
NSPrivacyCollectedDataTypeTracking: a.tracking === true,
NSPrivacyCollectedDataTypePurposes: (a.purposes ?? []).map(p => APPLE_PRIVACY_PURPOSES[p][1]),
});
}
const now = new Set(entries.map(e => e.NSPrivacyCollectedDataType as string));
for (const x of existing) {
const k = (x as Record<string, unknown>)?.NSPrivacyCollectedDataType;
if (typeof k === 'string' && !now.has(k.trim())) notes.push(`${k.trim()} was in the manifest but appPrivacy.types does not mark it collected: dropped (add it to appPrivacy.types if the app's own code collects it)`);
}
return { entries, notes };
}
/** Minimal plist reader for the keys a privacy manifest uses (no plist dependency). */
export function parseManifest(path: string, xml: string): Manifest {
const apis: Record<string, string[]> = {};

View file

@ -0,0 +1,43 @@
// Project signals the console answer sheets use (`console plan`): which ad / attribution SDKs the project has, whether
// the Android manifest (source or the merged one of a local build) requests or removes the advertising ID permission,
// and code that suggests App Privacy types without a Play Data safety equivalent (customer support, AR scanning).
// Pure over the scan facts; null fields mean "not found", never "not scanned" (that is `undefined` signals).
import { grep, type ProjectFacts, where } from './scan/project';
export const AD_ID_PERMISSION = 'com.google.android.gms.permission.AD_ID';
export interface ProjectSignals {
/** Ad SDKs (names) found in the dependencies. */
adSdks: string[];
/** Attribution SDKs (names). */
attributionSdks: string[];
/** SDKs whose own manifest merges the AD_ID permission (names). */
adIdSdks: string[];
/** file:line of the AD_ID permission in a source or merged manifest. */
adIdPermission: string | null;
/** file:line of <uses-permission ... AD_ID tools:node="remove">. */
adIdRemoved: string | null;
/** file:line of a customer support SDK or ticket code (App Privacy "Customer Support"). */
support: string | null;
/** file:line of AR / XR, hand or face tracking code (App Privacy Environment Scanning, Hands, Head). */
ar: string | null;
}
const SUPPORT_DEP = /zendesk|intercom|helpshift|freshchat|freshdesk|crisp-sdk|tawk|gorgias/i;
const SUPPORT_CODE = /\b(createTicket|submitTicket|supportTicket|SupportTicket|contactSupport\s*\()/;
const AR_CODE = /\bARKit\b|\bARSession\b|ARWorldTrackingConfiguration|ARFaceTrackingConfiguration|ARBodyTrackingConfiguration|VNDetectHumanHandPoseRequest|sceneReconstruction|\bnavigator\.xr\b|@react-three\/xr|ARCore|com\.google\.ar\.core|UnityEngine\.XR\.ARFoundation/;
export function projectSignals(p: ProjectFacts): ProjectSignals {
const names = (cat: string) => [...new Set(p.sdks.filter(s => s.sdk.category === cat).map(s => s.sdk.name))];
const perm = p.androidInfo.permissions.find(x => x.name === AD_ID_PERMISSION);
const removed = p.androidInfo.removedPermissions.find(x => x.name === AD_ID_PERMISSION);
const dep = p.deps.find(d => SUPPORT_DEP.test(d.name));
const support = dep ? `${dep.file}:${dep.line}` : grep(p.sources, SUPPORT_CODE, 1).map(h => where(h))[0] ?? null;
const arDep = p.deps.find(d => /arcore|arkit|ar-foundation|arfoundation|react-three\/xr|viro/i.test(d.name));
const ar = arDep ? `${arDep.file}:${arDep.line}` : grep(p.sources, AR_CODE, 1).map(h => where(h))[0] ?? null;
return {
adSdks: names('ads'), attributionSdks: names('attribution'),
adIdSdks: [...new Set(p.sdks.filter(s => s.sdk.mergesAdId).map(s => s.sdk.name))],
adIdPermission: perm ? where(perm.at) : null, adIdRemoved: removed ? where(removed.at) : null, support, ar,
};
}

View file

@ -81,7 +81,7 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
detected.set(t, e);
};
if (accounts) {
markAccount('PSL_USER_ACCOUNT', signUp.length ? signUp : [cfgAt(/accounts\s*:/, 'accounts.enabled')], 'accounts');
markAccount('PSL_USER_ACCOUNT', signUp.length ? signUp : [cfgAt(/accounts\s*:/, 'from config (accounts.enabled)')], 'accounts');
for (const s of signUpCfg) for (const t of ACCOUNT_SIGN_UP[s]?.data ?? []) if (t !== 'PSL_USER_ACCOUNT') markAccount(t, [cfgAt(/signUp\s*:/, `accounts.signUp: ${s}`)], `accounts.signUp ${s}`);
const em = grepOwn(p, SIGNALS.email, 2);
if (em.hits.length && !em.built) markAccount('PSL_EMAIL', em.hits, 'email in the sources');
@ -147,8 +147,12 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
// appPrivacy.types: propose collected + purposes for detected types the config does not answer (Apple apps only)
const haveTypes = c?.appPrivacy?.types ?? {};
const typesAdded: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {};
// appPrivacy.types is authoritative once it exists (a type it does not list is "not collected"), so a block this
// proposal writes also carries the types dataSafety already declares (kept) when the config has no types yet
const detectedNew = diff.some(d => d.change !== 'kept' && typeIdOf(d.apple.split(' / ')[1]!) && !haveTypes[typeIdOf(d.apple.split(' / ')[1]!)!]);
const withKept = detectedNew && !Object.keys(haveTypes).length;
if (c?.apple) for (const d of diff) {
if (d.change === 'kept') continue;
if (d.change === 'kept' && !withKept) continue;
const id = typeIdOf(d.apple.split(' / ')[1]!);
if (!id || id === 'OTHER_DATA_TYPES' || haveTypes[id]) continue;
const shared = d.after.shared ? d.after.share ?? [] : [];

View file

@ -7,7 +7,7 @@ import { reviewDemoAccount } from '../credentials';
import { proposeDeclarations, type Proposal } from './declare';
import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project';
import type { OriginalityReport } from '../originality';
import { APPLE_GUIDELINES, type ScanFinding, scanRules, type Severity, sortScan } from './rules';
import { APPLE_GUIDELINES, applyIgnore, type ScanFinding, scanRules, type Severity, sortScan, unusedIgnores } from './rules';
import type { SdkCategory } from './sdks';
export interface ScanReport {
@ -68,6 +68,8 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise
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 },
@ -149,10 +151,9 @@ export function addOriginality(r: ScanReport, o: OriginalityReport, stores: { ap
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' });
}
for (const f of r.findings) if (f.rule === 'originality' && !f.accepted) {
const hit = ignore.find(i => i.rule === f.rule && (!i.store || i.store === f.store) && (!i.match || f.title.toLowerCase().includes(i.match.toLowerCase())));
if (hit) f.accepted = hit.reason;
}
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]++; }

View file

@ -85,16 +85,24 @@ export const PLAY_TARGET_SDK = { phone: 36, wear: 35, automotive: 35, tv: 34 } a
export const PLAY_TARGET_SDK_SINCE = '2026-08-31';
// ---- code signals ----
/** `re` only on lines that are not import / require / export-from statements (a module path is not evidence). Line
* anchored with the m flag, so a whole-file test and a per-line test agree. */
export function codeLine(re: RegExp): RegExp {
return new RegExp(`^(?!\\s*import[\\s{*'"])(?!.*\\b(?:require|import)\\s*\\(\\s*['"\`])(?!.*\\bfrom\\s*['"\`]).*?(?:${re.source})`, `${re.flags.replace('m', '')}m`);
}
export const SIGNALS = {
appleSignIn: /ASAuthorizationAppleIDProvider|SignInWithAppleButton|AppleAuthenticationButton|AppleAuthentication\.signInAsync|appleAuth\.performRequest|SignInWithApple|OAuthProvider\(\s*['"]apple\.com|AppleAuthProvider|signInWithApple|AppleAuthManager/,
thirdPartyLogin: /GIDSignIn|GoogleSignin\.signIn|GoogleSignIn\(|GoogleAuthProvider|signInWithGoogle|FacebookAuthProvider|LoginManager\(\)\.logIn|LoginManager\.logInWithPermissions|signInWithFacebook|FacebookLogin|TwitterAuthProvider|GithubAuthProvider|OAuthProvider\(\s*['"](microsoft\.com|yahoo\.com|twitter\.com|github\.com)/,
signIn: /signInWithEmailAndPassword|signInWithPassword|signInWithCredential|createUserWithEmailAndPassword|\bsupabase\.auth\.sign|Amplify\.Auth\.signIn|\bAuth0\b|\bloginWithPassword\b|\.signIn\(\s*\{|\/(api\/)?(auth\/)?(login|signin|sign-in)\b['"`]/,
signUp: /createUserWithEmailAndPassword|\bsignUp\s*\(|\bsupabase\.auth\.signUp|Amplify\.Auth\.signUp|\/(api\/)?(auth\/)?(register|signup|sign-up)\b['"`]|["'>](Create (an )?account|Sign up)["'<]/,
// sign-in / sign-up: auth SDK calls, credential fields, guest-account creation, auth endpoints (an absolute path, a
// URL or a `${base}/...` template, never a module path like './buildings/register') and screen strings. Import /
// require lines never count: a module named register, login or account is not an auth flow.
signIn: codeLine(/signInWithEmailAndPassword|signInWithPassword|signInWithCredential|createUserWithEmailAndPassword|\bsupabase\.auth\.sign|Amplify\.Auth\.signIn|\bAuth0\b|\bloginWithPassword\b|\.signIn\(\s*\{|autoComplete=["']current-password|textContentType=["']?\.?password\b|\btype=["']password["']|['"`](https?:\/\/[^'"`\s]*|\$\{[^}]*\})?\/(api\/|v\d+\/)*(auth\/|users?\/|accounts?\/)?(login|signin|sign-in)['"`?]/),
signUp: codeLine(/createUserWithEmailAndPassword|\bsignUp\s*\(|\bsupabase\.auth\.signUp|Amplify\.Auth\.signUp|autoComplete=["']new-password|textContentType=["']?\.?newPassword\b|\bcreate(Guest|Anonymous)(Account|User)\b|\bregisterGuest\b|['"`](https?:\/\/[^'"`\s]*|\$\{[^}]*\})?\/(api\/|v\d+\/)*(auth\/|users?\/|accounts?\/)?(register|signup|sign-up)['"`?]|["'>](Create (an )?account|Sign up)["'<]/),
// real evidence that the app collects an email address: an email input or keyboard, an `email` property in a request
// body / schema / type (not a quoted constant or label), an email validation, an email endpoint or an email sign-in
// call. Accounts alone never mean email (a username with a device token has none).
email: /type\s*[:=]\s*["'{]*email["'}]|inputMode=["']email["']|keyboardType=["']?email-address|autoComplete=["']email|textContentType=["']?(emailAddress|\.emailAddress)|UITextContentType\.emailAddress|\.keyboardType\(\.emailAddress\)|keyboardType\s*=\s*\.emailAddress|TextInputType\.emailAddress|TYPE_TEXT_VARIATION_EMAIL_ADDRESS|KeyboardType\.Email\b|(?<![\w.$-])["']?email["']?\s*\??:\s*(?![\s"'`]|$)|z\.string\(\)(\.\w+\([^)]*\))*\.email\(|\bisEmail\(|\[\^\\s@\]\+@|\[a-zA-Z0-9._%+-\]\+@|\/(api\/)?(auth\/)?(email|verify-email|forgot-password|reset-password|magic-link)\b['"`]|signInWithEmailAndPassword|createUserWithEmailAndPassword|sendSignInLinkToEmail|createUser\(withEmail:|signIn\(withEmail:|sendSignInLink\(toEmail:|signInWithOtp\(\s*\{\s*email|ASAuthorization\.Scope\.email|requestedScopes[^\n]{0,40}\.email\b/,
deleteAccount: /deleteUser\b|\buser\.delete\(|currentUser\??\.delete\(|\bdeleteAccount\b|delete_account|DeleteAccount|delete-account|["'>]Delete (my |your )?account|\brevokeToken\b|auth\.admin\.deleteUser|Hesab(ı|ımı) sil/i,
deleteAccount: codeLine(/deleteUser\b|\buser\.delete\(|currentUser\??\.delete\(|\bdeleteAccount\b|delete_account|DeleteAccount|delete-account|["'>]Delete (my |your )?account|\brevokeToken\b|auth\.admin\.deleteUser|Hesab(ı|ımı) sil/i),
restore: /restoreCompletedTransactions|AppStore\.sync\(|restorePurchases|restoreTransactions|RestoreTransactions|getAvailablePurchases|\brestorePurchase\b|\.restore\(\)|Restore Purchases|syncPurchases|Purchases\.shared\.restore|store\.restore\b|queryPurchasesAsync/i,
subscriptionCode: /subscriptionPeriod|Product\.SubscriptionInfo|\.autoRenewable\b|entitlements\.active|SubscriptionStoreView|ProductType\.Subscription|ProductType\.SUBS|BillingClient\.ProductType\.SUBS/,
attRequest: /requestTrackingAuthorization|requestTrackingPermissionsAsync|requestTrackingPermission\b|AppTrackingTransparency\.requestTrackingAuthorization|ATTrackingStatusBinding\.RequestAuthorizationTracking|TrackingTransparency\.requestPermission/,
@ -108,7 +116,13 @@ export const SIGNALS = {
privateScheme: /["'`](App-Prefs|prefs):root\b/,
ratePrompt: /["'`>](Rate (us|this app)|Enjoying [A-Z][\w ]{0,30}\?|Love (this|the) app\?)/,
reviewApi: /SKStoreReviewController|requestReview|StoreReview\.requestReview|InAppReview|in_app_review|react-native-rate|requestReviewFlow|ReviewManager/,
incentiveRating: /(?<![\w-])(rate|review)(?![\w-])(?!\s+limit)[^"'`\n]{0,60}\b(rewards?|rewarded|coins?|gems?|bonus|free (lives|spins|coins))\b|(reward|coins?|gems?|bonus)\b[^"'`\n]{0,40}\b(for|to) (rating|reviewing|rate|review)\b|5[- ]?stars?\b[^"'`\n]{0,40}\b(reward|get|earn)/i,
// a reward tied to rating: a "rate us / rate this app / leave a review" phrase next to a reward word, or a reward
// "for rating / reviewing". A bare "rate" or "review" is never enough (a production rate, frame rate, exchangeRate,
// rate limit or code review next to "bonus" is not a review prompt).
incentiveRating: /\b(rate|review) (us|me|this (app|game)|the (app|game)|our (app|game))\b[^\n]{0,60}?\b(rewards?|rewarded|coins?|gems?|bonus|free (lives|spins|coins))\b|\b(leave|write|give) (us |me )?(a )?(review|rating|(5|five)[- ]?stars?)\b[^\n]{0,60}?\b(rewards?|rewarded|coins?|gems?|bonus|free (lives|spins|coins))\b|\b(rewards?|coins?|gems?|bonus)\b[^"'`\n]{0,40}\b(for|to|by) (rating|reviewing|(rate|review) (us|me|the|this|our)\b|leaving a review|writing a review)/i,
// a reward granted next to a system review prompt call (checked within a few lines by rewardNearReviewApi)
reviewApiCall: /SKStoreReviewController\.requestReview|\brequestReview\s*\(|requestReviewFlow\s*\(|launchReviewFlow\s*\(|InAppReview\.(instance\.)?requestReview|StoreReview\.requestReview|\.requestReview\(\)/,
rewardGrant: /\b(add|give|grant|award)(Coins?|Gems?|Reward|Bonus|Currency|Lives)\b|\b(reward|bonus)\s*\(|\bcoins\s*\+=|\bgems\s*\+=|\bgrantReward\b/i,
externalBuy: /\b(buy|subscribe|purchase|upgrade|pay)\b[^.\n]{0,40}\b(on|at|via|from) (our )?(website|web ?site|web|site)\b/i,
};
@ -128,6 +142,22 @@ const FGS_PERMISSION: Record<string, string> = {
};
const SMS_CALL_LOG = ['READ_SMS', 'RECEIVE_MMS', 'RECEIVE_SMS', 'RECEIVE_WAP_PUSH', 'SEND_SMS', 'WRITE_SMS', 'READ_CALL_LOG', 'WRITE_CALL_LOG', 'PROCESS_OUTGOING_CALLS'];
/** A system review prompt call with a reward grant within `win` lines of it (rewarding the rating): the call line. */
export function rewardNearReviewApi(sources: { path: string; text: string }[], max = 2, win = 6): Hit[] {
const out: Hit[] = [];
for (const s of sources) {
if (!SIGNALS.reviewApiCall.test(s.text) || !SIGNALS.rewardGrant.test(s.text)) continue;
const lines = s.text.split('\n');
lines.forEach((l, i) => {
if (out.length >= max || !SIGNALS.reviewApiCall.test(l) || /^\s*(\/\/|#|\*)/.test(l)) return;
if (lines.slice(Math.max(0, i - win), i + win + 1).some(x => SIGNALS.rewardGrant.test(x) && !/^\s*(\/\/|#|\*)/.test(x)))
out.push({ file: s.path, line: i + 1, text: l.trim().slice(0, 160) });
});
if (out.length >= max) break;
}
return out;
}
/** Text lines (inside fenced blocks of a listing file, or JSON string values) that match `re`. */
function listingHits(file: { path: string; text: string } | undefined, re: RegExp, o: { fenced?: boolean; field?: RegExp } = {}): Hit[] {
if (!file) return [];
@ -172,10 +202,12 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
const authSdk = sdkHits(s => s.sdk.category === 'auth');
const signInCode = src(SIGNALS.signIn, 2);
const signUpCode = src(SIGNALS.signUp, 2);
const accountsCfg = c?.accounts?.enabled ? cfg(/accounts\s*:/) : [];
const accountsCfg = c?.accounts?.enabled ? cfg(/accounts\s*:/, 'from config (accounts.enabled)') : [];
const signIn = [...accountsCfg, ...authSdk, ...signInCode];
const creates = [...accountsCfg, ...signUpCode, ...thirdParty, ...siwa, ...sdkHits(s => s.sdk.login === 'firebase')];
const deletion = [...(c?.accounts?.inApp ? cfg('inApp') : []), ...src(SIGNALS.deleteAccount, 2)];
// in-app deletion needs code evidence (a delete-account call, function or UI string): accounts.inApp alone is a claim
const deletionCode = src(SIGNALS.deleteAccount, 2);
const deletionClaim = c?.accounts?.inApp ? cfg('inApp', `from config (accounts.inApp: ${c.accounts.inApp})`) : [];
if (signIn.length) {
const ra = c?.reviewAccess;
@ -193,7 +225,11 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
fix: 'Play Console > App content > App access: add the demo account and instructions (keep the credentials in .storebound/credentials.json only).', skill: 'review-access', ref: `${PLAY}9859455` });
}
if (creates.length) {
if (apple && !deletion.length)
if (apple && !deletionCode.length && deletionClaim.length)
add({ rule: 'account-deletion', guideline: '5.1.1(v)', store: 'apple', severity: 'medium',
title: 'In-app account deletion is claimed by config (accounts.inApp), not found in code', evidence: [...deletionClaim, ...creates].slice(0, 4),
fix: 'Show the code path or the screen: a delete-account call (endpoint, deleteUser / user.delete()), a deletion function or a "Delete account" UI string in the app sources. If deletion lives behind a server-driven or web screen, check it on a device and accept this finding with scan.ignore (a reason). A config claim alone does not pass App Review.', skill: 'account-deletion' });
else if (apple && !deletionCode.length)
add({ rule: 'account-deletion', guideline: '5.1.1(v)', store: 'apple', severity: accountsCfg.length || signUpCode.length ? 'blocker' : 'high',
title: 'Accounts can be created but no in-app account deletion was found', evidence: creates.slice(0, 4),
fix: 'Add "Delete account" in the app (it deletes the account and its data, not only signs out; with Sign in with Apple also revoke the token), then set accounts.inApp to where it starts, e.g. "Settings > Account > Delete account".', skill: 'account-deletion' });
@ -511,7 +547,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
if (priv.length)
add({ rule: 'private-api', guideline: '2.5.1', store: 'apple', severity: 'high', title: 'Private settings URL scheme (App-Prefs: / prefs:root)', evidence: priv,
fix: 'Open your own settings page with UIApplication.openSettingsURLString instead.', skill: 'review-rejections' });
const incent = grep(p.sources, SIGNALS.incentiveRating, 2);
const incent = [...grep(p.sources, SIGNALS.incentiveRating, 2), ...rewardNearReviewApi(p.sources, 2)].slice(0, 2);
if (incent.length)
add({ rule: 'review-prompt', guideline: '3.2.2(x) / 5.6.3', store: 'apple', severity: 'high', title: 'A reward for rating or reviewing the app', evidence: incent,
fix: 'Never reward or gate ratings; ask with the system review prompt only.', skill: 'review-replies' });
@ -530,12 +566,34 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
playRules(inp, { add: f => add({ ...f, store: 'play' }), cfg, sdkHits, listingHits, creates, restorable, paySdk });
// ---- owner-accepted findings ----
const ignore = c?.scan?.ignore ?? [];
for (const f of out) {
const hit = ignore.find(i => i.rule === f.rule && (!i.store || i.store === f.store) && (!i.match || f.title.toLowerCase().includes(i.match.toLowerCase())));
applyIgnore(out, c?.scan?.ignore ?? []);
return sortScan(out);
}
export type ScanIgnore = NonNullable<NonNullable<ShipConfig['scan']>['ignore']>[number];
/** Whether a `scan.ignore` entry accepts a finding: the same rule (and store, when given), `match` in the title and
* `evidence` in one evidence line ("file:line text"), both case-insensitive. */
export function ignoreMatches(i: ScanIgnore, f: ScanFinding): boolean {
if (i.rule !== f.rule || (i.store && i.store !== f.store)) return false;
if (i.match && !f.title.toLowerCase().includes(i.match.toLowerCase())) return false;
const ev = i.evidence?.toLowerCase();
return !ev || f.evidence.some(e => `${e.file}:${e.line} ${e.text}`.toLowerCase().includes(ev));
}
/** Marks the findings a `scan.ignore` entry accepts (the first matching entry's reason). */
export function applyIgnore(findings: ScanFinding[], ignore: ScanIgnore[]): void {
for (const f of findings) if (!f.accepted) {
const hit = ignore.find(i => ignoreMatches(i, f));
if (hit) f.accepted = hit.reason;
}
return sortScan(out);
}
/** `scan.ignore` entries that accept no finding (a typo in the rule id, a title or evidence that changed, a fixed
* issue): each as a warning line. `skipRules`: rules this run could not produce (originality without --originality). */
export function unusedIgnores(findings: ScanFinding[], ignore: ScanIgnore[], skipRules: string[] = ['originality']): string[] {
return ignore.flatMap((i, n) => skipRules.includes(i.rule) || findings.some(f => ignoreMatches(i, f)) ? []
: [`scan.ignore[${n}] (rule ${i.rule}${i.store ? `, store ${i.store}` : ''}${i.match ? `, match "${i.match}"` : ''}${i.evidence ? `, evidence "${i.evidence}"` : ''}) matches no finding: check the rule id, the title text (match) and the evidence text (evidence), or remove it if the issue is fixed`]);
}
const RANK: Record<Severity, number> = { blocker: 0, high: 1, medium: 2, low: 3 };

View file

@ -9,7 +9,8 @@
// ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree in the app's manifest and in every SDK's (true
// needs a non-empty list, a non-empty list needs true); a mismatch is a blocker.
// --propose prints the app's PrivacyInfo.xcprivacy as storebound proposes it (declared + found API categories, the
// collected types it has): NSPrivacyTracking true only with app-level tracking domains (--tracking-domains, else
// collected types from appPrivacy.types of storebound.config.ts with linked, tracking and purposes, else the ones the
// manifest has): NSPrivacyTracking true only with app-level tracking domains (--tracking-domains, else
// appPrivacy.trackingDomains of storebound.config.ts when there is one), else false; tracking done by an SDK is
// declared in the SDK's own manifest.
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
@ -19,7 +20,8 @@ import { log } from './lib/log';
import { nativeIosPlugins } from './lib/node-packages';
import { parsePlist, toXmlPlist, type PlistValue } from './lib/plist';
import { manifestFromBytes } from './lib/privacy-files';
import { evaluatePrivacyManifest, type Manifest, parseManifest, proposeAppManifest, scanSources, type SdkInfo } from './lib/privacy-manifest';
import type { AppleDataTypeId, AppleTypeAnswer } from './lib/app-privacy';
import { collectedFromAppPrivacy, evaluatePrivacyManifest, type Manifest, parseManifest, proposeAppManifest, scanSources, type SdkInfo } from './lib/privacy-manifest';
const SKIP = new Set(['.git', 'build', 'DerivedData', 'node_modules', '.build', 'xcuserdata']);
const SRC = /\.(swift|m|mm|c|cc|cpp|h|hpp)$/;
@ -54,11 +56,12 @@ function existingCollected(path: string | undefined): PlistValue[] {
try { const v = parsePlist(readFileSync(path)) as Record<string, PlistValue>; return Array.isArray(v.NSPrivacyCollectedDataTypes) ? v.NSPrivacyCollectedDataTypes : []; } catch { return []; }
}
async function configTrackingDomains(): Promise<string[]> {
async function configAppPrivacy(): Promise<{ trackingDomains: string[]; types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> | undefined }> {
try {
const { loadConfig } = await import('./lib/config');
return (await loadConfig()).config.appPrivacy?.trackingDomains ?? [];
} catch { return []; }
const ap = (await loadConfig()).config.appPrivacy;
return { trackingDomains: ap?.trackingDomains ?? [], types: ap?.types };
} catch { return { trackingDomains: [], types: undefined }; }
}
export async function run(args: Args): Promise<void> {
@ -110,8 +113,16 @@ export async function run(args: Args): Promise<void> {
log('privacy', `${sources.length} source files, ${sdks.length} SDKs, ${manifests.length} app manifest(s)`);
const sourceHits = scanSources(sources);
if (args.has('propose')) {
const domains = args.list('tracking-domains').length ? args.list('tracking-domains') : await configTrackingDomains();
console.log(toXmlPlist(proposeAppManifest({ trackingDomains: domains, apis: appManifest?.apis, used: sourceHits.keys(), collected: existingCollected(manifests[0]) }) as PlistValue).trimEnd());
const ap = await configAppPrivacy();
const domains = args.list('tracking-domains').length ? args.list('tracking-domains') : ap.trackingDomains;
const existing = existingCollected(manifests[0]);
const types = ap.types && Object.keys(ap.types).length ? ap.types : undefined;
console.log(toXmlPlist(proposeAppManifest({ trackingDomains: domains, apis: appManifest?.apis, used: sourceHits.keys(), collected: existing, types }) as PlistValue).trimEnd());
if (types) {
const c = collectedFromAppPrivacy(types, existing);
log('privacy', `NSPrivacyCollectedDataTypes: ${c.entries.length} type(s) from appPrivacy.types (the App Privacy answers)`);
for (const n of c.notes) log('privacy', n);
} else if (!existing.length) log('privacy', 'NSPrivacyCollectedDataTypes empty: no appPrivacy.types in storebound.config.ts and none in the manifest (answer appPrivacy.types first: storebound console plan apple-app-privacy)');
log('privacy', domains.length ? `NSPrivacyTracking true with ${domains.length} app-level tracking domain(s)` : 'NSPrivacyTracking false: no app-level tracking domains (tracking SDKs declare theirs in their own manifests; true with an empty list is ITMS-91064)');
return;
}