144 lines
12 KiB
TypeScript
144 lines
12 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'; msg: string }
|
|
|
|
/**
|
|
* `appPrivacy.types` against the iOS project (both ways): any type used for tracking <-> Info.plist
|
|
* NSUserTrackingUsageDescription and the app manifest's NSPrivacyTracking true; 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 }): 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 && m.tracking !== true) out.push({ level: 'error', msg: `appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking, but ${m.path} has NSPrivacyTracking ${m.tracking === false ? 'false' : 'missing'}` });
|
|
} 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}` });
|
|
}
|
|
for (const [id, a] of entries) if (a.collected === true && !inManifest.has(id))
|
|
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;
|
|
}
|