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