133 lines
8.4 KiB
TypeScript
133 lines
8.4 KiB
TypeScript
// 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]
|
|
// storebound privacy-manifest --ios ios/App --propose [--tracking-domains a.example.com,b.example.com]
|
|
// 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.
|
|
// ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree in the app's manifest and in every SDK's (true
|
|
// needs a non-empty list, a non-empty list needs true); a mismatch is a blocker.
|
|
// --propose prints the app's PrivacyInfo.xcprivacy as storebound proposes it (declared + found API categories, the
|
|
// collected types from appPrivacy.types of storebound.config.ts with linked, tracking and purposes, else the ones the
|
|
// manifest has): NSPrivacyTracking true only with app-level tracking domains (--tracking-domains, else
|
|
// appPrivacy.trackingDomains of storebound.config.ts when there is one), else false; tracking done by an SDK is
|
|
// declared in the SDK's own manifest.
|
|
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, printJson } from './lib/log';
|
|
import { nativeIosPlugins } from './lib/node-packages';
|
|
import { parsePlist, toXmlPlist, type PlistValue } from './lib/plist';
|
|
import { manifestFromBytes } from './lib/privacy-files';
|
|
import type { AppleDataTypeId, AppleTypeAnswer } from './lib/app-privacy';
|
|
import { collectedFromAppPrivacy, evaluatePrivacyManifest, isBuildCopyPath, type Manifest, parseManifest, proposeAppManifest, 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 manifestsIn(dir: string): Manifest[] {
|
|
const out: Manifest[] = [];
|
|
walk(dir, p => {
|
|
if (basename(p) !== 'PrivacyInfo.xcprivacy') return;
|
|
try { out.push(manifestFromBytes(relative(process.cwd(), p), readFileSync(p))); } catch { /* unreadable */ }
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** The NSPrivacyCollectedDataTypes entries of the app's manifest as they are (kept by --propose). */
|
|
function existingCollected(path: string | undefined): PlistValue[] {
|
|
if (!path) return [];
|
|
try { const v = parsePlist(readFileSync(path)) as Record<string, PlistValue>; return Array.isArray(v.NSPrivacyCollectedDataTypes) ? v.NSPrivacyCollectedDataTypes : []; } catch { return []; }
|
|
}
|
|
|
|
async function configAppPrivacy(): Promise<{ trackingDomains: string[]; types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> | undefined }> {
|
|
try {
|
|
const { loadConfig } = await import('./lib/config');
|
|
const ap = (await loadConfig()).config.appPrivacy;
|
|
return { trackingDomains: ap?.trackingDomains ?? [], types: ap?.types };
|
|
} catch { return { trackingDomains: [], types: undefined }; }
|
|
}
|
|
|
|
export async function run(args: Args): Promise<void> {
|
|
const ios = args.get('ios');
|
|
if (!ios) throw new Error('usage: privacy-manifest --ios <path to the iOS project folder, e.g. ios/App> [--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<string>();
|
|
const seen = new Set<string>();
|
|
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);
|
|
const manifests = manifestsIn(p);
|
|
sdks.push({ name: name ?? basename(p).replace(/\.(xc)?framework$/, ''), path: relative(process.cwd(), p), hasManifest: manifests.length > 0, manifests });
|
|
};
|
|
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') { if (!isBuildCopyPath(relative(root, p))) 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 sourceHits = scanSources(sources);
|
|
if (args.has('propose')) {
|
|
const ap = await configAppPrivacy();
|
|
const domains = args.list('tracking-domains').length ? args.list('tracking-domains') : ap.trackingDomains;
|
|
const existing = existingCollected(manifests[0]);
|
|
const types = ap.types && Object.keys(ap.types).length ? ap.types : undefined;
|
|
console.log(toXmlPlist(proposeAppManifest({ trackingDomains: domains, apis: appManifest?.apis, used: sourceHits.keys(), collected: existing, types }) as PlistValue).trimEnd());
|
|
if (types) {
|
|
const c = collectedFromAppPrivacy(types, existing);
|
|
log('privacy', `NSPrivacyCollectedDataTypes: ${c.entries.length} type(s) from appPrivacy.types (the App Privacy answers)`);
|
|
for (const n of c.notes) log('privacy', n);
|
|
} else if (!existing.length) log('privacy', 'NSPrivacyCollectedDataTypes empty: no appPrivacy.types in storebound.config.ts and none in the manifest (answer appPrivacy.types first: storebound console plan apple-app-privacy)');
|
|
log('privacy', domains.length ? `NSPrivacyTracking true with ${domains.length} app-level tracking domain(s)` : 'NSPrivacyTracking false: no app-level tracking domains (tracking SDKs declare theirs in their own manifests; true with an empty list is ITMS-91064)');
|
|
return;
|
|
}
|
|
const findings = evaluatePrivacyManifest({ appManifest, sourceHits, sdks, asksTracking: /NSUserTrackingUsageDescription/.test(infoPlist) });
|
|
if (args.has('json')) printJson({ appManifest, sdks, findings });
|
|
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;
|
|
}
|