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

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

View file

@ -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);