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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue