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:
CRTkafa 2026-10-12 10:27:21 +08:00
parent b7bfe89151
commit c8213be2b6
19 changed files with 631 additions and 44 deletions

View file

@ -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' });
}

View file

@ -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)) {