storebound/cli/lib/privacy-manifest.ts

243 lines
18 KiB
TypeScript

// 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).
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 }> = {
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<string, string[]>; collectedTypes: string[] }
/** Apple's upload error for a manifest whose NSPrivacyTracking and NSPrivacyTrackingDomains disagree. Apple's wording
* names one direction (domains listed, tracking false), but a real build was refused one minute after submission for
* the other one (NSPrivacyTracking true with an empty NSPrivacyTrackingDomains), so both are invalid. */
export const itms91064 = (path: string): string =>
`ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "${path}". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid.`;
export type TrackingProblem = 'true-without-domains' | 'domains-without-true';
/** Rule: a manifest's NSPrivacyTracking and NSPrivacyTrackingDomains agree. true needs a non-empty list (the domains
* the code of THAT bundle sends tracking data to); a non-empty list needs true. */
export function trackingProblem(m: { tracking: boolean | null; trackingDomains: string[] }): TrackingProblem | null {
if (m.tracking === true && !m.trackingDomains.length) return 'true-without-domains';
if (m.tracking !== true && m.trackingDomains.length) return 'domains-without-true';
return null;
}
/** The ITMS-91064 message for one manifest, or null when it is valid. `sdk`: the SDK (framework, bundle, pod) the
* manifest belongs to; null for the app's own manifest. `bundlePath`: the path inside the built app, for the ITMS text. */
export function trackingIssue(m: { path: string; tracking: boolean | null; trackingDomains: string[] }, sdk: string | null = null, bundlePath = 'PrivacyInfo.xcprivacy'): string | null {
const p = trackingProblem(m);
if (!p) return null;
const what = p === 'true-without-domains'
? 'NSPrivacyTracking true with an empty NSPrivacyTrackingDomains'
: `NSPrivacyTrackingDomains lists ${m.trackingDomains.join(', ')} but NSPrivacyTracking is ${m.tracking === false ? 'false' : 'missing'}`;
const fix = sdk
? `this is ${sdk}'s own manifest: update ${sdk} to a version with a valid manifest (or report it to the vendor); never patch a vendor manifest in place`
: p === 'true-without-domains'
? 'set NSPrivacyTracking false in the app\'s manifest when the tracking comes from an SDK (ad network, mediation, attribution: its own manifest declares it, and App Privacy in App Store Connect still says "used for tracking"); true only with the domains the app\'s own code sends tracking data to (appPrivacy.trackingDomains)'
: 'set NSPrivacyTracking true only if the app\'s own code tracks through these domains, else remove NSPrivacyTrackingDomains';
return `${m.path}: ${what}. Apple refuses the build at upload with ${itms91064(bundlePath)} Fix: ${fix}.`;
}
/** Manifest keys from a parsed plist (a binary manifest in a built app). */
export function manifestFromPlist(path: string, v: unknown): Manifest {
const d = (v && typeof v === 'object' && !Array.isArray(v) ? v : {}) as Record<string, unknown>;
const strs = (x: unknown): string[] => (Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string').map(s => s.trim()) : []);
const apis: Record<string, string[]> = {};
for (const a of Array.isArray(d.NSPrivacyAccessedAPITypes) ? d.NSPrivacyAccessedAPITypes : []) {
const r = a as Record<string, unknown>;
if (typeof r?.NSPrivacyAccessedAPIType === 'string') apis[r.NSPrivacyAccessedAPIType.trim()] = strs(r.NSPrivacyAccessedAPITypeReasons);
}
const collected = (Array.isArray(d.NSPrivacyCollectedDataTypes) ? d.NSPrivacyCollectedDataTypes : [])
.map(x => (x as Record<string, unknown>)?.NSPrivacyCollectedDataType).filter((s): s is string => typeof s === 'string').map(s => s.trim());
return { path, tracking: typeof d.NSPrivacyTracking === 'boolean' ? d.NSPrivacyTracking : null, trackingDomains: strs(d.NSPrivacyTrackingDomains), apis, collectedTypes: collected };
}
/** The typical reason of each required-reason category (first in the note), for a proposed manifest. */
const TYPICAL_REASON: Record<string, string> = {
NSPrivacyAccessedAPICategoryUserDefaults: 'CA92.1', NSPrivacyAccessedAPICategoryFileTimestamp: 'C617.1',
NSPrivacyAccessedAPICategorySystemBootTime: '35F9.1', NSPrivacyAccessedAPICategoryDiskSpace: 'E174.1',
};
/**
* The app's own PrivacyInfo.xcprivacy as storebound proposes it. NSPrivacyTracking is true only with a non-empty list
* 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 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[]; 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.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[]> = {};
const accessed = /<key>NSPrivacyAccessedAPITypes<\/key>\s*<array>([\s\S]*?)<\/array>\s*(<key>|<\/dict>\s*<\/plist>)/.exec(xml)?.[1] ?? '';
for (const d of accessed.split(/<dict>/).slice(1)) {
const cat = /<key>NSPrivacyAccessedAPIType<\/key>\s*<string>([^<]+)<\/string>/.exec(d)?.[1];
if (!cat) continue;
const reasonsBlock = /<key>NSPrivacyAccessedAPITypeReasons<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(d)?.[1] ?? '';
apis[cat.trim()] = [...reasonsBlock.matchAll(/<string>([^<]+)<\/string>/g)].map(m => m[1]!.trim());
}
const t = /<key>NSPrivacyTracking<\/key>\s*<(true|false)\s*\/>/.exec(xml)?.[1];
const domainsBlock = /<key>NSPrivacyTrackingDomains<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? '';
const collected = [...xml.matchAll(/<key>NSPrivacyCollectedDataType<\/key>\s*<string>([^<]+)<\/string>/g)].map(m => m[1]!.trim());
return { path, tracking: t === undefined ? null : t === 'true', trackingDomains: [...domainsBlock.matchAll(/<string>([^<]+)<\/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<string, Set<string>> {
const hits = new Map<string, Set<string>>();
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; /** The SDK's own manifests, parsed. */ manifests?: Manifest[] }
export type PmLevel = 'blocker' | 'warn' | 'ok' | 'info';
export interface PmFinding { level: PmLevel; msg: string }
export interface PmInput {
appManifest: Manifest | null;
sourceHits: Map<string, Set<string>>;
sdks: SdkInfo[];
/** Info.plist has NSUserTrackingUsageDescription (the app asks for ATT). */
asksTracking: boolean;
}
/** SDKs whose own manifest declares NSPrivacyTracking true. */
export const sdkTrackers = (sdks: SdkInfo[]): string[] => [...new Set(sdks.filter(s => s.manifests?.some(m => m.tracking === true)).map(s => s.name))];
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)` });
// ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree, in the app's manifest and in every SDK's
const appIssue = i.appManifest ? trackingIssue(i.appManifest) : null;
if (appIssue) out.push({ level: 'blocker', msg: appIssue });
for (const s of i.sdks) for (const m of s.manifests ?? []) {
const issue = trackingIssue(m, s.name);
if (issue) out.push({ level: 'blocker', msg: issue });
}
if (i.asksTracking && i.appManifest && i.appManifest.tracking !== true) {
const by = sdkTrackers(i.sdks);
out.push(by.length
? { level: 'info', msg: `tracking declared by the ${by.join(', ')} manifest${by.length > 1 ? 's' : ''}; the app's own manifest says NSPrivacyTracking ${i.appManifest.tracking === false ? 'false' : 'missing'}, which is right when only the SDK tracks` }
: { level: 'warn', msg: 'Info.plist asks for tracking (NSUserTrackingUsageDescription), but no privacy manifest found here declares NSPrivacyTracking true: check that the tracking SDK (ad network, mediation, attribution) ships a manifest that declares it (install Pods / resolve packages, or check the built app with `storebound apple upload --dry`). Do not set the app\'s manifest to true without NSPrivacyTrackingDomains (ITMS-91064)' });
}
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<PmLevel, number> = { blocker: 0, warn: 1, info: 2, ok: 3 };
return out.sort((a, b) => order[a.level] - order[b.level]);
}
const COPY_DIRS = new Set(['build', 'Build', 'DerivedData', 'node_modules', 'dist', 'Products', 'Intermediates.noindex', 'ArchiveIntermediates']);
/** A PrivacyInfo.xcprivacy path (relative to the project) that is build output or a copy, not the app's own manifest:
* under a hidden folder (.native-build, .output, .build), build/, DerivedData, node_modules, dist, Xcode Products,
* or inside a built .app / .xcarchive. Seen live: a stale .native-build/apps/native/ios copy was checked instead of
* the real apps/native/ios manifest. */
export function isBuildCopyPath(rel: string): boolean {
const dirs = rel.split(/[\\/]/).slice(0, -1);
return dirs.some(d => (d.startsWith('.') && d !== '.' && d !== '..') || COPY_DIRS.has(d) || /\.(app|xcarchive)$/.test(d));
}