// Local scan of an iOS project for Apple's privacy manifest rules (no network, no credentials, no config needed). // storebound privacy-manifest --ios ios/App [--sdk-dirs node_modules/@capacitor,node_modules/some-plugin] [--json] // Reports: the app's PrivacyInfo.xcprivacy, required-reason API categories used in app sources vs declared, tracking // flag vs the ATT usage string, and every SDK (CocoaPods, .xcframework / .framework, SwiftPM checkouts, local SwiftPM // packages, the native plugins of a Capacitor / Cordova / React Native app found through node_modules and workspaces // the way Node resolves them, so a monorepo's apps/native/node_modules works without flags, and --sdk-dirs) // without its own manifest. Heuristic text scan: it can miss uses inside binaries and flag harmless matches. import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, join, relative, resolve } from 'node:path'; import type { Args } from './lib/args'; import { log } from './lib/log'; import { nativeIosPlugins } from './lib/node-packages'; import { evaluatePrivacyManifest, type Manifest, parseManifest, scanSources, type SdkInfo } from './lib/privacy-manifest'; const SKIP = new Set(['.git', 'build', 'DerivedData', 'node_modules', '.build', 'xcuserdata']); const SRC = /\.(swift|m|mm|c|cc|cpp|h|hpp)$/; const POD_META = new Set(['Target Support Files', 'Headers', 'Local Podspecs', 'Pods.xcodeproj', 'Manifest.lock']); function walk(dir: string, visit: (p: string, isDir: boolean) => boolean | void, depth = 0): void { if (depth > 14) return; let names: string[] = []; try { names = readdirSync(dir); } catch { return; } for (const n of names) { if (SKIP.has(n)) continue; const p = join(dir, n); let st; try { st = statSync(p); } catch { continue; } const descend = visit(p, st.isDirectory()); if (st.isDirectory() && descend !== false) walk(p, visit, depth + 1); } } function hasManifest(dir: string): boolean { let found = false; walk(dir, p => { if (basename(p) === 'PrivacyInfo.xcprivacy') found = true; return !found; }); return found; } export async function run(args: Args): Promise { const ios = args.get('ios'); if (!ios) throw new Error('usage: privacy-manifest --ios [--sdk-dirs a,b] [--json]'); const root = resolve(ios); if (!existsSync(root)) throw new Error(`${root} does not exist`); const sdks: SdkInfo[] = []; const sdkRoots = new Set(); const seen = new Set(); const addSdk = (p: string, name?: string) => { let key = p; try { key = realpathSync(p); } catch { /* keep p */ } if (seen.has(key)) return; seen.add(key); sdkRoots.add(p); sdks.push({ name: name ?? basename(p).replace(/\.(xc)?framework$/, ''), path: relative(process.cwd(), p), hasManifest: hasManifest(p) }); }; const addPods = (dir: string) => { for (const n of readdirSync(dir)) if (!POD_META.has(n) && statSync(join(dir, n)).isDirectory()) addSdk(join(dir, n)); }; const pods = join(root, 'Pods'); if (existsSync(pods)) addPods(pods); // Capacitor / Cordova / React Native plugins: node_modules next to the app's package.json and above it, workspace // packages, local SwiftPM packages (CapApp-SPM) for (const pl of nativeIosPlugins(root)) addSdk(pl.dir, pl.name); for (const extra of args.list('sdk-dirs')) { const d = resolve(extra); if (!existsSync(d)) continue; const kids = readdirSync(d).filter(n => statSync(join(d, n)).isDirectory()); if (kids.some(k => k === 'ios' || k === 'src')) addSdk(d); else for (const k of kids) addSdk(join(d, k)); } const manifests: string[] = []; const sources: { path: string; text: string }[] = []; let infoPlist = ''; walk(root, (p, isDir) => { if ([...sdkRoots].some(s => p.startsWith(s + '/') || p === s)) return false; if (isDir) { if (/\.(xc)?framework$/.test(p)) { addSdk(p); return false; } if (basename(p) === 'Pods') { addPods(p); return false; } if (basename(p) === 'checkouts' && p.includes('SourcePackages')) { for (const n of readdirSync(p)) addSdk(join(p, n)); return false; } return true; } const n = basename(p); if (n === 'PrivacyInfo.xcprivacy') manifests.push(p); else if (n === 'Info.plist' && !infoPlist && !/Tests?\//.test(p)) infoPlist = readFileSync(p, 'utf8'); else if (SRC.test(n) && statSync(p).size < 2_000_000) sources.push({ path: relative(process.cwd(), p), text: readFileSync(p, 'utf8') }); }); const appManifest: Manifest | null = manifests[0] ? parseManifest(relative(process.cwd(), manifests[0]), readFileSync(manifests[0], 'utf8')) : null; log('privacy', `${sources.length} source files, ${sdks.length} SDKs, ${manifests.length} app manifest(s)`); const findings = evaluatePrivacyManifest({ appManifest, sourceHits: scanSources(sources), sdks, asksTracking: /NSUserTrackingUsageDescription/.test(infoPlist) }); if (args.has('json')) console.log(JSON.stringify({ appManifest, sdks, findings }, null, 2)); else for (const f of findings) console.log(`${f.level.toUpperCase().padEnd(7)} ${f.msg}`); if (findings.some(f => f.level === 'blocker')) process.exitCode = 1; }