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
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue