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:
parent
86cd79dd45
commit
1366e7e231
34 changed files with 881 additions and 71 deletions
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -1,5 +1,35 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
Fixes from a real first release of a second game (Capacitor 8 monorepo, lessons 35 to 37):
|
||||
|
||||
- Fix (`scan`, review-prompt 3.2.2(x)): a bare "rate" next to "bonus" (`const rate = F.genRate(lv, bonus.gen(r))`) is
|
||||
no longer a reward for rating. The signal needs a rate-us / leave-a-review phrase next to a reward, a reward "for
|
||||
rating / reviewing", or a reward granted within a few lines of a system review prompt call.
|
||||
- Fix (`scan`, account findings and the declarations proposal): import, require and export-from lines are never
|
||||
sign-in / sign-up / deletion evidence (`import './buildings/register'` was), auth endpoints must be an absolute path,
|
||||
a URL or a `${base}/...` template, credential fields and guest-account creation count; accounts only the config
|
||||
claims read "from config (accounts.enabled)".
|
||||
- Fix (`scan`, 5.1.1(v)): `accounts.inApp` alone no longer clears the finding: without deletion code it stays, medium,
|
||||
"claimed by config (accounts.inApp), not found in code".
|
||||
- Fix (`privacy-manifest --propose`): NSPrivacyCollectedDataTypes is built from `appPrivacy.types` (Apple's manifest
|
||||
keys for type and purposes, linked, tracking); NSPrivacyTracking stays true only with app-level domains. `check`
|
||||
warns once when the app manifest's list is empty but `appPrivacy.types` marks types collected.
|
||||
- Fix (`console plan apple-app-privacy`): `appPrivacy.types` is authoritative: a type it does not list is "not
|
||||
collected" (certain), never derived from dataSafety or asked. Without it, `appPrivacy.tracking: false` and no ad /
|
||||
attribution SDK answer Advertising Data, Customer Support, Environment Scanning, Hands and Head "not collected"
|
||||
unless a support SDK or AR code is found. `console plan` scans the project for these signals.
|
||||
- Fix (`console plan play-advertising-id` / `play-app-content`): "Does your app use advertising ID?" comes from ad /
|
||||
attribution SDKs, the AD_ID permission in the source or merged manifest (or its `tools:node="remove"`),
|
||||
`ads.appAdsTxt` or `console.appContent.advertisingId`; a Data safety Device ID alone is not the advertising ID. No
|
||||
signal: No (certain); signals that disagree: asked.
|
||||
- `scan.ignore[].evidence`: accept a finding by the text of an evidence line (`match` keeps matching the title).
|
||||
`scan` and `check` name every entry that matches no finding.
|
||||
- Fix (`init`): `.storebound/credentials.json` starts with empty strings only. Placeholder-looking values
|
||||
("your-…", `<…>`, "xxx", "/absolute/path/to/…", "example") count as missing in every credential read; `check`
|
||||
warns about them and `doctor` lists each credential as set, missing or placeholder.
|
||||
|
||||
## 0.7.1 · 2026-10-13
|
||||
|
||||
- Fix: README links and images on npmjs.com resolved against npmjs.com and gave 404 (`docs/README.md`, the logo).
|
||||
|
|
|
|||
18
cli/check.ts
18
cli/check.ts
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 -`.' },
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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[]> = {};
|
||||
|
|
|
|||
43
cli/lib/project-signals.ts
Normal file
43
cli/lib/project-signals.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 ?? [] : [];
|
||||
|
|
|
|||
|
|
@ -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]++; }
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 37 | 2026-10 | A `scan.ignore` entry whose `match` text was in the evidence (a code line) instead of the finding title accepted nothing and said nothing. `init --profile game` wrote example-looking values into `.storebound/credentials.json` (`YOUR_KEY_ID`, `/absolute/path/to/upload.p12`, `sk_your_revenuecat_v2_secret_key`), which commands read as real credentials. | `scan.ignore[].evidence` matches an evidence line ("file:line text"), `match` stays the title; `scan` (report notes) and `check` (warning) name every entry that accepts no finding (`ignoreMatches` / `applyIgnore` / `unusedIgnores` in `cli/lib/scan/rules.ts`). `templates/credentials.example.json` holds empty strings only; `isPlaceholder` (`cli/lib/credentials.ts`) makes "your-…", `<…>`, "xxx", "/absolute/path/to/…", "example" and the zero Apple Ads client id missing in every credential read; `check` warns, `doctor` shows set / missing / placeholder per key. `tests/game-report.test.ts`. |
|
||||
| 36 | 2026-10 | Privacy answers derived from the wrong source: `privacy-manifest --propose` wrote an EMPTY NSPrivacyCollectedDataTypes while `appPrivacy.types` listed 5 collected types; `console plan apple-app-privacy` added "Other Usage Data" from dataSafety (PSL_OTHER_APP_ACTIVITY) although `appPrivacy.types` was given; `console plan play-advertising-id` answered "uses advertising ID" Yes because dataSafety declared PSL_DEVICE_ID (a random install id, not the advertising ID). | `--propose` builds NSPrivacyCollectedDataTypes from `appPrivacy.types` (types, linked, tracking, purposes as Apple's manifest keys; NSPrivacyTracking still only with app-level domains, lesson 29) and `check` warns on an empty list against collected types. `appPrivacy.types` is authoritative in the App Privacy sheet (unlisted = not collected, certain); without it, tracking false and no ad / attribution SDK make Advertising Data, Customer Support, Environment Scanning, Hands and Head "not collected" unless a project signal suggests them (`cli/lib/project-signals.ts`). Advertising ID comes from ad / attribution SDKs, the AD_ID permission (source or merged manifest, or its `tools:node="remove"`), ads.appAdsTxt or `console.appContent.advertisingId`; none: No (certain), a conflict: ask (`advertisingIdUse`). `tests/game-report.test.ts`. |
|
||||
| 35 | 2026-10 | `scan` on a Capacitor game: rule `review-prompt` (3.2.2(x), reward for rating) fired on `const rate = F.genRate(lv, bonus.gen(r));` (a bare "rate" next to "bonus"); every account finding cited `import './buildings/register';` as sign-up evidence; and 5.1.1(v) cleared as soon as `accounts.inApp` named a screen the code did not have. | The incentive signal needs a rate-us / leave-a-review phrase next to a reward, a reward "for rating / reviewing", or a reward granted within a few lines of a system review prompt call (`rewardNearReviewApi`). Sign-in / sign-up / deletion signals skip import, require and export-from lines (`codeLine`), and auth endpoints must be an absolute path, URL or `${base}/...`, never a module path; config-only accounts say "from config (accounts.enabled)". A config claim of in-app deletion without code evidence stays a finding (medium, "claimed by config, not found in code"). `tests/game-report.test.ts` with `tests/fixtures/scan/capacitor-game`. |
|
||||
| 34 | 2026-10 | The macOS desktop release ran the universal app's x86_64 slice on the Apple silicon build Mac (`arch -x86_64 storebound-desktop --self-test`, and the x64 sidecar's `version`) to cover both CPU types. That starts Rosetta, and macOS showed the owner "App Update Required: this version of storebound includes a component that will not open in macOS 28". | `tools/desktop-release.ts` never runs an x86_64 slice: `sliceProblems` checks every Mach-O in the bundle with `lipo -archs` (each must carry arm64 and x86_64), the self-test and the sidecar `version` run natively (arm64) only; `tests/desktop-release.test.ts` (slice check, and no `arch -x86_64` / Rosetta launch in the release tools); docs/desktop-release.md. |
|
||||
| 33 | 2026-10 | Play Console: pressing "Submit N changes for review" in Publishing overview while a review was running opened a dialog "Do you want to restart your review?" with "Restart review". Pressing it restarts the running review with the new changes; the plan did not know the dialog. | `play-production-release` plan: the dialog is a known step and UI fact, "Restart review" is in neverPress (the owner's button), Cancel is allowed (closes it, nothing changes); play-first-release and release-management skills explain it. `tests/console-play-plans.test.ts`. |
|
||||
| 32 | 2026-10 | `play build` passed `-PversionCode` / `-PversionName` and `android.injected.version.*`, but a Capacitor `app/build.gradle` read versionCode from an env var, so the AAB stayed at versionCode 1 (the post-build check caught it). Checked with AGP 8.13: `-Pandroid.injected.version.code=42` on `versionCode 1` gives an AAB with versionCode 1, so the injected property does not override `defaultConfig` for `bundleRelease`. The Groovy line `versionCode (project.findProperty('versionCode') ?: 1) as Integer` fails ("Value is null": it parses as `versionCode(...) as Integer`); the cast belongs inside the parentheses. | `versionSourceIssues` (cli/lib/gradle.ts) reads `defaultConfig` of the app module's build.gradle(.kts): a literal, an env var or anything but the project property is a warning in `play build` (before Gradle, also in the dry run and `--json` as `versionWarnings`) and in `check`, with the exact line (Groovy `versionCode((project.findProperty('versionCode') ?: <current>) as Integer)`, `versionName project.findProperty('versionName') ?: '<current>'`; Kotlin `versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: <current>`, `versionName = project.findProperty("versionName") as String? ?: "<current>"`), both lines built with AGP 8.13; the post-build versionCode check is now a hard stop (the AAB is not copied). `tests/build-sign.test.ts`, `tests/web-assets.test.ts`. |
|
||||
|
|
|
|||
|
|
@ -470,3 +470,44 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
- **Guard:** `tools/desktop-release.ts` (`archProblems` before signing: every Mach-O must contain arm64, no Intel-only
|
||||
file, no Info.plist that forces Intel; `sliceProblems`: every Mach-O in the bundle must carry arm64 and x86_64,
|
||||
nothing x86_64 is run); `tests/desktop-release.test.ts`.
|
||||
|
||||
## 35. Scan findings from a bare word, a module path or a config claim
|
||||
- **Symptom:** `scan` reports "A reward for rating or reviewing the app" on `const rate = F.genRate(lv, bonus.gen(r));`;
|
||||
account findings cite `import './buildings/register';`; 5.1.1(v) disappears once `accounts.inApp` is set although
|
||||
the app has no delete-account code.
|
||||
- **Cause:** the signals matched "rate" next to "bonus", and "/register'" anywhere on a line; the config's word was
|
||||
taken as proof.
|
||||
- **Do:** read every finding's evidence before acting on it. A finding whose evidence is not real code is a scan bug:
|
||||
report it (and accept it with `scan.ignore` and the owner's reason meanwhile). For 5.1.1(v), show the deletion code
|
||||
path or screen; a config line alone is a claim, not evidence.
|
||||
- **Guard:** the incentive signal needs a rate-us / review phrase next to a reward, a reward "for rating", or a reward
|
||||
next to a system review prompt call; sign-in / sign-up / deletion signals skip import and require lines and accept
|
||||
only absolute auth paths or URLs; config-only accounts read "from config (accounts.enabled)"; deletion claimed only
|
||||
by the config stays a medium finding ("claimed by config, not found in code"). `tests/game-report.test.ts`.
|
||||
|
||||
## 36. Privacy answers derived from the wrong source
|
||||
- **Symptom:** `privacy-manifest --propose` prints an empty NSPrivacyCollectedDataTypes while `appPrivacy.types` lists
|
||||
collected types; the App Privacy plan adds "Other Usage Data" you never listed; the Play plan answers "uses
|
||||
advertising ID: Yes" because Data safety declares a Device ID.
|
||||
- **Cause:** the proposal only kept the manifest's own entries; the App Privacy sheet mixed in types derived from
|
||||
dataSafety; the advertising ID answer read PSL_DEVICE_ID (a random install id is not the advertising ID).
|
||||
- **Do:** treat `appPrivacy.types` as the App Privacy answer: a type it does not list is "not collected". Answer the
|
||||
advertising ID from the SDKs and the merged manifest (`com.google.android.gms.permission.AD_ID`), never from Data
|
||||
safety alone; set `console.appContent.advertisingId` when the signals disagree.
|
||||
- **Guard:** `--propose` builds the list from `appPrivacy.types` (linked, tracking, purposes; NSPrivacyTracking true
|
||||
only with app-level domains, lesson 29) and `check` warns on an empty list; `console plan` scans the project
|
||||
(ad / attribution SDKs, AD_ID, support and AR code) and answers No with no signal, asks on a conflict.
|
||||
`tests/game-report.test.ts`.
|
||||
|
||||
## 37. `scan.ignore` that matches nothing; placeholder credentials
|
||||
- **Symptom:** a `scan.ignore` entry does not accept the finding and nothing says why; commands treat
|
||||
`YOUR_KEY_ID` or `/absolute/path/to/upload.p12` in `.storebound/credentials.json` as a credential.
|
||||
- **Cause:** `match` is compared with the finding TITLE, not the evidence; the 0.7.1 credentials template held
|
||||
example-looking values.
|
||||
- **Do:** `scan.ignore: [{ rule, reason, match?, evidence?, store? }]`: `match` = text in the finding title (one check
|
||||
of a rule with several), `evidence` = text in one evidence line as the report prints it (`src/main.ts:12` or a code
|
||||
fragment); both together narrow further. Never add an entry without the owner's reason. Fill credentials with real
|
||||
values or leave them empty.
|
||||
- **Guard:** `scan` (notes) and `check` (warnings) name every entry that accepts no finding; the credentials template
|
||||
is empty strings; "your-…", `<…>`, "xxx", "/absolute/path/to/…" and "example" values count as missing everywhere
|
||||
(`check` warns, `doctor` lists them). `tests/game-report.test.ts`.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ the stores; run them freely.
|
|||
Privacy / ATT / privacy manifest proposal, each item "detected (file:line)" or "ask owner", as a diff against the
|
||||
config. Exit 1 on an open blocker. `--write` adds only the detected answers to the config (show the diff and ask
|
||||
the owner first; MCP never writes). `--store apple|play`, `--json`. A finding the owner accepts goes into
|
||||
`scan.ignore: [{ rule, reason, match? }]` (`match` = text in the finding title, to accept one check of a rule with several); never add one without the owner's reason. The Play rules also read
|
||||
`scan.ignore: [{ rule, reason, match?, evidence? }]` (`match` = text in the finding title, to accept one check of a rule with several; `evidence` = text in one evidence line, e.g. `src/main.ts:12`); never add one without the owner's reason. `scan` and `check` name an entry that matches no finding. The Play rules also read
|
||||
`play.targetAges` (Families), `play.accountType` / `play.accountCreated` (closed-testing requirement) and
|
||||
`play.billingPrograms` (payments): ask the owner for them, never guess.
|
||||
1. `storebound check`: config, both listing files (limits are checked locally: title 30, subtitle 30, keywords 100
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"PLAY_SERVICE_ACCOUNT": "/absolute/path/to/play-service-account.json",
|
||||
"ASC_KEY_ID": "YOUR_KEY_ID",
|
||||
"ASC_ISSUER_ID": "00000000-0000-0000-0000-000000000000",
|
||||
"ASC_P8": "/absolute/path/to/AuthKey_<KEY_ID>.p8",
|
||||
"APPLE_ADS_CLIENT_ID": "SEARCHADS.00000000-0000-0000-0000-000000000000",
|
||||
"APPLE_ADS_TEAM_ID": "SEARCHADS.00000000-0000-0000-0000-000000000000",
|
||||
"APPLE_ADS_KEY_ID": "00000000-0000-0000-0000-000000000000",
|
||||
"APPLE_ADS_PRIVATE_KEY": "/absolute/path/to/apple-ads-private-key.pem",
|
||||
"PLAY_SERVICE_ACCOUNT": "",
|
||||
"ASC_KEY_ID": "",
|
||||
"ASC_ISSUER_ID": "",
|
||||
"ASC_P8": "",
|
||||
"APPLE_ADS_CLIENT_ID": "",
|
||||
"APPLE_ADS_TEAM_ID": "",
|
||||
"APPLE_ADS_KEY_ID": "",
|
||||
"APPLE_ADS_PRIVATE_KEY": "",
|
||||
"APPLE_ADS_ACCOUNT_ID": "",
|
||||
"REVENUECAT_SECRET_KEY": "sk_your_revenuecat_v2_secret_key",
|
||||
"ANDROID_KEYSTORE": "/absolute/path/to/upload.p12",
|
||||
"REVENUECAT_SECRET_KEY": "",
|
||||
"ANDROID_KEYSTORE": "",
|
||||
"ANDROID_KEYSTORE_PASSWORD": "",
|
||||
"ANDROID_KEY_ALIAS": "upload",
|
||||
"ANDROID_KEY_ALIAS": "",
|
||||
"ANDROID_KEY_PASSWORD": "",
|
||||
"ASC_VENDOR_NUMBER": "",
|
||||
"reviewContact": { "firstName": "", "lastName": "", "phone": "+1 555 0100", "email": "review-contact@example.com" },
|
||||
"reviewContact": { "firstName": "", "lastName": "", "phone": "", "email": "" },
|
||||
"reviewDemoAccount": { "username": "", "password": "" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ describe('appPrivacy.types (the owner\'s answers)', () => {
|
|||
expect(a.get('type:Gameplay Content')).toMatchObject({ value: false, certain: true });
|
||||
expect(a.get('linked:Performance Data')).toMatchObject({ value: false, certain: true });
|
||||
expect(a.get('tracking:Performance Data')).toMatchObject({ value: null, certain: false }); // not set: (you decide)
|
||||
expect(a.get('type:Customer Support')).toMatchObject({ value: null, certain: false });
|
||||
// appPrivacy.types is authoritative: a type it does not list is not collected (not asked, not derived)
|
||||
expect(a.get('type:Customer Support')).toMatchObject({ value: false, certain: true, source: 'appPrivacy.types (not listed: not collected)' });
|
||||
// the Advertising Data dialog gets a section of its own
|
||||
expect(formDef('apple-app-privacy', [...a.values()]).sections.map(s => s.title)).toContain('Advertising Data: purposes');
|
||||
});
|
||||
|
|
@ -86,7 +87,7 @@ describe('appPrivacy.types (the owner\'s answers)', () => {
|
|||
const a = answers(c);
|
||||
expect(a.get('collects')).toMatchObject({ value: true, certain: true });
|
||||
expect(a.get('type:Hands')?.value).toBe(true);
|
||||
expect(a.get('type:Device ID')).toMatchObject({ value: null, certain: false });
|
||||
expect(a.get('type:Device ID')).toMatchObject({ value: false, certain: true });
|
||||
expect(a.get('linked:Hands')?.certain).toBe(false);
|
||||
const none = { ...base, dataSafety: undefined, appPrivacy: { types: Object.fromEntries(APPLE_DATA_TYPE_IDS.map(id => [id, { collected: false }])) } } as ShipConfig;
|
||||
expect([...answers(none).values()]).toEqual([expect.objectContaining({ id: 'collects', value: false, certain: true })]);
|
||||
|
|
@ -136,7 +137,7 @@ describe('check: appPrivacy.types against the iOS project', () => {
|
|||
r = appPrivacyMismatches({ GAMEPLAY_CONTENT: { collected: true, tracking: false } }, { attString: true, manifest: m(true, []) });
|
||||
expect(msgs(r)).toContain('warn: Info.plist has NSUserTrackingUsageDescription, but appPrivacy.types marks no collected type as used for tracking');
|
||||
expect(msgs(r)).toContain('has NSPrivacyTracking true, but appPrivacy.types marks no collected type');
|
||||
expect(msgs(r)).toContain('appPrivacy.types.GAMEPLAY_CONTENT is collected, but App/PrivacyInfo.xcprivacy has no NSPrivacyCollectedDataTypeGameplayContent');
|
||||
expect(msgs(r)).toContain('App/PrivacyInfo.xcprivacy has an empty NSPrivacyCollectedDataTypes, but appPrivacy.types marks GAMEPLAY_CONTENT as collected');
|
||||
expect(appPrivacyMismatches({ DEVICE_ID: { collected: true, tracking: true } }, { attString: true, manifest: m(true, ['NSPrivacyCollectedDataTypeDeviceID']) })).toEqual([]);
|
||||
});
|
||||
test('storebound check: a missing accounts.deletionUrl is a Data safety problem (Play 400 otherwise)', async () => {
|
||||
|
|
|
|||
7
tests/fixtures/scan/capacitor-game/README.md
vendored
Normal file
7
tests/fixtures/scan/capacitor-game/README.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# capacitor-game fixture
|
||||
|
||||
A made-up Capacitor 8 farm game (first release) with the patterns a real-use report found: a module named
|
||||
`buildings/register` imported for its side effects, production rates next to a `bonus` object, accounts and in-app
|
||||
deletion claimed only by the config (no auth or deletion code), five App Privacy types in `appPrivacy.types` while the
|
||||
app's PrivacyInfo.xcprivacy lists none, a random install id declared as Device ID in Data safety, and no ad SDK or
|
||||
AD_ID permission. Not a buildable project.
|
||||
5
tests/fixtures/scan/capacitor-game/android/app/src/main/AndroidManifest.xml
vendored
Normal file
5
tests/fixtures/scan/capacitor-game/android/app/src/main/AndroidManifest.xml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application android:label="Example Farm" />
|
||||
</manifest>
|
||||
5
tests/fixtures/scan/capacitor-game/capacitor.config.json
vendored
Normal file
5
tests/fixtures/scan/capacitor-game/capacitor.config.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"appId": "org.example.farmgame",
|
||||
"appName": "Example Farm",
|
||||
"webDir": "dist"
|
||||
}
|
||||
10
tests/fixtures/scan/capacitor-game/ios/App/App/Info.plist
vendored
Normal file
10
tests/fixtures/scan/capacitor-game/ios/App/App/Info.plist
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Example Farm</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
23
tests/fixtures/scan/capacitor-game/ios/App/App/PrivacyInfo.xcprivacy
vendored
Normal file
23
tests/fixtures/scan/capacitor-game/ios/App/App/PrivacyInfo.xcprivacy
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
10
tests/fixtures/scan/capacitor-game/package.json
vendored
Normal file
10
tests/fixtures/scan/capacitor-game/package.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "farm-game",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.0.0",
|
||||
"@capacitor/core": "8.0.0",
|
||||
"@capacitor/ios": "8.0.0",
|
||||
"@capacitor/preferences": "8.0.0"
|
||||
}
|
||||
}
|
||||
7
tests/fixtures/scan/capacitor-game/src/buildings/register.ts
vendored
Normal file
7
tests/fixtures/scan/capacitor-game/src/buildings/register.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { registry } from './registry';
|
||||
|
||||
export class Farm { level = 1 }
|
||||
export class Mill { level = 1 }
|
||||
|
||||
registry.register('farm', Farm);
|
||||
registry.register('mill', Mill);
|
||||
11
tests/fixtures/scan/capacitor-game/src/economy.ts
vendored
Normal file
11
tests/fixtures/scan/capacitor-game/src/economy.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export const bonus = { gen: (r: number) => 1 + r * 0.1 };
|
||||
export const F = {
|
||||
genRate: (lv: number, mult: number) => lv * 2 * mult,
|
||||
};
|
||||
|
||||
export function tick(f: typeof F, lv = 1, r = 0): number {
|
||||
const rate = F.genRate(lv, bonus.gen(r));
|
||||
const frameRate = 60; // bonus coins drop every second
|
||||
const exchangeRate = 10; // coins per gem
|
||||
return rate * frameRate / exchangeRate;
|
||||
}
|
||||
10
tests/fixtures/scan/capacitor-game/src/main.ts
vendored
Normal file
10
tests/fixtures/scan/capacitor-game/src/main.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import './buildings/register';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { F, tick } from './economy';
|
||||
|
||||
export async function boot(): Promise<void> {
|
||||
const saved = await Preferences.get({ key: 'farm' });
|
||||
const installId = saved.value ?? crypto.randomUUID();
|
||||
await Preferences.set({ key: 'farm', value: installId });
|
||||
setInterval(() => tick(F), 1000);
|
||||
}
|
||||
41
tests/fixtures/scan/capacitor-game/storebound.config.ts
vendored
Normal file
41
tests/fixtures/scan/capacitor-game/storebound.config.ts
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Fixture config (made up, example.com placeholders only).
|
||||
export default {
|
||||
app: {
|
||||
name: 'Example Farm',
|
||||
profile: 'game',
|
||||
privacyPolicyUrl: 'https://example.com/privacy',
|
||||
supportUrl: 'https://example.com/support',
|
||||
website: 'https://example.com',
|
||||
contactEmail: 'support@example.com',
|
||||
},
|
||||
accounts: { enabled: true, deletionUrl: 'https://example.com/delete-account', inApp: 'Settings > Delete account' },
|
||||
reviewAccess: { signInRequired: false, instructions: 'No sign-in needed.' },
|
||||
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
|
||||
play: { packageName: 'org.example.farmgame', accountType: 'organization', targetAges: ['13-15', '16-17', '18+'] },
|
||||
apple: { bundleId: 'org.example.farmgame', usesNonExemptEncryption: false },
|
||||
appPrivacy: {
|
||||
tracking: false,
|
||||
types: {
|
||||
USER_ID: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
||||
DEVICE_ID: { collected: true, linked: false, tracking: false, purposes: ['APP_FUNCTIONALITY', 'ANALYTICS'] },
|
||||
GAMEPLAY_CONTENT: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
||||
PRODUCT_INTERACTION: { collected: true, linked: false, tracking: false, purposes: ['ANALYTICS'] },
|
||||
CRASH_DATA: { collected: true, linked: false, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
||||
},
|
||||
},
|
||||
dataSafety: {
|
||||
collectsData: true,
|
||||
encryptedInTransit: true,
|
||||
data: {
|
||||
PSL_USER_ACCOUNT: { shared: false, collect: ['account', 'functionality'] },
|
||||
PSL_DEVICE_ID: { shared: false, collect: ['functionality', 'analytics'] },
|
||||
PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['analytics'] },
|
||||
PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] },
|
||||
},
|
||||
},
|
||||
scan: {
|
||||
ignore: [
|
||||
{ rule: 'privacy-labels', evidence: 'src/no-such-file.ts', reason: 'stale entry: matches nothing' },
|
||||
],
|
||||
},
|
||||
};
|
||||
307
tests/game-report.test.ts
Normal file
307
tests/game-report.test.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// A real-use report from a second game (Capacitor monorepo, first release): false positives and gaps in scan, check,
|
||||
// privacy-manifest, the console answer sheets and init. The patterns are recreated with example code.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { type AppleDataTypeId, type AppleTypeAnswer, appPrivacyMismatches } from '../cli/lib/app-privacy';
|
||||
import type { ShipConfig } from '../cli/lib/config';
|
||||
import { validateConfig } from '../cli/lib/config';
|
||||
import { consoleAnswers } from '../cli/lib/console-answers';
|
||||
import { consolePlan } from '../cli/lib/console-plan';
|
||||
import { advertisingIdUse } from '../cli/lib/console-play-plans';
|
||||
import { credentialStatus, credentialValue, isPlaceholder } from '../cli/lib/credentials';
|
||||
import { collectedFromAppPrivacy, proposeAppManifest } from '../cli/lib/privacy-manifest';
|
||||
import { type ProjectSignals, projectSignals } from '../cli/lib/project-signals';
|
||||
import { runScan } from '../cli/lib/scan';
|
||||
import { detectProject } from '../cli/lib/scan/project';
|
||||
import { applyIgnore, type ScanFinding, SIGNALS, rewardNearReviewApi, unusedIgnores } from '../cli/lib/scan/rules';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const FX = join(import.meta.dir, 'fixtures', 'scan', 'capacitor-game');
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'sb-game-report-'));
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
/** A copy of the fixture with files replaced (path -> text). */
|
||||
function variant(name: string, files: Record<string, string>): string {
|
||||
const d = join(tmp, name);
|
||||
cpSync(FX, d, { recursive: true });
|
||||
for (const [p, t] of Object.entries(files)) { mkdirSync(join(d, p, '..'), { recursive: true }); writeFileSync(join(d, p), t); }
|
||||
return d;
|
||||
}
|
||||
const fxConfig = readFileSync(join(FX, 'storebound.config.ts'), 'utf8');
|
||||
|
||||
describe('review-prompt: a bare "rate" next to a reward word is not a reward for rating', () => {
|
||||
test('negatives: production rates, frame rates, rate limits, exchange rates, code review', () => {
|
||||
for (const t of [
|
||||
'const rate = F.genRate(lv, bonus.gen(r));',
|
||||
'const frameRate = 60; // bonus coins drop at 30 fps',
|
||||
'if (rateLimit.hit) return bonus.coins;',
|
||||
'const exchangeRate = coins / gems;',
|
||||
'rate limit bonus reward',
|
||||
'review the code before the bonus round ships',
|
||||
'growth rate per level gives bonus coins',
|
||||
]) expect({ t, hit: SIGNALS.incentiveRating.test(t) }).toEqual({ t, hit: false });
|
||||
});
|
||||
test('positives: rate-us phrases next to a reward, rewards for rating', () => {
|
||||
for (const t of [
|
||||
'Rate us 5 stars and get 100 coins',
|
||||
'review the app to earn a reward',
|
||||
'Get 50 gems for rating us',
|
||||
'rate the game for a bonus',
|
||||
'Leave a review and get 20 gems!',
|
||||
'Write us a review for free spins',
|
||||
]) expect({ t, hit: SIGNALS.incentiveRating.test(t) }).toEqual({ t, hit: true });
|
||||
});
|
||||
test('a reward granted next to the system review prompt call', () => {
|
||||
const yes = { path: 'src/rate.ts', text: 'export async function askRating() {\n await InAppReview.requestReview();\n wallet.addCoins(50);\n}\n' };
|
||||
expect(rewardNearReviewApi([yes]).map(h => h.line)).toEqual([2]);
|
||||
const no = { path: 'src/rate.ts', text: 'export async function askRating() {\n await InAppReview.requestReview();\n}\n' + '\n'.repeat(20) + 'wallet.addCoins(50);\n' };
|
||||
expect(rewardNearReviewApi([no])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('account signals: module paths are not auth code', () => {
|
||||
test('negatives: import paths and module names with register / login / account', () => {
|
||||
for (const t of [
|
||||
"import './buildings/register';",
|
||||
"import { registerAll } from './modules/register';",
|
||||
"export * from './account/login';",
|
||||
"const m = require('./login');",
|
||||
"const Screen = lazy(() => import('./screens/register'));",
|
||||
"loadModule('./buildings/register');",
|
||||
"registry.register('farm', Farm);",
|
||||
"router.add({ path: './buildings/signup' });",
|
||||
]) expect({ t, up: SIGNALS.signUp.test(t), in: SIGNALS.signIn.test(t) }).toEqual({ t, up: false, in: false });
|
||||
});
|
||||
test('positives: auth endpoints, SDK calls, credential fields, guest accounts, screen strings', () => {
|
||||
for (const t of [
|
||||
"await fetch(`${API}/auth/register`, { method: 'POST', body });",
|
||||
"fetch('/api/signup', { method: 'POST' })",
|
||||
"http.post('https://api.example.com/v1/register', body)",
|
||||
'<button>Sign up</button>',
|
||||
'const guest = await createGuestAccount(deviceId);',
|
||||
'<input autoComplete="new-password" />',
|
||||
'await supabase.auth.signUp({ email, password })',
|
||||
]) expect({ t, hit: SIGNALS.signUp.test(t) }).toEqual({ t, hit: true });
|
||||
for (const t of ["fetch('/auth/login', { method: 'POST' })", '<input type="password" autoComplete="current-password" />', 'await signInWithEmailAndPassword(auth, e, p)'])
|
||||
expect({ t, hit: SIGNALS.signIn.test(t) }).toEqual({ t, hit: true });
|
||||
});
|
||||
test('a whole file: the import line is skipped, the real call on a later line still counts', () => {
|
||||
expect(SIGNALS.signUp.test("import './buildings/register';\nconst x = 1;\n")).toBe(false);
|
||||
expect(SIGNALS.signUp.test("import './buildings/register';\nawait fetch('/api/register', { method: 'POST' });\n")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan on the Capacitor game fixture', () => {
|
||||
test('no reward-for-rating finding from production rates; account evidence never an import path', async () => {
|
||||
const r = (await runScan(FX)).report;
|
||||
expect(r.findings.filter(f => f.rule === 'review-prompt')).toEqual([]);
|
||||
const account = r.findings.filter(f => ['account-deletion', 'play-deletion-url', 'demo-account', 'privacy-policy-link'].includes(f.rule));
|
||||
expect(account.length).toBeGreaterThan(0);
|
||||
for (const f of account) for (const e of f.evidence) expect({ rule: f.rule, bad: /import|buildings\/register/.test(e.text) }).toEqual({ rule: f.rule, bad: false });
|
||||
const ua = r.declarations.items.find(i => i.what === 'PSL_USER_ACCOUNT')!;
|
||||
expect(ua.evidence[0]!.text).toBe('from config (accounts.enabled)');
|
||||
});
|
||||
test('accounts.inApp alone does not clear 5.1.1(v): medium, "claimed by config"', async () => {
|
||||
const r = (await runScan(FX)).report;
|
||||
const del = r.findings.find(f => f.rule === 'account-deletion')!;
|
||||
expect(del).toMatchObject({ severity: 'medium', store: 'apple' });
|
||||
expect(del.title).toContain('claimed by config');
|
||||
expect(del.evidence[0]!.text).toBe('from config (accounts.inApp: Settings > Delete account)');
|
||||
expect(del.fix).toContain('Show the code path or the screen');
|
||||
});
|
||||
test('deletion code clears it; no config claim and no code is a blocker; an import of a delete-account module is not code', async () => {
|
||||
const withCode = variant('del-code', { 'src/settings.ts': "export async function deleteAccount(): Promise<void> {\n await fetch('/api/account', { method: 'DELETE' });\n}\n" });
|
||||
expect((await runScan(withCode)).report.findings.filter(f => f.rule === 'account-deletion')).toEqual([]);
|
||||
const noClaim = variant('del-none', { 'storebound.config.ts': fxConfig.replace(", inApp: 'Settings > Delete account'", '') });
|
||||
expect((await runScan(noClaim)).report.findings.find(f => f.rule === 'account-deletion')).toMatchObject({ severity: 'blocker', title: 'Accounts can be created but no in-app account deletion was found' });
|
||||
const imported = variant('del-import', { 'src/menu.ts': "import './screens/delete-account';\n" });
|
||||
expect((await runScan(imported)).report.findings.find(f => f.rule === 'account-deletion')!.severity).toBe('medium');
|
||||
});
|
||||
test('a scan.ignore entry that matches nothing is reported; evidence matching accepts by the evidence text', async () => {
|
||||
const r = (await runScan(FX)).report;
|
||||
expect(r.notes.some(n => n.startsWith('scan.ignore[0] (rule privacy-labels, evidence "src/no-such-file.ts") matches no finding'))).toBe(true);
|
||||
const by = variant('ignore-evidence', { 'storebound.config.ts': fxConfig.replace("{ rule: 'privacy-labels', evidence: 'src/no-such-file.ts', reason: 'stale entry: matches nothing' }",
|
||||
"{ rule: 'account-deletion', evidence: 'accounts.inApp', reason: 'deletion is a server-driven screen, checked on a device' }") });
|
||||
const r2 = (await runScan(by)).report;
|
||||
expect(r2.findings.find(f => f.rule === 'account-deletion')!.accepted).toBe('deletion is a server-driven screen, checked on a device');
|
||||
expect(r2.notes.filter(n => n.startsWith('scan.ignore['))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan.ignore: title (match) and evidence', () => {
|
||||
const f = (): ScanFinding => ({ rule: 'privacy-labels', guideline: '5.1.2', store: 'apple', severity: 'medium', title: 'Data types the SDKs collect are missing', evidence: [{ file: 'src/sdk.ts', line: 7, text: 'Analytics.init(key)' }], fix: 'x', skill: 's', ref: 'https://example.com' });
|
||||
test('match keeps matching the title (backward compatible), evidence matches file:line or text, both together narrow', () => {
|
||||
const cases: [{ rule: string; store?: 'apple' | 'play'; match?: string; evidence?: string; reason: string }, boolean][] = [
|
||||
[{ rule: 'privacy-labels', match: 'SDKS COLLECT', reason: 'r' }, true],
|
||||
[{ rule: 'privacy-labels', match: 'Analytics.init', reason: 'r' }, false],
|
||||
[{ rule: 'privacy-labels', evidence: 'src/sdk.ts:7', reason: 'r' }, true],
|
||||
[{ rule: 'privacy-labels', evidence: 'analytics.init', reason: 'r' }, true],
|
||||
[{ rule: 'privacy-labels', evidence: 'src/other.ts', reason: 'r' }, false],
|
||||
[{ rule: 'privacy-labels', match: 'missing', evidence: 'src/sdk.ts', reason: 'r' }, true],
|
||||
[{ rule: 'privacy-labels', match: 'missing', evidence: 'src/other.ts', reason: 'r' }, false],
|
||||
[{ rule: 'privacy-labels', store: 'play', reason: 'r' }, false],
|
||||
];
|
||||
for (const [i, yes] of cases) {
|
||||
const x = f();
|
||||
applyIgnore([x], [i]);
|
||||
expect({ i, accepted: !!x.accepted }).toEqual({ i, accepted: yes });
|
||||
}
|
||||
});
|
||||
test('unused entries are named; originality entries are skipped unless that rule ran', () => {
|
||||
const msgs = unusedIgnores([f()], [{ rule: 'privacy-labels', evidence: 'src/sdk.ts', reason: 'r' }, { rule: 'privacy-label', reason: 'typo' }, { rule: 'originality', reason: 'r' }]);
|
||||
expect(msgs.length).toBe(1);
|
||||
expect(msgs[0]).toStartWith('scan.ignore[1] (rule privacy-label) matches no finding');
|
||||
});
|
||||
test('the config accepts evidence and rejects an empty one', () => {
|
||||
const base = { app: { name: 'A', privacyPolicyUrl: 'https://example.com/p' }, languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }] };
|
||||
const errs = (ignore: unknown[]) => validateConfig({ ...base, scan: { ignore } }).filter(e => e.startsWith('scan.ignore'));
|
||||
expect(errs([{ rule: 'x', evidence: 'src/a.ts:3', reason: 'r' }])).toEqual([]);
|
||||
expect(errs([{ rule: 'x', evidence: ' ', reason: 'r' }]).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacy manifest from appPrivacy.types', () => {
|
||||
const types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = {
|
||||
USER_ID: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
||||
DEVICE_ID: { collected: true, linked: false, tracking: true, purposes: ['APP_FUNCTIONALITY', 'THIRD_PARTY_ADVERTISING'] },
|
||||
PRODUCT_INTERACTION: { collected: true, linked: false, purposes: ['ANALYTICS'] },
|
||||
CRASH_DATA: { collected: true, linked: false, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
||||
GAMEPLAY_CONTENT: { collected: true, linked: true, tracking: false, purposes: ['APP_FUNCTIONALITY'] },
|
||||
NAME: { collected: false },
|
||||
};
|
||||
test('--propose lists every collected type with linked, tracking and purposes; NSPrivacyTracking stays false without domains', () => {
|
||||
const m = proposeAppManifest({ types, collected: [] }) as { NSPrivacyTracking: boolean; NSPrivacyCollectedDataTypes: Record<string, unknown>[] };
|
||||
expect(m.NSPrivacyTracking).toBe(false);
|
||||
expect(m.NSPrivacyCollectedDataTypes.map(e => e.NSPrivacyCollectedDataType)).toEqual(['NSPrivacyCollectedDataTypeGameplayContent', 'NSPrivacyCollectedDataTypeUserID',
|
||||
'NSPrivacyCollectedDataTypeDeviceID', 'NSPrivacyCollectedDataTypeProductInteraction', 'NSPrivacyCollectedDataTypeCrashData']);
|
||||
expect(m.NSPrivacyCollectedDataTypes.find(e => e.NSPrivacyCollectedDataType === 'NSPrivacyCollectedDataTypeDeviceID')).toEqual({
|
||||
NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeDeviceID', NSPrivacyCollectedDataTypeLinked: false, NSPrivacyCollectedDataTypeTracking: true,
|
||||
NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality', 'NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising'] });
|
||||
expect((proposeAppManifest({ types, trackingDomains: ['t.example.com'] }) as { NSPrivacyTracking: boolean }).NSPrivacyTracking).toBe(true);
|
||||
// no types: the manifest's own entries stay
|
||||
expect((proposeAppManifest({ collected: [{ NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeName' }] }) as { NSPrivacyCollectedDataTypes: unknown[] }).NSPrivacyCollectedDataTypes.length).toBe(1);
|
||||
});
|
||||
test('notes: unanswered fields and manifest entries that appPrivacy.types does not mark collected', () => {
|
||||
const { notes } = collectedFromAppPrivacy(types, [{ NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeName' }]);
|
||||
expect(notes).toContain('appPrivacy.types.PRODUCT_INTERACTION.tracking is not answered: wrote false');
|
||||
expect(notes.some(n => n.startsWith('NSPrivacyCollectedDataTypeName was in the manifest'))).toBe(true);
|
||||
});
|
||||
test('check: an empty NSPrivacyCollectedDataTypes against collected types is one warning naming --propose', () => {
|
||||
const out = appPrivacyMismatches(types, { attString: true, manifest: { path: 'ios/App/App/PrivacyInfo.xcprivacy', tracking: false, collectedTypes: [] }, tracking: { trackerSdks: ['Example Ads'] } });
|
||||
const w = out.filter(x => x.msg.includes('empty NSPrivacyCollectedDataTypes'));
|
||||
expect(w).toHaveLength(1);
|
||||
expect(w[0]!.level).toBe('warn');
|
||||
expect(w[0]!.msg).toContain('--propose');
|
||||
expect(w[0]!.msg).toContain('USER_ID, DEVICE_ID, PRODUCT_INTERACTION, CRASH_DATA, GAMEPLAY_CONTENT');
|
||||
});
|
||||
test('the CLI proposes the fixture manifest from the config', () => {
|
||||
const r = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'privacy-manifest', '--ios', 'ios/App', '--propose'], { cwd: FX, env: { ...process.env, STOREBOUND_PROFILE: '' } });
|
||||
const out = r.stdout.toString();
|
||||
expect(r.exitCode).toBe(0);
|
||||
for (const k of ['UserID', 'DeviceID', 'GameplayContent', 'ProductInteraction', 'CrashData']) expect(out).toContain(`<string>NSPrivacyCollectedDataType${k}</string>`);
|
||||
expect(out).toMatch(/<key>NSPrivacyTracking<\/key>\s*<false\/>/);
|
||||
});
|
||||
});
|
||||
|
||||
const none: ProjectSignals = { adSdks: [], attributionSdks: [], adIdSdks: [], adIdPermission: null, adIdRemoved: null, support: null, ar: null };
|
||||
|
||||
describe('App Privacy answer sheet: appPrivacy.types is authoritative', () => {
|
||||
const cfg = async () => (await runScan(FX)).config!;
|
||||
test('unlisted types are not collected (certain), never derived from dataSafety (PSL_OTHER_APP_ACTIVITY) or asked', async () => {
|
||||
const a = consoleAnswers('apple-app-privacy', await cfg(), none);
|
||||
const types = a.filter(x => x.id.startsWith('type:'));
|
||||
expect(types.filter(x => !x.certain)).toEqual([]);
|
||||
expect(a.find(x => x.id === 'type:Other Usage Data')).toMatchObject({ value: false, certain: true, source: 'appPrivacy.types (not listed: not collected)' });
|
||||
expect(types.filter(x => x.value === true).map(x => x.id).sort()).toEqual(['type:Crash Data', 'type:Device ID', 'type:Gameplay Content', 'type:Product Interaction', 'type:User ID']);
|
||||
// the same without project signals: authoritative does not need a scan
|
||||
expect(consoleAnswers('apple-app-privacy', await cfg()).filter(x => x.id.startsWith('type:') && !x.certain)).toEqual([]);
|
||||
});
|
||||
test('without appPrivacy.types: tracking false and no ad SDK default the ad, support and AR types to not collected, unless a signal says otherwise', async () => {
|
||||
const c = { ...(await cfg()), appPrivacy: { tracking: false } } as ShipConfig;
|
||||
const val = (s: ProjectSignals | undefined, t: string) => consoleAnswers('apple-app-privacy', c, s).find(x => x.id === `type:${t}`)!;
|
||||
for (const t of ['Advertising Data', 'Customer Support', 'Environment Scanning', 'Hands', 'Head']) expect(val(none, t)).toMatchObject({ value: false, certain: true });
|
||||
expect(val(undefined, 'Advertising Data').certain).toBe(false);
|
||||
expect(val({ ...none, ar: 'src/ar.swift:3' }, 'Hands').certain).toBe(false);
|
||||
expect(val({ ...none, ar: 'src/ar.swift:3' }, 'Advertising Data').value).toBe(false);
|
||||
expect(val({ ...none, support: 'package.json:9' }, 'Customer Support').certain).toBe(false);
|
||||
expect(val({ ...none, adSdks: ['Google AdMob'] }, 'Advertising Data').certain).toBe(false);
|
||||
expect(consoleAnswers('apple-app-privacy', { ...c, appPrivacy: { tracking: true } } as ShipConfig, none).find(x => x.id === 'type:Advertising Data')!.certain).toBe(false);
|
||||
// dataSafety still answers the types it maps (Other Usage Data from PSL_OTHER_APP_ACTIVITY) when there are no types
|
||||
expect(val(none, 'Other Usage Data')).toMatchObject({ value: true, certain: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Play "Does your app use advertising ID?" from real signals', () => {
|
||||
const c = { app: { name: 'Example Farm', profile: 'game' }, play: { packageName: 'org.example.farmgame' }, languages: [],
|
||||
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { shared: false, collect: ['functionality'] } } } } as unknown as ShipConfig;
|
||||
test('a Device ID in Data safety alone is No (certain) after a scan', () => {
|
||||
expect(advertisingIdUse(c, none)).toMatchObject({ v: false });
|
||||
const p = consolePlan('play-advertising-id', c, { signals: none });
|
||||
expect(p.answers.find(a => a.id === 'uses')).toMatchObject({ value: false, status: 'certain', label: 'No' });
|
||||
expect(p.answers.filter(a => a.id.startsWith('purpose'))).toEqual([]);
|
||||
});
|
||||
test('ad / attribution SDKs, the AD_ID permission, app-ads.txt or the flag say Yes; disagreeing signals ask', () => {
|
||||
expect(advertisingIdUse(c, { ...none, adSdks: ['Google AdMob'] }).v).toBe(true);
|
||||
expect(advertisingIdUse(c, { ...none, attributionSdks: ['Adjust'] }).v).toBe(true);
|
||||
expect(advertisingIdUse(c, { ...none, adIdPermission: 'android/app/src/main/AndroidManifest.xml:3' }).v).toBe(true);
|
||||
expect(advertisingIdUse({ ...c, console: { appContent: { advertisingId: true } } } as ShipConfig, none).v).toBe(true);
|
||||
expect(advertisingIdUse({ ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, none).v).toBe(false);
|
||||
const clash = advertisingIdUse({ ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, { ...none, adSdks: ['Google AdMob'] });
|
||||
expect(clash.v).toBeNull();
|
||||
expect(clash.note).toContain('disagree');
|
||||
expect(advertisingIdUse(c, { ...none, adSdks: ['Google AdMob'], adIdRemoved: 'android/app/src/main/AndroidManifest.xml:4' }).v).toBeNull();
|
||||
expect(consolePlan('play-advertising-id', { ...c, console: { appContent: { advertisingId: false } } } as ShipConfig, { signals: { ...none, adSdks: ['Google AdMob'] } }).answers.find(a => a.id === 'uses')!.status).toBe('ask-owner');
|
||||
// not scanned: ask, as before
|
||||
expect(advertisingIdUse(c, undefined).v).toBeNull();
|
||||
});
|
||||
test('project signals read the AD_ID permission and its removal from the manifests', () => {
|
||||
expect(projectSignals(detectProject(FX))).toMatchObject({ adSdks: [], adIdPermission: null, adIdRemoved: null, ar: null, support: null });
|
||||
const withPerm = variant('ad-id', { 'android/app/src/main/AndroidManifest.xml': '<?xml version="1.0" encoding="utf-8"?>\n<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n <uses-permission android:name="com.google.android.gms.permission.AD_ID" />\n</manifest>\n' });
|
||||
expect(projectSignals(detectProject(withPerm)).adIdPermission).toBe('android/app/src/main/AndroidManifest.xml:3');
|
||||
const removed = variant('ad-id-removed', { 'android/app/src/main/AndroidManifest.xml': '<?xml version="1.0" encoding="utf-8"?>\n<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">\n <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />\n</manifest>\n' });
|
||||
expect(projectSignals(detectProject(removed)).adIdRemoved).toBe('android/app/src/main/AndroidManifest.xml:3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentials: template placeholders are missing, never credentials', () => {
|
||||
test('the template has only empty values', () => {
|
||||
const t = JSON.parse(readFileSync(join(ROOT, 'templates', 'credentials.example.json'), 'utf8')) as Record<string, unknown>;
|
||||
const strings = (v: unknown): string[] => typeof v === 'string' ? [v] : v && typeof v === 'object' ? Object.values(v).flatMap(strings) : [];
|
||||
expect(strings(t).filter(Boolean)).toEqual([]);
|
||||
});
|
||||
test('placeholder-looking values', () => {
|
||||
for (const v of ['YOUR_KEY_ID', 'your-key', 'sk_your_revenuecat_v2_secret_key', '/absolute/path/to/upload.p12', '/absolute/path/to/AuthKey_<KEY_ID>.p8', '<KEY_ID>', 'xxx', 'XXXX-XXXX',
|
||||
'SEARCHADS.00000000-0000-0000-0000-000000000000', 'example', 'key-example', 'review-contact@example.com'])
|
||||
expect({ v, p: isPlaceholder(v) }).toEqual({ v, p: true });
|
||||
for (const v of ['ABCDE12345', '00000000-0000-0000-0000-000000000000', 'sk_test_placeholder_secret_value', '/Users/me/keys/upload.p12', 'upload', '-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----', '{"client_email":"x@example.com"}', ''])
|
||||
expect({ v, p: isPlaceholder(v) }).toEqual({ v, p: false });
|
||||
expect(isPlaceholder('demo@example.com', { example: false })).toBe(false);
|
||||
});
|
||||
test('the 0.7.1 template in .storebound/credentials.json counts as missing', () => {
|
||||
const d = join(tmp, 'creds');
|
||||
mkdirSync(join(d, '.storebound'), { recursive: true });
|
||||
writeFileSync(join(d, '.storebound', 'credentials.json'), JSON.stringify({
|
||||
PLAY_SERVICE_ACCOUNT: '/absolute/path/to/play-service-account.json', ASC_KEY_ID: 'YOUR_KEY_ID', ASC_P8: '/absolute/path/to/AuthKey_<KEY_ID>.p8',
|
||||
REVENUECAT_SECRET_KEY: 'sk_your_revenuecat_v2_secret_key', ANDROID_KEYSTORE: '/absolute/path/to/upload.p12', ANDROID_KEY_ALIAS: 'upload', ASC_VENDOR_NUMBER: '',
|
||||
}));
|
||||
const keep = Object.fromEntries(['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEY_ALIAS', 'STOREBOUND_PROFILE'].map(k => [k, process.env[k]]));
|
||||
for (const k of Object.keys(keep)) delete process.env[k];
|
||||
try {
|
||||
for (const k of ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_P8', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE']) expect({ k, v: credentialValue(d, k) }).toEqual({ k, v: undefined });
|
||||
expect(credentialValue(d, 'ANDROID_KEY_ALIAS')).toBe('upload');
|
||||
const st = Object.fromEntries(credentialStatus(d).map(s => [s.key, s.status]));
|
||||
expect(st).toMatchObject({ PLAY_SERVICE_ACCOUNT: 'placeholder', ASC_KEY_ID: 'placeholder', REVENUECAT_SECRET_KEY: 'placeholder', ANDROID_KEY_ALIAS: 'set', ASC_VENDOR_NUMBER: 'missing', ASC_ISSUER_ID: 'missing' });
|
||||
} finally { for (const [k, v] of Object.entries(keep)) if (v !== undefined) process.env[k] = v; }
|
||||
});
|
||||
test('check warns about placeholders, the empty manifest list and an unused scan.ignore entry', () => {
|
||||
const d = variant('check', { '.storebound/credentials.json': JSON.stringify({ ASC_KEY_ID: 'YOUR_KEY_ID', REVENUECAT_SECRET_KEY: 'sk_your_revenuecat_v2_secret_key' }) });
|
||||
const env = { ...process.env };
|
||||
for (const k of ['ASC_KEY_ID', 'REVENUECAT_SECRET_KEY', 'STOREBOUND_PROFILE']) delete env[k];
|
||||
const r = Bun.spawnSync(['bun', join(ROOT, 'cli', 'index.ts'), 'check'], { cwd: d, env });
|
||||
const err = r.stderr.toString() + r.stdout.toString();
|
||||
expect(err).toContain('credentials: ASC_KEY_ID, REVENUECAT_SECRET_KEY still hold template placeholders');
|
||||
expect(err).toContain('has an empty NSPrivacyCollectedDataTypes');
|
||||
expect(err).toContain('scan.ignore[0] (rule privacy-labels');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue