storebound/cli/lib/app-privacy.ts

169 lines
14 KiB
TypeScript

// Apple App Privacy ("privacy nutrition label") data types and purposes, and the config block `appPrivacy.types` that
// answers them per type. Pure, no imports from the config (config.ts imports this file).
// Sources (checked October 2026):
// https://developer.apple.com/app-store/app-privacy-details/ (data types, purposes and their definitions, tracking,
// linked to the user)
// https://developer.apple.com/documentation/bundleresources/describing-data-use-in-privacy-manifests
// (NSPrivacyCollectedDataTypes: the manifest's data type and purpose keys, one per App Privacy type / purpose)
// Purpose definitions on the App Privacy details page, which the Play purpose mapping (declarations.ts applePurpose)
// follows:
// Third-Party Advertising: displaying third-party ads in your app, or sharing data with entities who display
// third-party ads.
// Developer's Advertising or Marketing: displaying first-party ads in your app, sending marketing communications
// directly to your users, or sharing data with entities who will display your ads.
// Analytics: evaluating user behavior (effectiveness of features, planning features, audience size).
// Product Personalization: customizing what the user sees (recommendations, posts, suggestions).
// App Functionality: authenticating the user, enabling features, preventing fraud, security, server up-time,
// minimizing crashes, scalability and performance, customer support.
// Other Purposes: any other purpose not listed.
/** App Privacy data type id -> [category, the name App Store Connect shows, the privacy manifest key]. */
export const APPLE_PRIVACY_TYPES = {
NAME: ['Contact Info', 'Name', 'NSPrivacyCollectedDataTypeName'],
EMAIL_ADDRESS: ['Contact Info', 'Email Address', 'NSPrivacyCollectedDataTypeEmailAddress'],
PHONE_NUMBER: ['Contact Info', 'Phone Number', 'NSPrivacyCollectedDataTypePhoneNumber'],
PHYSICAL_ADDRESS: ['Contact Info', 'Physical Address', 'NSPrivacyCollectedDataTypePhysicalAddress'],
OTHER_USER_CONTACT_INFO: ['Contact Info', 'Other User Contact Info', 'NSPrivacyCollectedDataTypeOtherUserContactInfo'],
HEALTH: ['Health & Fitness', 'Health', 'NSPrivacyCollectedDataTypeHealth'],
FITNESS: ['Health & Fitness', 'Fitness', 'NSPrivacyCollectedDataTypeFitness'],
PAYMENT_INFO: ['Financial Info', 'Payment Info', 'NSPrivacyCollectedDataTypePaymentInfo'],
CREDIT_INFO: ['Financial Info', 'Credit Info', 'NSPrivacyCollectedDataTypeCreditInfo'],
OTHER_FINANCIAL_INFO: ['Financial Info', 'Other Financial Info', 'NSPrivacyCollectedDataTypeOtherFinancialInfo'],
PRECISE_LOCATION: ['Location', 'Precise Location', 'NSPrivacyCollectedDataTypePreciseLocation'],
COARSE_LOCATION: ['Location', 'Coarse Location', 'NSPrivacyCollectedDataTypeCoarseLocation'],
SENSITIVE_INFO: ['Sensitive Info', 'Sensitive Info', 'NSPrivacyCollectedDataTypeSensitiveInfo'],
CONTACTS: ['Contacts', 'Contacts', 'NSPrivacyCollectedDataTypeContacts'],
EMAILS_OR_TEXT_MESSAGES: ['User Content', 'Emails or Text Messages', 'NSPrivacyCollectedDataTypeEmailsOrTextMessages'],
PHOTOS_OR_VIDEOS: ['User Content', 'Photos or Videos', 'NSPrivacyCollectedDataTypePhotosorVideos'],
AUDIO_DATA: ['User Content', 'Audio Data', 'NSPrivacyCollectedDataTypeAudioData'],
GAMEPLAY_CONTENT: ['User Content', 'Gameplay Content', 'NSPrivacyCollectedDataTypeGameplayContent'],
CUSTOMER_SUPPORT: ['User Content', 'Customer Support', 'NSPrivacyCollectedDataTypeCustomerSupport'],
OTHER_USER_CONTENT: ['User Content', 'Other User Content', 'NSPrivacyCollectedDataTypeOtherUserContent'],
BROWSING_HISTORY: ['Browsing History', 'Browsing History', 'NSPrivacyCollectedDataTypeBrowsingHistory'],
SEARCH_HISTORY: ['Search History', 'Search History', 'NSPrivacyCollectedDataTypeSearchHistory'],
USER_ID: ['Identifiers', 'User ID', 'NSPrivacyCollectedDataTypeUserID'],
DEVICE_ID: ['Identifiers', 'Device ID', 'NSPrivacyCollectedDataTypeDeviceID'],
PURCHASE_HISTORY: ['Purchases', 'Purchase History', 'NSPrivacyCollectedDataTypePurchaseHistory'],
PRODUCT_INTERACTION: ['Usage Data', 'Product Interaction', 'NSPrivacyCollectedDataTypeProductInteraction'],
ADVERTISING_DATA: ['Usage Data', 'Advertising Data', 'NSPrivacyCollectedDataTypeAdvertisingData'],
OTHER_USAGE_DATA: ['Usage Data', 'Other Usage Data', 'NSPrivacyCollectedDataTypeOtherUsageData'],
CRASH_DATA: ['Diagnostics', 'Crash Data', 'NSPrivacyCollectedDataTypeCrashData'],
PERFORMANCE_DATA: ['Diagnostics', 'Performance Data', 'NSPrivacyCollectedDataTypePerformanceData'],
OTHER_DIAGNOSTIC_DATA: ['Diagnostics', 'Other Diagnostic Data', 'NSPrivacyCollectedDataTypeOtherDiagnosticData'],
ENVIRONMENT_SCANNING: ['Surroundings', 'Environment Scanning', 'NSPrivacyCollectedDataTypeEnvironmentScanning'],
HANDS: ['Body', 'Hands', 'NSPrivacyCollectedDataTypeHands'],
HEAD: ['Body', 'Head', 'NSPrivacyCollectedDataTypeHead'],
OTHER_DATA_TYPES: ['Other Data', 'Other Data Types', 'NSPrivacyCollectedDataTypeOtherDataTypes'],
} as const satisfies Record<string, readonly [string, string, string]>;
export type AppleDataTypeId = keyof typeof APPLE_PRIVACY_TYPES;
export const APPLE_DATA_TYPE_IDS = Object.keys(APPLE_PRIVACY_TYPES) as AppleDataTypeId[];
/** App Privacy purpose id -> [the name App Store Connect shows, the privacy manifest key]. */
export const APPLE_PRIVACY_PURPOSES = {
THIRD_PARTY_ADVERTISING: ['Third-Party Advertising', 'NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising'],
DEVELOPERS_ADVERTISING: ['Developer\'s Advertising or Marketing', 'NSPrivacyCollectedDataTypePurposeDeveloperAdvertising'],
ANALYTICS: ['Analytics', 'NSPrivacyCollectedDataTypePurposeAnalytics'],
PRODUCT_PERSONALIZATION: ['Product Personalization', 'NSPrivacyCollectedDataTypePurposeProductPersonalization'],
APP_FUNCTIONALITY: ['App Functionality', 'NSPrivacyCollectedDataTypePurposeAppFunctionality'],
OTHER_PURPOSES: ['Other Purposes', 'NSPrivacyCollectedDataTypePurposeOther'],
} as const satisfies Record<string, readonly [string, string]>;
export type AppleAppPrivacyPurpose = keyof typeof APPLE_PRIVACY_PURPOSES;
export const APPLE_PURPOSE_IDS = Object.keys(APPLE_PRIVACY_PURPOSES) as AppleAppPrivacyPurpose[];
/** The owner's App Privacy answers for one data type (`appPrivacy.types.<ID>`). Unset fields stay questions. */
export interface AppleTypeAnswer {
collected?: boolean;
linked?: boolean;
tracking?: boolean;
/** The full purpose list of this type (replaces what storebound derives from dataSafety). */
purposes?: AppleAppPrivacyPurpose[];
}
export const typeName = (id: AppleDataTypeId): string => APPLE_PRIVACY_TYPES[id][1];
export const purposeName = (id: AppleAppPrivacyPurpose): string => APPLE_PRIVACY_PURPOSES[id][0];
/** The id of an App Store Connect type name ("Device ID" -> DEVICE_ID), or null. */
export const typeIdOf = (name: string): AppleDataTypeId | null => APPLE_DATA_TYPE_IDS.find(id => APPLE_PRIVACY_TYPES[id][1] === name) ?? null;
/** The id of an App Store Connect purpose name, or null. */
export const purposeIdOf = (name: string): AppleAppPrivacyPurpose | null => APPLE_PURPOSE_IDS.find(id => APPLE_PRIVACY_PURPOSES[id][0] === name) ?? null;
/** The App Privacy type of a privacy manifest data type key (NSPrivacyCollectedDataTypeDeviceID -> DEVICE_ID), or null. */
export const typeIdOfManifest = (key: string): AppleDataTypeId | null => APPLE_DATA_TYPE_IDS.find(id => APPLE_PRIVACY_TYPES[id][2] === key) ?? null;
/** Problems of `appPrivacy.types` (ids and purposes against Apple's lists). */
export function validateAppPrivacyTypes(types: unknown): string[] {
if (types === undefined) return [];
if (!types || typeof types !== 'object' || Array.isArray(types)) return ['appPrivacy.types: an object keyed by App Privacy data type id (DEVICE_ID, USER_ID ...)'];
const e: string[] = [];
for (const [id, v] of Object.entries(types as Record<string, unknown>)) {
if (!(id in APPLE_PRIVACY_TYPES)) { e.push(`appPrivacy.types.${id}: not an App Privacy data type id (one of ${APPLE_DATA_TYPE_IDS.join(', ')})`); continue; }
if (!v || typeof v !== 'object' || Array.isArray(v)) { e.push(`appPrivacy.types.${id}: { collected?, linked?, tracking?, purposes? }`); continue; }
const a = v as Record<string, unknown>;
for (const k of Object.keys(a)) if (!['collected', 'linked', 'tracking', 'purposes'].includes(k)) e.push(`appPrivacy.types.${id}.${k}: unknown (collected | linked | tracking | purposes)`);
for (const k of ['collected', 'linked', 'tracking'] as const) if (a[k] !== undefined && typeof a[k] !== 'boolean') e.push(`appPrivacy.types.${id}.${k}: true | false`);
if (a.purposes !== undefined) {
if (!Array.isArray(a.purposes)) e.push(`appPrivacy.types.${id}.purposes: a list of ${APPLE_PURPOSE_IDS.join(' | ')}`);
else for (const p of a.purposes) if (!(APPLE_PURPOSE_IDS as string[]).includes(p as string)) e.push(`appPrivacy.types.${id}.purposes: "${String(p)}" is not one of ${APPLE_PURPOSE_IDS.join(' | ')}`);
}
if (a.collected === false && (a.linked !== undefined || a.tracking !== undefined || (Array.isArray(a.purposes) && a.purposes.length)))
e.push(`appPrivacy.types.${id}: collected is false, so linked / tracking / purposes do not apply`);
}
return e;
}
export interface PrivacyMismatch { level: 'error' | 'warn' | 'info'; msg: string }
/** Where tracking is declared besides the app's own manifest. */
export interface TrackingSources {
/** SDKs whose own PrivacyInfo.xcprivacy declares NSPrivacyTracking true (Pods, frameworks, plugins, built app). */
sdkManifests?: string[];
/** Tracking-capable SDKs found in the project's dependencies (ads, mediation, attribution) whose manifest was not
* found locally (Pods not installed, binary only in the build). */
trackerSdks?: string[];
}
/** App Privacy "used for tracking" against the privacy manifests. The app's own manifest does NOT have to say
* NSPrivacyTracking true when the tracking comes from an SDK: the SDK's manifest declares it (and true with an empty
* NSPrivacyTrackingDomains is ITMS-91064). `what`: the App Privacy answer, e.g. "appPrivacy.tracking is true". */
export function trackingDeclaredIssues(what: string, appManifest: { path: string; tracking: boolean | null } | null, src: TrackingSources): PrivacyMismatch[] {
if (appManifest?.tracking === true) return [];
const own = appManifest ? `${appManifest.path} says NSPrivacyTracking ${appManifest.tracking === false ? 'false' : 'missing'}` : 'no app manifest';
const by = [...new Set(src.sdkManifests ?? [])];
if (by.length) return [{ level: 'info', msg: `${what}: tracking declared by the ${by.join(', ')} manifest${by.length > 1 ? 's' : ''} (${own}, right when only the SDK tracks)` }];
const known = [...new Set(src.trackerSdks ?? [])];
if (known.length) return [{ level: 'info', msg: `${what}: tracking comes from ${known.join(', ')}; its own privacy manifest declares it (not found in this folder: install Pods / resolve packages, or \`storebound apple upload --dry\` checks the built app). ${own}, right when only the SDK tracks` }];
return [{ level: 'warn', msg: `${what}, but no privacy manifest declares NSPrivacyTracking true (${own}, and no SDK manifest that declares tracking was found): check that the tracking SDK ships a manifest that declares it. Do not set the app's manifest to true without NSPrivacyTrackingDomains (ITMS-91064)` }];
}
/**
* `appPrivacy.types` against the iOS project (both ways): any type used for tracking <-> Info.plist
* NSUserTrackingUsageDescription and a manifest that declares tracking (the app's, or an SDK's: `tracking`); the app
* manifest's NSPrivacyCollectedDataTypes <-> the types marked collected. `manifest` null: no PrivacyInfo.xcprivacy found.
*/
export function appPrivacyMismatches(types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>>, ios: { attString: boolean; manifest: { path: string; tracking: boolean | null; collectedTypes: string[] } | null; tracking?: TrackingSources }): PrivacyMismatch[] {
const out: PrivacyMismatch[] = [];
const entries = Object.entries(types) as [AppleDataTypeId, AppleTypeAnswer][];
const tracked = entries.filter(([, a]) => a.tracking === true).map(([id]) => id);
const m = ios.manifest;
if (tracked.length) {
if (!ios.attString) out.push({ level: 'error', msg: `appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking, but Info.plist has no NSUserTrackingUsageDescription (App Tracking Transparency is required before tracking)` });
if (m) out.push(...trackingDeclaredIssues(`appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking`, m, ios.tracking ?? {}));
} else if (entries.some(([, a]) => a.tracking === false) && entries.every(([, a]) => a.collected === false || a.tracking === false)) {
if (ios.attString) out.push({ level: 'warn', msg: 'Info.plist has NSUserTrackingUsageDescription, but appPrivacy.types marks no collected type as used for tracking' });
if (m?.tracking === true) out.push({ level: 'warn', msg: `${m.path} has NSPrivacyTracking true, but appPrivacy.types marks no collected type as used for tracking` });
}
if (!m) return out;
const inManifest = new Set<AppleDataTypeId>();
for (const k of m.collectedTypes) {
const id = typeIdOfManifest(k);
if (!id) { out.push({ level: 'warn', msg: `${m.path}: unknown NSPrivacyCollectedDataType "${k}"` }); continue; }
inManifest.add(id);
const a = types[id];
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}` });
}
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;
}