ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree in every privacy manifest (true needs domains, domains need true); check, scan and privacy-manifest fail on either mismatch with the exact ITMS text; App Privacy tracking no longer demands the app manifest's true: SDK manifests that declare tracking (Pods, frameworks, SwiftPM, Carthage, plugins, the built IPA) are info, known tracking SDKs info, else a warning; privacy-manifest --propose writes true only with appPrivacy.trackingDomains; apple upload checks every manifest in the IPA (XML or binary) before any network call, apple build after the export. Pitfall 29
This commit is contained in:
parent
b7bfe89151
commit
c8213be2b6
19 changed files with 631 additions and 44 deletions
|
|
@ -1,16 +1,25 @@
|
|||
// 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 it 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 } from './lib/log';
|
||||
import { nativeIosPlugins } from './lib/node-packages';
|
||||
import { evaluatePrivacyManifest, type Manifest, parseManifest, scanSources, type SdkInfo } from './lib/privacy-manifest';
|
||||
import { parsePlist, toXmlPlist, type PlistValue } from './lib/plist';
|
||||
import { manifestFromBytes } from './lib/privacy-files';
|
||||
import { evaluatePrivacyManifest, 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)$/;
|
||||
|
|
@ -30,10 +39,26 @@ function walk(dir: string, visit: (p: string, isDir: boolean) => boolean | void,
|
|||
}
|
||||
}
|
||||
|
||||
function hasManifest(dir: string): boolean {
|
||||
let found = false;
|
||||
walk(dir, p => { if (basename(p) === 'PrivacyInfo.xcprivacy') found = true; return !found; });
|
||||
return found;
|
||||
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 configTrackingDomains(): Promise<string[]> {
|
||||
try {
|
||||
const { loadConfig } = await import('./lib/config');
|
||||
return (await loadConfig()).config.appPrivacy?.trackingDomains ?? [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function run(args: Args): Promise<void> {
|
||||
|
|
@ -49,7 +74,8 @@ export async function run(args: Args): Promise<void> {
|
|||
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 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)); };
|
||||
|
||||
|
|
@ -82,7 +108,14 @@ export async function run(args: Args): Promise<void> {
|
|||
});
|
||||
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) });
|
||||
const sourceHits = scanSources(sources);
|
||||
if (args.has('propose')) {
|
||||
const domains = args.list('tracking-domains').length ? args.list('tracking-domains') : await configTrackingDomains();
|
||||
console.log(toXmlPlist(proposeAppManifest({ trackingDomains: domains, apis: appManifest?.apis, used: sourceHits.keys(), collected: existingCollected(manifests[0]) }) as PlistValue).trimEnd());
|
||||
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')) 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue