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
|
|
@ -23,6 +23,7 @@ import { nextBuildNumber } from '../lib/release';
|
|||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
import { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { ascClient } from './asc';
|
||||
import { findBundleId } from './signing';
|
||||
|
||||
|
|
@ -155,6 +156,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const info = ipaInfo(join(exportPath, ipa));
|
||||
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
|
||||
if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`);
|
||||
// ITMS-91064 (Invalid Binary after the upload): NSPrivacyTracking / NSPrivacyTrackingDomains of every manifest in the IPA
|
||||
const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa)));
|
||||
if (privacy.length) throw new Error(`${relative(root, join(exportPath, ipa))} was built, but Apple would refuse it at upload:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest and build again.`);
|
||||
const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle };
|
||||
if (json) printJson(result);
|
||||
done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
// storebound apple upload [<app.ipa>] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
|
||||
// Default IPA: the newest in <apple.build.out or build/ios>/export. The bundle id, version and build number come from
|
||||
// the IPA's Info.plist; the upload is refused when that build number is already on App Store Connect for the app
|
||||
// (`apple next-build` / `apple build` pick a free one).
|
||||
// (`apple next-build` / `apple build` pick a free one). Before any network call every PrivacyInfo.xcprivacy in the IPA
|
||||
// (app root, Frameworks/*.framework, bundles; XML or binary) is checked: NSPrivacyTracking and NSPrivacyTrackingDomains
|
||||
// must agree (ITMS-91064, Invalid Binary a minute after the upload), else nothing is uploaded.
|
||||
// --via api (default): the App Store Connect API build upload (Build Uploads, API 4.1+): POST /v1/buildUploads,
|
||||
// reserve /v1/buildUploadFiles, PUT the parts, commit with the MD5, then poll the upload state. Same API key, no
|
||||
// Xcode needed, works on any OS.
|
||||
|
|
@ -19,6 +21,7 @@ import { confirm, need } from '../lib/ctx';
|
|||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
|
||||
import { altoolArgs, buildUploadReqs, ipaInfo, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { BUILD_WAIT_EXIT } from '../lib/build-ops';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
import { waitForBuild, waitMessage } from './testflight';
|
||||
|
|
@ -207,6 +210,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
} else ipa = resolve(ipa);
|
||||
const info = ipaInfo(ipa);
|
||||
if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`);
|
||||
// every PrivacyInfo.xcprivacy in the app (root, Frameworks, bundles): Apple refuses the build minutes after the
|
||||
// upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call
|
||||
const privacy = bundleTrackingIssues(ipaManifests(ipa));
|
||||
if (privacy.length) throw new Error(`${relative(root, ipa)}: not uploaded, Apple would refuse it as Invalid Binary:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest, rebuild (storebound apple build) and upload the new IPA.`);
|
||||
const size = statSync(ipa).size;
|
||||
const dry = a.has('dry');
|
||||
const json = a.has('json');
|
||||
|
|
|
|||
54
cli/check.ts
54
cli/check.ts
|
|
@ -4,9 +4,11 @@
|
|||
// storebound check everything above
|
||||
// storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors
|
||||
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, relative } from 'node:path';
|
||||
import { appPrivacyMismatches } from './lib/app-privacy';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources } from './lib/app-privacy';
|
||||
import { type BundleManifest, bundleTrackingIssues, ipaManifests, projectSdkManifests, sdkOfProjectPath } from './lib/privacy-files';
|
||||
import { trackingIssue } from './lib/privacy-manifest';
|
||||
import { type AsoFinding, auditAso } from './lib/aso';
|
||||
import { draftLocales } from './lib/aso-intel';
|
||||
import { appleName, playCodes } from './lib/config';
|
||||
|
|
@ -23,6 +25,11 @@ import { allSubscriptions } from './lib/subscriptions';
|
|||
import { type CopyFile, validateCopy } from './shots/lib';
|
||||
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
|
||||
|
||||
/** The newest .ipa in a folder (apple build's export), or null. */
|
||||
function newestIpa(dir: string): string | null {
|
||||
try { return readdirSync(dir).filter(f => f.endsWith('.ipa')).map(f => join(dir, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0] ?? null; } catch { return null; }
|
||||
}
|
||||
|
||||
function aso(ctx: Ctx): void {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const appleMd = config.apple ? readFileSync(resolve(config.apple.listing ?? 'store/appstore.md'), 'utf8') : '';
|
||||
|
|
@ -85,18 +92,39 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (todo.length) note(`${relative(process.cwd(), f) || f}: not translated yet (status=translate): ${todo.join(', ')}`);
|
||||
if (drafts.length) note(`${relative(process.cwd(), f) || f}: machine-translated drafts not reviewed: ${drafts.join(', ')} (storebound listing check, then listing reviewed --lang <ids>)`);
|
||||
}
|
||||
// App Privacy answers (appPrivacy.types) against the iOS project: ATT string, PrivacyInfo.xcprivacy tracking and types
|
||||
// Privacy manifests (ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains agree in every manifest, the app's,
|
||||
// the SDKs' and the last built IPA's) and the App Privacy answers against the iOS project: ATT string, a manifest
|
||||
// that declares tracking (the app's or an SDK's), PrivacyInfo.xcprivacy types
|
||||
const apTypes = config.appPrivacy?.types;
|
||||
if (config.apple && apTypes && Object.keys(apTypes).length) {
|
||||
const facts = detectProject(ctx.loaded.root);
|
||||
if (!facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
|
||||
else {
|
||||
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
|
||||
const found = appPrivacyMismatches(apTypes, { attString: facts.plist.has('NSUserTrackingUsageDescription'), manifest: own });
|
||||
for (const f of found) if (f.level === 'error') problems.push(f.msg); else note(f.msg);
|
||||
items.push({ what: 'app privacy', detail: `${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project` });
|
||||
log('check', `app privacy: ${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project`);
|
||||
const hasTypes = !!apTypes && Object.keys(apTypes).length > 0;
|
||||
const facts = config.apple ? detectProject(ctx.loaded.root) : null;
|
||||
if (hasTypes && facts && !facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
|
||||
if (facts?.ios) {
|
||||
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
|
||||
const ownPaths = new Set(facts.privacyManifests.map(m => m.path));
|
||||
const sdkMs = projectSdkManifests(ctx.loaded.root).filter(s => !ownPaths.has(s.manifest.path));
|
||||
for (const m of facts.privacyManifests) { const i = trackingIssue(m, sdkOfProjectPath(m.path)); if (i) problems.push(i); }
|
||||
for (const s of sdkMs) { const i = trackingIssue(s.manifest, s.sdk); if (i) problems.push(i); }
|
||||
const ipa = newestIpa(resolve(join(config.apple!.build?.out ?? 'build/ios', 'export')));
|
||||
let built: BundleManifest[] = [];
|
||||
if (ipa) {
|
||||
try { built = ipaManifests(ipa); } catch (e) { note(`${relative(process.cwd(), ipa)}: privacy manifests not readable (${(e as Error).message})`); }
|
||||
for (const i of bundleTrackingIssues(built)) problems.push(`${relative(process.cwd(), ipa)}: ${i} Rebuild after the fix (storebound apple build).`);
|
||||
}
|
||||
const tracking: TrackingSources = {
|
||||
sdkManifests: [...sdkMs.filter(s => s.manifest.tracking === true).map(s => s.sdk), ...built.filter(b => b.sdk && b.manifest.tracking === true).map(b => b.sdk!)],
|
||||
trackerSdks: facts.sdks.filter(s => s.sdk.tracking).map(s => s.sdk.name),
|
||||
};
|
||||
const say = (f: PrivacyMismatch) => { if (f.level === 'error') problems.push(f.msg); else if (f.level === 'warn') note(f.msg); else { hints.push(f.msg); log('check', `hint: ${f.msg}`); } };
|
||||
if (hasTypes) {
|
||||
const found = appPrivacyMismatches(apTypes!, { attString: facts.plist.has('NSUserTrackingUsageDescription'), manifest: own, tracking });
|
||||
found.forEach(say);
|
||||
const n = found.filter(f => f.level !== 'info').length;
|
||||
items.push({ what: 'app privacy', detail: `${Object.keys(apTypes!).length} types answered, ${n} mismatch(es) with the iOS project` });
|
||||
log('check', `app privacy: ${Object.keys(apTypes!).length} types answered, ${n} mismatch(es) with the iOS project`);
|
||||
}
|
||||
const typesTrack = hasTypes && Object.values(apTypes!).some(a => a?.tracking === true);
|
||||
if (config.appPrivacy?.tracking === true && !typesTrack && own) trackingDeclaredIssues('appPrivacy.tracking is true', own, tracking).forEach(say);
|
||||
}
|
||||
if (config.dataSafety) tryDo('data safety', () => {
|
||||
const rows = dataSafetyRows(config.dataSafety!, config.accounts);
|
||||
|
|
|
|||
|
|
@ -484,7 +484,9 @@
|
|||
],
|
||||
"privacy-manifest": [
|
||||
"ios",
|
||||
"sdk-dirs"
|
||||
"propose",
|
||||
"sdk-dirs",
|
||||
"tracking-domains"
|
||||
],
|
||||
"release": [
|
||||
"all",
|
||||
|
|
@ -807,6 +809,7 @@
|
|||
"production",
|
||||
"profile",
|
||||
"project",
|
||||
"propose",
|
||||
"prune",
|
||||
"q",
|
||||
"rating",
|
||||
|
|
@ -846,6 +849,7 @@
|
|||
"to",
|
||||
"top",
|
||||
"track",
|
||||
"tracking-domains",
|
||||
"traffic",
|
||||
"translate",
|
||||
"type",
|
||||
|
|
@ -1120,7 +1124,8 @@
|
|||
"storebound prices ppp [--base <USD>] [...] purchasing power price sheet (prices-ppp.ts)"
|
||||
],
|
||||
"privacy-manifest": [
|
||||
"storebound privacy-manifest --ios ios/App [--sdk-dirs node_modules/@capacitor,node_modules/some-plugin] [--json]"
|
||||
"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": [
|
||||
"storebound reports apple-sales [--type SALES] [--frequency DAILY|WEEKLY|MONTHLY|YEARLY] [--date ...] [--vendor N]",
|
||||
|
|
|
|||
|
|
@ -109,21 +109,43 @@ export function validateAppPrivacyTypes(types: unknown): string[] {
|
|||
return e;
|
||||
}
|
||||
|
||||
export interface PrivacyMismatch { level: 'error' | 'warn'; msg: string }
|
||||
export interface PrivacyMismatch { level: 'error' | 'warn' | 'info'; msg: string }
|
||||
|
||||
/** Where tracking is declared besides the app's own manifest. */
|
||||
export interface TrackingSources {
|
||||
/** SDKs whose own PrivacyInfo.xcprivacy declares NSPrivacyTracking true (Pods, frameworks, plugins, built app). */
|
||||
sdkManifests?: string[];
|
||||
/** Tracking-capable SDKs found in the project's dependencies (ads, mediation, attribution) whose manifest was not
|
||||
* found locally (Pods not installed, binary only in the build). */
|
||||
trackerSdks?: string[];
|
||||
}
|
||||
|
||||
/** App Privacy "used for tracking" against the privacy manifests. The app's own manifest does NOT have to say
|
||||
* NSPrivacyTracking true when the tracking comes from an SDK: the SDK's manifest declares it (and true with an empty
|
||||
* NSPrivacyTrackingDomains is ITMS-91064). `what`: the App Privacy answer, e.g. "appPrivacy.tracking is true". */
|
||||
export function trackingDeclaredIssues(what: string, appManifest: { path: string; tracking: boolean | null } | null, src: TrackingSources): PrivacyMismatch[] {
|
||||
if (appManifest?.tracking === true) return [];
|
||||
const own = appManifest ? `${appManifest.path} says NSPrivacyTracking ${appManifest.tracking === false ? 'false' : 'missing'}` : 'no app manifest';
|
||||
const by = [...new Set(src.sdkManifests ?? [])];
|
||||
if (by.length) return [{ level: 'info', msg: `${what}: tracking declared by the ${by.join(', ')} manifest${by.length > 1 ? 's' : ''} (${own}, right when only the SDK tracks)` }];
|
||||
const known = [...new Set(src.trackerSdks ?? [])];
|
||||
if (known.length) return [{ level: 'info', msg: `${what}: tracking comes from ${known.join(', ')}; its own privacy manifest declares it (not found in this folder: install Pods / resolve packages, or \`storebound apple upload --dry\` checks the built app). ${own}, right when only the SDK tracks` }];
|
||||
return [{ level: 'warn', msg: `${what}, but no privacy manifest declares NSPrivacyTracking true (${own}, and no SDK manifest that declares tracking was found): check that the tracking SDK ships a manifest that declares it. Do not set the app's manifest to true without NSPrivacyTrackingDomains (ITMS-91064)` }];
|
||||
}
|
||||
|
||||
/**
|
||||
* `appPrivacy.types` against the iOS project (both ways): any type used for tracking <-> Info.plist
|
||||
* NSUserTrackingUsageDescription and the app manifest's NSPrivacyTracking true; the app manifest's
|
||||
* NSPrivacyCollectedDataTypes <-> the types marked collected. `manifest` null: no PrivacyInfo.xcprivacy found.
|
||||
* NSUserTrackingUsageDescription and a manifest that declares tracking (the app's, or an SDK's: `tracking`); the app
|
||||
* manifest's NSPrivacyCollectedDataTypes <-> the types marked collected. `manifest` null: no PrivacyInfo.xcprivacy found.
|
||||
*/
|
||||
export function appPrivacyMismatches(types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>>, ios: { attString: boolean; manifest: { path: string; tracking: boolean | null; collectedTypes: string[] } | null }): PrivacyMismatch[] {
|
||||
export function appPrivacyMismatches(types: Partial<Record<AppleDataTypeId, AppleTypeAnswer>>, ios: { attString: boolean; manifest: { path: string; tracking: boolean | null; collectedTypes: string[] } | null; tracking?: TrackingSources }): PrivacyMismatch[] {
|
||||
const out: PrivacyMismatch[] = [];
|
||||
const entries = Object.entries(types) as [AppleDataTypeId, AppleTypeAnswer][];
|
||||
const tracked = entries.filter(([, a]) => a.tracking === true).map(([id]) => id);
|
||||
const m = ios.manifest;
|
||||
if (tracked.length) {
|
||||
if (!ios.attString) out.push({ level: 'error', msg: `appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking, but Info.plist has no NSUserTrackingUsageDescription (App Tracking Transparency is required before tracking)` });
|
||||
if (m && m.tracking !== true) out.push({ level: 'error', msg: `appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking, but ${m.path} has NSPrivacyTracking ${m.tracking === false ? 'false' : 'missing'}` });
|
||||
if (m) out.push(...trackingDeclaredIssues(`appPrivacy.types says ${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} used for tracking`, m, ios.tracking ?? {}));
|
||||
} else if (entries.some(([, a]) => a.tracking === false) && entries.every(([, a]) => a.collected === false || a.tracking === false)) {
|
||||
if (ios.attString) out.push({ level: 'warn', msg: 'Info.plist has NSUserTrackingUsageDescription, but appPrivacy.types marks no collected type as used for tracking' });
|
||||
if (m?.tracking === true) out.push({ level: 'warn', msg: `${m.path} has NSPrivacyTracking true, but appPrivacy.types marks no collected type as used for tracking` });
|
||||
|
|
|
|||
|
|
@ -375,10 +375,13 @@ export interface ReviewAccess {
|
|||
}
|
||||
|
||||
/** App Privacy: does the app (or an SDK in it) track users across other companies' apps and websites? true needs
|
||||
* App Tracking Transparency (NSUserTrackingUsageDescription + the prompt) and the privacy manifest's NSPrivacyTracking. */
|
||||
* App Tracking Transparency (NSUserTrackingUsageDescription + the prompt) and a privacy manifest that declares it:
|
||||
* the SDK's own manifest when an SDK tracks (ad network, mediation, attribution), the app's only with trackingDomains. */
|
||||
export interface AppPrivacyConfig {
|
||||
tracking?: boolean;
|
||||
/** Domains that receive tracking data (privacy manifest NSPrivacyTrackingDomains). */
|
||||
/** Domains the app's OWN code sends tracking data to: the app manifest's NSPrivacyTrackingDomains. The app manifest
|
||||
* says NSPrivacyTracking true only with this non-empty list (true with an empty list is ITMS-91064 at upload);
|
||||
* SDK domains stay in each SDK's manifest. Usually unset. */
|
||||
trackingDomains?: string[];
|
||||
/** The owner's App Store App Privacy answers per data type, keyed by Apple's type id (DEVICE_ID, USER_ID,
|
||||
* GAMEPLAY_CONTENT, ADVERTISING_DATA ...; cli/lib/app-privacy.ts). `console fill apple-app-privacy` fills them as
|
||||
|
|
|
|||
115
cli/lib/privacy-files.ts
Normal file
115
cli/lib/privacy-files.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Privacy manifests on disk and inside a built app: the SDK manifests of an iOS project (Pods, .framework /
|
||||
// .xcframework, SwiftPM checkouts, Capacitor / Cordova / React Native plugins in node_modules, a built .app's
|
||||
// Frameworks and bundles) and every PrivacyInfo.xcprivacy inside an .ipa or .app (XML or binary). Read-only; the
|
||||
// directory walk is bounded.
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { basename, join, relative, sep } from 'node:path';
|
||||
import { nativeIosPlugins } from './node-packages';
|
||||
import { parseBinaryPlist } from './plist';
|
||||
import { type Manifest, manifestFromPlist, parseManifest, trackingIssue } from './privacy-manifest';
|
||||
import { zipEntries, zipRead } from './zip';
|
||||
|
||||
/** A manifest read from bytes: binary plists (common in built apps) and XML. */
|
||||
export function manifestFromBytes(path: string, b: Buffer): Manifest {
|
||||
if (b.subarray(0, 8).toString('latin1') === 'bplist00') return manifestFromPlist(path, parseBinaryPlist(b));
|
||||
return parseManifest(path, b.toString('utf8'));
|
||||
}
|
||||
|
||||
export interface BundleManifest {
|
||||
/** Path inside the app bundle ("PrivacyInfo.xcprivacy", "Frameworks/X.framework/PrivacyInfo.xcprivacy"). */
|
||||
inApp: string;
|
||||
/** The SDK the manifest belongs to (framework, bundle, plugin), null for the app's own. */
|
||||
sdk: string | null;
|
||||
manifest: Manifest;
|
||||
}
|
||||
|
||||
/** The SDK name of a path inside an app bundle (null: the app's own manifest at the bundle root). */
|
||||
export function sdkOfBundlePath(inApp: string): string | null {
|
||||
const parts = inApp.split('/');
|
||||
for (let k = parts.length - 2; k >= 0; k--) {
|
||||
const m = /^(.+)\.(framework|bundle|appex|xctest)$/.exec(parts[k]!);
|
||||
if (m) return m[1]!;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every PrivacyInfo.xcprivacy inside an .ipa (Payload/<App>.app/...). */
|
||||
export function ipaManifests(ipa: string | Buffer): BundleManifest[] {
|
||||
const out: BundleManifest[] = [];
|
||||
for (const e of zipEntries(ipa)) {
|
||||
const m = /^Payload\/[^/]+\.app\/(.*PrivacyInfo\.xcprivacy)$/.exec(e.name);
|
||||
if (!m || e.name.endsWith('/')) continue;
|
||||
out.push({ inApp: m[1]!, sdk: sdkOfBundlePath(m[1]!), manifest: manifestFromBytes(e.name, zipRead(ipa, e)) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every PrivacyInfo.xcprivacy inside a built .app folder. */
|
||||
export function appDirManifests(app: string): BundleManifest[] {
|
||||
const out: BundleManifest[] = [];
|
||||
walk(app, (p, isDir) => {
|
||||
if (isDir) return true;
|
||||
if (basename(p) !== 'PrivacyInfo.xcprivacy') return;
|
||||
const inApp = relative(app, p).split(sep).join('/');
|
||||
try { out.push({ inApp, sdk: sdkOfBundlePath(inApp), manifest: manifestFromBytes(p, readFileSync(p)) }); } catch { /* unreadable */ }
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ITMS-91064 problems of a built app's manifests (app root, Frameworks/*.framework, bundles), one message each. */
|
||||
export function bundleTrackingIssues(list: BundleManifest[]): string[] {
|
||||
return list.flatMap(b => { const i = trackingIssue(b.manifest, b.sdk, b.inApp); return i ? [i] : []; });
|
||||
}
|
||||
|
||||
function walk(dir: string, visit: (p: string, isDir: boolean) => boolean | void, depth = 0, budget = { n: 60_000 }): void {
|
||||
if (depth > 16 || budget.n <= 0) return;
|
||||
let names: string[] = [];
|
||||
try { names = readdirSync(dir).sort(); } catch { return; }
|
||||
for (const n of names) {
|
||||
if (--budget.n <= 0) return;
|
||||
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, budget);
|
||||
}
|
||||
}
|
||||
|
||||
const SKIP = new Set(['.git', 'node_modules', '.gradle', '.dart_tool', '.storebound', '.claude', '.cursor', 'android', '.next', 'coverage', 'xcuserdata']);
|
||||
|
||||
/** The SDK a manifest path in a project belongs to, or null when it is the app's own (not under an SDK folder). */
|
||||
export function sdkOfProjectPath(rel: string): string | null {
|
||||
const parts = rel.split('/');
|
||||
for (let k = parts.length - 2; k >= 0; k--) {
|
||||
const p = parts[k]!;
|
||||
const fw = /^(.+)\.(xc)?framework$|^(.+)\.bundle$/.exec(p);
|
||||
if (fw) return fw[1] ?? fw[3]!;
|
||||
if (k > 0 && (parts[k - 1] === 'Pods' || parts[k - 1] === 'checkouts' || parts[k - 1] === 'Carthage')) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface SdkManifest { sdk: string; manifest: Manifest }
|
||||
|
||||
/** SDK manifests of an iOS / cross-platform project: Pods, .framework / .xcframework, SwiftPM checkouts, Carthage,
|
||||
* built .app Frameworks, and the native plugins found through node_modules. Paths relative to `root`. */
|
||||
export function projectSdkManifests(root: string): SdkManifest[] {
|
||||
const out: SdkManifest[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (abs: string, sdk: string) => {
|
||||
if (seen.has(abs)) return;
|
||||
seen.add(abs);
|
||||
try { out.push({ sdk, manifest: manifestFromBytes(relative(root, abs).split(sep).join('/'), readFileSync(abs)) }); } catch { /* unreadable */ }
|
||||
};
|
||||
walk(root, (p, isDir) => {
|
||||
if (isDir) return !SKIP.has(basename(p));
|
||||
if (basename(p) !== 'PrivacyInfo.xcprivacy') return;
|
||||
const sdk = sdkOfProjectPath(relative(root, p).split(sep).join('/'));
|
||||
if (sdk) add(p, sdk);
|
||||
});
|
||||
for (const pl of nativeIosPlugins(root)) walk(pl.dir, (p, isDir) => {
|
||||
if (isDir) return basename(p) !== 'node_modules' && basename(p) !== '.git';
|
||||
if (basename(p) === 'PrivacyInfo.xcprivacy') add(p, pl.name);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
|
@ -46,6 +46,77 @@ export const LISTED_SDKS = ['Abseil', 'AFNetworking', 'Alamofire', 'AppAuth', 'B
|
|||
|
||||
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 (the owner's answers).
|
||||
*/
|
||||
export function proposeAppManifest(o: { trackingDomains?: string[]; apis?: Record<string, string[]>; used?: Iterable<string>; collected?: unknown[] }): 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.collected ?? [],
|
||||
NSPrivacyAccessedAPITypes: Object.entries(apis).sort(([a], [b]) => a.localeCompare(b)).map(([cat, reasons]) => ({ NSPrivacyAccessedAPIType: cat, NSPrivacyAccessedAPITypeReasons: reasons })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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[]> = {};
|
||||
|
|
@ -73,7 +144,7 @@ export function scanSources(files: { path: string; text: string }[]): Map<string
|
|||
return hits;
|
||||
}
|
||||
|
||||
export interface SdkInfo { name: string; path: string; hasManifest: boolean }
|
||||
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 }
|
||||
|
||||
|
|
@ -85,6 +156,9 @@ export interface PmInput {
|
|||
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)' });
|
||||
|
|
@ -103,10 +177,19 @@ export function evaluatePrivacyManifest(i: PmInput): PmFinding[] {
|
|||
}
|
||||
}
|
||||
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' });
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
|
|||
if (attStr) items.push({ area: 'att', what: 'NSUserTrackingUsageDescription', value: String(attStr.value).slice(0, 80), status: 'detected', evidence: [{ ...attStr.at, text: 'NSUserTrackingUsageDescription' }] });
|
||||
else if (trackers.length || attReq.length) items.push({ area: 'att', what: 'NSUserTrackingUsageDescription', value: 'needed if anything tracks, e.g. "Your data is used to show ads that fit your interests."', status: 'ask', evidence: [] });
|
||||
if (attReq.length) items.push({ area: 'att', what: 'ATT request', value: 'found', status: 'detected', evidence: attReq });
|
||||
if (trackers.length || attReq.length) items.push({ area: 'appPrivacy', what: 'trackingDomains', value: 'the SDKs\' tracking domains (from each SDK\'s privacy manifest)', status: 'ask', evidence: [] });
|
||||
if (trackers.length || attReq.length) items.push({ area: 'appPrivacy', what: 'trackingDomains', value: 'only the domains the app\'s OWN code sends tracking data to (usually none: SDK domains stay in each SDK\'s manifest)', status: 'ask', evidence: [] });
|
||||
|
||||
// privacy manifest
|
||||
if (p.ios) {
|
||||
|
|
@ -183,7 +183,14 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi
|
|||
items.push({ area: 'privacyManifest', what: cat, value: have?.length ? `declared: ${have.join(', ')}` : `add with a reason: ${def.note}`, status: have?.length ? 'detected' : 'ask', evidence: h });
|
||||
}
|
||||
const m = p.privacyManifests[0];
|
||||
if (tracking !== null || m) items.push({ area: 'privacyManifest', what: 'NSPrivacyTracking', value: tracking ? 'true' : m ? String(m.tracking) : '?', status: tracking ? 'detected' : 'ask', evidence: m ? [{ file: m.path, line: 1, text: 'PrivacyInfo.xcprivacy' }] : [] });
|
||||
// the app's own manifest says true only with app-level tracking domains: tracking by an SDK is declared in the SDK's
|
||||
// manifest, and true with an empty NSPrivacyTrackingDomains is refused at upload (ITMS-91064)
|
||||
const appDomains = c?.appPrivacy?.trackingDomains ?? [];
|
||||
if (tracking !== null || m) items.push({ area: 'privacyManifest', what: 'NSPrivacyTracking',
|
||||
value: tracking && appDomains.length ? `true (NSPrivacyTrackingDomains: ${appDomains.join(', ')})`
|
||||
: tracking ? 'false in the app\'s manifest (the tracking SDKs declare it in their own manifests; true needs the app\'s own NSPrivacyTrackingDomains, else ITMS-91064)'
|
||||
: m ? String(m.tracking) : '?',
|
||||
status: tracking ? 'detected' : 'ask', evidence: m ? [{ file: m.path, line: 1, text: 'PrivacyInfo.xcprivacy' }] : [] });
|
||||
if (diff.length) items.push({ area: 'privacyManifest', what: 'NSPrivacyCollectedDataTypes', value: uniq(diff.map(d => d.apple.split(' / ')[1]!)).join(', '), status: 'detected', evidence: diff.flatMap(d => d.evidence).slice(0, 1), note: 'the app\'s own collection; SDKs declare theirs in their manifests' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@
|
|||
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
|
||||
import type { ShipConfig } from '../config';
|
||||
import { allSubscriptions } from '../subscriptions';
|
||||
import { LISTED_SDKS, REQUIRED_REASON_APIS } from '../privacy-manifest';
|
||||
import { sdkOfProjectPath } from '../privacy-files';
|
||||
import { itms91064, LISTED_SDKS, REQUIRED_REASON_APIS, trackingIssue, trackingProblem } from '../privacy-manifest';
|
||||
import { IOS_PERMISSIONS, vaguePurpose } from './permissions';
|
||||
import { playRules } from './play-rules';
|
||||
import { grep, grepOwn, type Hit, lineOf, type ProjectFacts } from './project';
|
||||
|
|
@ -304,6 +305,17 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
else if (apiHits.length)
|
||||
add({ rule: 'privacy-manifest', guideline: 'Privacy manifest (ITMS-91053)', store: 'apple', severity: 'blocker', title: `Required-reason APIs used but not declared in PrivacyInfo.xcprivacy: ${apiHits.map(h => h.text.split(':')[0]).join(', ')}`, evidence: apiHits.slice(0, 4),
|
||||
fix: 'Add each category to NSPrivacyAccessedAPITypes with an approved reason code (for app data in UserDefaults: CA92.1).', skill: 'privacy-manifest', ref: 'https://developer.apple.com/documentation/bundleresources/privacy-manifest-files/describing-use-of-required-reason-api' });
|
||||
// ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree (true + empty list was refused at upload too)
|
||||
for (const m of p.privacyManifests) {
|
||||
const problem = trackingProblem(m);
|
||||
if (!problem) continue;
|
||||
const sdk = sdkOfProjectPath(m.path);
|
||||
add({ rule: 'privacy-manifest', guideline: 'Privacy manifest (ITMS-91064)', store: 'apple', severity: 'blocker',
|
||||
title: problem === 'true-without-domains' ? `${m.path}: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (Apple refuses the build: ITMS-91064)` : `${m.path}: NSPrivacyTrackingDomains listed with NSPrivacyTracking ${m.tracking === false ? 'false' : 'missing'} (Apple refuses the build: ITMS-91064)`,
|
||||
evidence: [{ file: m.path, line: 1, text: problem === 'true-without-domains' ? 'NSPrivacyTracking true, NSPrivacyTrackingDomains empty' : `NSPrivacyTrackingDomains: ${m.trackingDomains.join(', ')}` }],
|
||||
fix: `${trackingIssue(m, sdk)!.split(' Fix: ')[1]} Apple: ${itms91064('PrivacyInfo.xcprivacy')}`, skill: 'privacy-manifest',
|
||||
ref: 'https://developer.apple.com/documentation/bundleresources/privacy-manifest-files' });
|
||||
}
|
||||
for (const s of p.sdks) {
|
||||
if (!s.sdk.manifestSince) continue;
|
||||
for (const d of s.deps.filter(x => (x.eco === 'cocoapods' || x.eco === 'spm') && x.version)) {
|
||||
|
|
|
|||
|
|
@ -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