// Apple privacy manifest (PrivacyInfo.xcprivacy) checks for an iOS project. Pure: callers pass file paths and text. // 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). /** Required-reason API categories and the symbols that typically mean the code uses one. Heuristic, not a compiler. */ export const REQUIRED_REASON_APIS: Record = { NSPrivacyAccessedAPICategoryUserDefaults: { symbols: /\b(NSUserDefaults|UserDefaults)\b/, reasons: ['CA92.1', '1C8F.1', 'C56D.1', 'AC6B.1'], note: 'CA92.1 = read / write data only this app uses (typical for app data, game saves and settings)', }, NSPrivacyAccessedAPICategoryFileTimestamp: { symbols: /\b(creationDate|modificationDate|fileModificationDate|contentModificationDateKey|creationDateKey|NSFileCreationDate|NSFileModificationDate|getattrlist|fstatat|lstat)\b/, reasons: ['DDA9.1', 'C617.1', '3B52.1', '0A2A.1'], note: 'C617.1 = timestamps of files inside the app container', }, NSPrivacyAccessedAPICategorySystemBootTime: { symbols: /\b(systemUptime|mach_absolute_time)\b/, reasons: ['35F9.1', '8FFB.1', '3D61.1'], note: '35F9.1 = measure elapsed time between in-app events', }, NSPrivacyAccessedAPICategoryDiskSpace: { symbols: /\b(NSFileSystemFreeSize|NSFileSystemSize|volumeAvailableCapacityKey|volumeAvailableCapacityForImportantUsageKey|volumeAvailableCapacityForOpportunisticUsageKey|volumeTotalCapacityKey|statfs|statvfs)\b/, reasons: ['85F4.1', 'E174.1', '7D9E.1', 'B728.1'], note: 'E174.1 = check there is room before writing files', }, NSPrivacyAccessedAPICategoryActiveKeyboards: { symbols: /\bactiveInputModes\b/, reasons: ['3EC4.1', '54BD.1'], note: 'rare in apps and games', }, }; /** Part of Apple's "commonly used third-party SDKs" list (must ship a manifest + signature). Check Apple's page for * the current list: developer.apple.com/support/third-party-SDK-requirements */ export const LISTED_SDKS = ['Abseil', 'AFNetworking', 'Alamofire', 'AppAuth', 'BoringSSL', 'Capacitor', 'Charts', 'Cordova', 'DKImagePickerController', 'DKPhotoGallery', 'FBAEMKit', 'FBLPromises', 'FBSDKCoreKit', 'FBSDKCoreKit_Basics', 'FBSDKLoginKit', 'FBSDKShareKit', 'FirebaseABTesting', 'FirebaseAuth', 'FirebaseCore', 'FirebaseCoreDiagnostics', 'FirebaseCoreExtension', 'FirebaseCoreInternal', 'FirebaseCrashlytics', 'FirebaseDynamicLinks', 'FirebaseFirestore', 'FirebaseInstallations', 'FirebaseMessaging', 'FirebaseRemoteConfig', 'Flutter', 'FMDB', 'GoogleDataTransport', 'GoogleSignIn', 'GoogleToolboxForMac', 'GoogleUtilities', 'grpcpp', 'GTMAppAuth', 'GTMSessionFetcher', 'hermes', 'IQKeyboardManager', 'IQKeyboardManagerSwift', 'Kingfisher', 'leveldb', 'Lottie', 'MBProgressHUD', 'nanopb', 'OneSignal', 'OneSignalCore', 'OneSignalExtension', 'OneSignalOutcomes', 'OpenSSL', 'OrderedSet', 'Promises', 'Protobuf', 'Reachability', 'RealmSwift', 'RxCocoa', 'RxRelay', 'RxSwift', 'SDWebImage', 'SnapKit', 'Starscream', 'SVProgressHUD', 'SwiftyGif', 'SwiftyJSON', 'Toast', 'UnityFramework']; export interface Manifest { path: string; tracking: boolean | null; trackingDomains: string[]; apis: Record; collectedTypes: string[] } /** Minimal plist reader for the keys a privacy manifest uses (no plist dependency). */ export function parseManifest(path: string, xml: string): Manifest { const apis: Record = {}; const accessed = /NSPrivacyAccessedAPITypes<\/key>\s*([\s\S]*?)<\/array>\s*(|<\/dict>\s*<\/plist>)/.exec(xml)?.[1] ?? ''; for (const d of accessed.split(//).slice(1)) { const cat = /NSPrivacyAccessedAPIType<\/key>\s*([^<]+)<\/string>/.exec(d)?.[1]; if (!cat) continue; const reasonsBlock = /NSPrivacyAccessedAPITypeReasons<\/key>\s*([\s\S]*?)<\/array>/.exec(d)?.[1] ?? ''; apis[cat.trim()] = [...reasonsBlock.matchAll(/([^<]+)<\/string>/g)].map(m => m[1]!.trim()); } const t = /NSPrivacyTracking<\/key>\s*<(true|false)\s*\/>/.exec(xml)?.[1]; const domainsBlock = /NSPrivacyTrackingDomains<\/key>\s*([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? ''; const collected = [...xml.matchAll(/NSPrivacyCollectedDataType<\/key>\s*([^<]+)<\/string>/g)].map(m => m[1]!.trim()); return { path, tracking: t === undefined ? null : t === 'true', trackingDomains: [...domainsBlock.matchAll(/([^<]+)<\/string>/g)].map(m => m[1]!.trim()), apis, collectedTypes: collected }; } /** Categories whose symbols appear in source text (file path -> categories hit). */ export function scanSources(files: { path: string; text: string }[]): Map> { const hits = new Map>(); for (const f of files) for (const [cat, def] of Object.entries(REQUIRED_REASON_APIS)) { if (!def.symbols.test(f.text)) continue; if (!hits.has(cat)) hits.set(cat, new Set()); hits.get(cat)!.add(f.path); } return hits; } export interface SdkInfo { name: string; path: string; hasManifest: boolean } export type PmLevel = 'blocker' | 'warn' | 'ok' | 'info'; export interface PmFinding { level: PmLevel; msg: string } export interface PmInput { appManifest: Manifest | null; sourceHits: Map>; sdks: SdkInfo[]; /** Info.plist has NSUserTrackingUsageDescription (the app asks for ATT). */ asksTracking: boolean; } export function evaluatePrivacyManifest(i: PmInput): PmFinding[] { const out: PmFinding[] = []; if (!i.appManifest) out.push({ level: 'blocker', msg: 'no PrivacyInfo.xcprivacy in the app target (Xcode > New File > App Privacy; add it to the app target)' }); else out.push({ level: 'ok', msg: `app manifest: ${i.appManifest.path}` }); const declared = i.appManifest?.apis ?? {}; for (const [cat, files] of i.sourceHits) { const def = REQUIRED_REASON_APIS[cat]!; const reasons = declared[cat]; const where = [...files].slice(0, 3).join(', ') + (files.size > 3 ? ` +${files.size - 3}` : ''); if (!reasons) out.push({ level: 'blocker', msg: `${cat} used (${where}) but not declared; ${def.note}` }); else if (!reasons.length) out.push({ level: 'blocker', msg: `${cat} declared without a reason; ${def.note}` }); else { const bad = reasons.filter(r => !def.reasons.includes(r)); out.push(bad.length ? { level: 'warn', msg: `${cat}: unknown reason code(s) ${bad.join(', ')} (known: ${def.reasons.join(', ')})` } : { level: 'ok', msg: `${cat}: declared (${reasons.join(', ')})` }); } } for (const cat of Object.keys(declared)) if (!i.sourceHits.has(cat)) out.push({ level: 'info', msg: `${cat} declared; no use found in the scanned app sources (fine if an SDK or plugin compiled into the app uses it)` }); if (i.asksTracking && i.appManifest && i.appManifest.tracking !== true) out.push({ level: 'warn', msg: 'Info.plist asks for tracking (NSUserTrackingUsageDescription) but the manifest has NSPrivacyTracking false / missing; keep it consistent with App Privacy "used for tracking"' }); if (i.appManifest?.tracking && !i.appManifest.trackingDomains.length) out.push({ level: 'info', msg: 'NSPrivacyTracking true without NSPrivacyTrackingDomains: list the tracking domains if your SDKs connect to them' }); for (const s of i.sdks) { const listed = LISTED_SDKS.includes(s.name); if (s.hasManifest) continue; out.push({ level: listed ? 'blocker' : 'warn', msg: `${s.name} (${s.path}) ships no PrivacyInfo.xcprivacy${listed ? ': it is on Apple\'s list, update to a version with a manifest' : '; ad / analytics SDKs usually have one in newer versions'}` }); } const order: Record = { blocker: 0, warn: 1, info: 2, ok: 3 }; return out.sort((a, b) => order[a.level] - order[b.level]); }