// AdAttributionKit / SKAdNetwork checks of an iOS app's Info.plist and sources, as pure rules. The command // (cli/ads.ts scan) finds the files, parses them (cli/lib/plist.ts) and prints the findings. Text checks only. // Sources (Apple developer documentation, checked October 2026): // https://developer.apple.com/documentation/adattributionkit // Publisher app: "add the ad network IDs" under `AdNetworkIdentifiers` (array of strings); "Lowercase the ad network ID // string; otherwise, the system doesn't recognize it as a valid network." // https://developer.apple.com/documentation/adattributionkit/configuring-a-publisher-app // https://developer.apple.com/documentation/bundleresources/information-property-list/adnetworkidentifiers // SKAdNetwork: `SKAdNetworkItems`, one dictionary with `SKAdNetworkIdentifier` per network; "Ad network IDs are // case-sensitive and are in lowercase." `.adattributionkit` and `.skadnetwork` ids work with both frameworks. // https://developer.apple.com/documentation/bundleresources/information-property-list/skadnetworkitems // https://developer.apple.com/documentation/adattributionkit/adattributionkit-skadnetwork-interoperability // Advertised app: copies of winning postbacks with `AttributionCopyEndpoint` (Xcode shows it as "AdAttributionKit - // Postback Copy URL"; Apple's property list reference lists the key at the top level, Xcode's editor files it under // an `AdAttributionKit` dictionary: both are read here), "a valid URL in the format of https://example.com"; the // system uses the registrable domain and posts to https:///.well-known/appattribution/report-attribution/. // Re-engagement copies: `EligibleForAdAttributionKitReengagementPostbackCopies` (Boolean YES). The app has to call // updateConversionValue at least once to start a postback's conversion window. // https://developer.apple.com/documentation/adattributionkit/configuring-an-advertised-app // https://developer.apple.com/documentation/bundleresources/information-property-list/attributioncopyendpoint // SKAdNetwork postback copies and Private Click Measurement: `NSAdvertisingAttributionReportEndpoint`. // https://developer.apple.com/documentation/bundleresources/information-property-list/nsadvertisingattributionreportendpoint // Attribution rules: `AdAttributionKitConfigurations` > `AttributionWindows` > `global` | > `install` > // `click` (1-30 days) / `view` (1-7 days) / `ignoreInteractionType` (view | click; ad network entries only); // `AttributionCooldown` > `install-cooldown-hours` / `re-engagement-cooldown-hours` (0-720). // https://developer.apple.com/documentation/adattributionkit/configuring-attribution-rules-for-your-app // AdAttributionKit needs no App Tracking Transparency prompt. import type { PlistValue } from './plist'; export type AakLevel = 'blocker' | 'warn' | 'ok' | 'info'; export interface AakFinding { level: AakLevel; code: string; msg: string } type Dict = { [k: string]: PlistValue }; const isDict = (v: PlistValue | undefined): v is Dict => !!v && typeof v === 'object' && !Array.isArray(v) && !Buffer.isBuffer(v); const ID_RE = /^[a-z0-9]+\.(skadnetwork|adattributionkit)$/; export interface SourceSignals { /** `import AdAttributionKit` (or the framework linked). */ aakImport: boolean; /** updateConversionValue / updatePostbackConversionValue calls (app code or an attribution SDK). */ conversionUpdates: boolean; /** Ad SDKs found (Podfile.lock, Package.resolved, frameworks): these show ads, so the app is a publisher. */ adSdks: string[]; /** Mobile measurement SDKs that update conversion values for you (AppsFlyer, Adjust, Branch, Singular, Kochava). */ mmpSdks: string[]; } export const AD_SDKS: [RegExp, string][] = [ [/Google-Mobile-Ads-SDK|GoogleMobileAds|swift-package-manager-google-mobile-ads/i, 'Google Mobile Ads (AdMob)'], [/AppLovinSDK|applovin-ios-sdk/i, 'AppLovin MAX'], [/IronSourceSDK|IronSource|LevelPlay/i, 'Unity LevelPlay / ironSource'], [/UnityAds/i, 'Unity Ads'], [/FBAudienceNetwork/i, 'Meta Audience Network'], [/Vungle|LiftoffMonetize/i, 'Liftoff Monetize'], [/InMobiSDK/i, 'InMobi'], [/MintegralAdSDK/i, 'Mintegral'], [/Pangle|Ads-Global|PAGAdSDK/i, 'Pangle'], [/ChartboostSDK/i, 'Chartboost'], ]; export const MMP_SDKS: [RegExp, string][] = [[/AppsFlyer/i, 'AppsFlyer'], [/\bAdjust(Sdk|Signature)?\b/i, 'Adjust'], [/\bBranch\b|BranchSDK/i, 'Branch'], [/Singular/i, 'Singular'], [/KochavaNetworking|KochavaTracker/i, 'Kochava']]; /** Ad / MMP SDK names and AdAttributionKit signals in dependency lock files and sources. Pure. */ export function sourceSignals(files: { path: string; text: string }[]): SourceSignals { const deps = files.filter(f => /(Podfile\.lock|Package\.resolved|Cartfile\.resolved|project\.pbxproj)$/.test(f.path)).map(f => f.text).join('\n'); const code = files.filter(f => /\.(swift|m|mm)$/.test(f.path)).map(f => f.text).join('\n'); return { aakImport: /^\s*import\s+AdAttributionKit\b|@import\s+AdAttributionKit|AdAttributionKit\.framework/m.test(code + deps), conversionUpdates: /\bupdateConversionValue\s*\(|\bupdatePostbackConversionValue\s*\(/.test(code), adSdks: AD_SDKS.filter(([re]) => re.test(deps)).map(([, n]) => n), mmpSdks: MMP_SDKS.filter(([re]) => re.test(deps)).map(([, n]) => n), }; } function checkUrl(v: PlistValue | undefined, key: string, f: AakFinding[]): string | null { if (v === undefined) return null; if (typeof v !== 'string' || !/^https:\/\/[^\s/]+(\/.*)?$/i.test(v.trim())) { f.push({ level: 'blocker', code: 'endpoint-url', msg: `${key}: "${String(v)}" is not an https URL like https://example.com` }); return null; } const host = new URL(v.trim()).hostname; if (new URL(v.trim()).pathname.replace(/\/$/, '')) f.push({ level: 'warn', code: 'endpoint-path', msg: `${key}: the path is ignored; the system only uses the domain (${host})` }); return host; } /** Registrable-ish domain (last two labels; good enough to compare two endpoints of one app). */ const domainOf = (host: string): string => host.split('.').slice(-2).join('.'); /** Every finding for one Info.plist (+ the source signals of the project). Pure. */ export function evaluateAdAttribution(plist: Dict, s: SourceSignals): AakFinding[] { const f: AakFinding[] = []; // ---- publisher: ad network ids ---- const skItems = plist.SKAdNetworkItems; const skIds: string[] = []; if (skItems !== undefined) { if (!Array.isArray(skItems)) f.push({ level: 'blocker', code: 'skadnetwork-type', msg: 'SKAdNetworkItems must be an array of dictionaries with SKAdNetworkIdentifier' }); else for (const [i, it] of skItems.entries()) { const id = isDict(it) ? it.SKAdNetworkIdentifier : undefined; if (typeof id !== 'string') { f.push({ level: 'blocker', code: 'skadnetwork-entry', msg: `SKAdNetworkItems[${i}]: no SKAdNetworkIdentifier string` }); continue; } skIds.push(id); } } const aak = plist.AdNetworkIdentifiers; const aakIds: string[] = []; if (aak !== undefined) { if (!Array.isArray(aak) || !aak.every(x => typeof x === 'string')) f.push({ level: 'blocker', code: 'aak-ids-type', msg: 'AdNetworkIdentifiers must be an array of strings' }); else aakIds.push(...(aak as string[])); } for (const [list, name] of [[skIds, 'SKAdNetworkItems'], [aakIds, 'AdNetworkIdentifiers']] as const) { const upper = list.filter(id => id !== id.toLowerCase()); if (upper.length) f.push({ level: 'blocker', code: 'id-case', msg: `${name}: ${upper.length} id(s) with capital letters (${upper.slice(0, 3).join(', ')}): the system ignores them; ad network ids are lowercase` }); const odd = list.filter(id => id === id.toLowerCase() && !ID_RE.test(id)); if (odd.length) f.push({ level: 'warn', code: 'id-format', msg: `${name}: ${odd.slice(0, 3).join(', ')} do not look like ad network ids (.skadnetwork / .adattributionkit)` }); const dup = list.filter((id, i) => list.indexOf(id) !== i); if (dup.length) f.push({ level: 'info', code: 'id-duplicate', msg: `${name}: listed twice: ${[...new Set(dup)].slice(0, 3).join(', ')}` }); } if (s.adSdks.length && !skIds.length && !aakIds.length) f.push({ level: 'blocker', code: 'no-network-ids', msg: `ad SDKs (${s.adSdks.join(', ')}) but no SKAdNetworkItems / AdNetworkIdentifiers: installs from those ads cannot be attributed; add each network's ids from its list` }); if (skIds.length && !aakIds.length) f.push({ level: 'warn', code: 'aak-ids-missing', msg: `SKAdNetworkItems has ${skIds.length} id(s) but there is no AdNetworkIdentifiers: impressions that networks sign with AdAttributionKit are only eligible for networks in AdNetworkIdentifiers (Configuring a publisher app); the same ids work for both` }); else if (aakIds.length && skIds.length) { const onlySk = skIds.filter(x => !aakIds.includes(x)); if (onlySk.length) f.push({ level: 'info', code: 'aak-ids-partial', msg: `${onlySk.length} id(s) only in SKAdNetworkItems (${onlySk.slice(0, 3).join(', ')}${onlySk.length > 3 ? ', ...' : ''}): add them to AdNetworkIdentifiers too if those networks use AdAttributionKit` }); } if (skIds.length || aakIds.length) f.push({ level: 'ok', code: 'network-ids', msg: `${new Set([...skIds, ...aakIds]).size} ad network id(s) (SKAdNetworkItems ${skIds.length}, AdNetworkIdentifiers ${aakIds.length})` }); // ---- advertised app: postback copies ---- const group = isDict(plist.AdAttributionKit) ? plist.AdAttributionKit : undefined; const copyRaw = group?.AttributionCopyEndpoint ?? plist.AttributionCopyEndpoint; const reRaw = group?.EligibleForAdAttributionKitReengagementPostbackCopies ?? plist.EligibleForAdAttributionKitReengagementPostbackCopies; const copyHost = checkUrl(copyRaw, 'AttributionCopyEndpoint', f); if (copyHost) f.push({ level: 'ok', code: 'copy-endpoint', msg: `AdAttributionKit postback copies go to https://${domainOf(copyHost)}/.well-known/appattribution/report-attribution/ (needs a valid TLS certificate and to accept HTTPS POST)` }); if (reRaw !== undefined && typeof reRaw !== 'boolean') f.push({ level: 'blocker', code: 'reengagement-type', msg: 'EligibleForAdAttributionKitReengagementPostbackCopies must be a Boolean (YES)' }); if (reRaw === true && !copyHost) f.push({ level: 'warn', code: 'reengagement-without-endpoint', msg: 'EligibleForAdAttributionKitReengagementPostbackCopies is on but there is no AttributionCopyEndpoint: no copy can be sent' }); const skHost = checkUrl(plist.NSAdvertisingAttributionReportEndpoint, 'NSAdvertisingAttributionReportEndpoint', f); if (skHost) f.push({ level: 'ok', code: 'skan-endpoint', msg: `SKAdNetwork postback copies (and Private Click Measurement) go to https://${domainOf(skHost)}/.well-known/...` }); if (copyHost && skHost && domainOf(copyHost) !== domainOf(skHost)) f.push({ level: 'info', code: 'endpoint-domains', msg: `AdAttributionKit copies go to ${domainOf(copyHost)}, SKAdNetwork copies to ${domainOf(skHost)}` }); if (skHost && !copyHost && (s.aakImport || aakIds.length)) f.push({ level: 'info', code: 'copy-endpoint-missing', msg: 'SKAdNetwork postback copies are set up but AdAttributionKit ones are not: add AttributionCopyEndpoint for AdAttributionKit copies' }); if ((copyHost || s.aakImport) && !s.conversionUpdates && !s.mmpSdks.length) f.push({ level: 'warn', code: 'no-conversion-update', msg: 'no updateConversionValue / updatePostbackConversionValue call and no attribution SDK found: the advertised app must update the conversion value at least once (best at first launch) to start the conversion window' }); // ---- attribution rules ---- const conf = plist.AdAttributionKitConfigurations; if (conf !== undefined) { if (!isDict(conf)) f.push({ level: 'blocker', code: 'config-type', msg: 'AdAttributionKitConfigurations must be a dictionary' }); else { const known = new Set([...skIds, ...aakIds]); const windows = conf.AttributionWindows; if (windows !== undefined && !isDict(windows)) f.push({ level: 'blocker', code: 'windows-type', msg: 'AttributionWindows must be a dictionary' }); if (isDict(windows)) for (const [who, cfg] of Object.entries(windows)) { const at = `AttributionWindows.${who}`; if (who !== 'global') { if (who !== who.toLowerCase()) f.push({ level: 'blocker', code: 'windows-id-case', msg: `${at}: ad network ids are lowercase` }); else if (known.size && !known.has(who)) f.push({ level: 'warn', code: 'windows-unknown-network', msg: `${at}: not in AdNetworkIdentifiers / SKAdNetworkItems` }); } const install = isDict(cfg) ? cfg.install : undefined; if (!isDict(install)) { f.push({ level: 'warn', code: 'windows-install', msg: `${at}: expected an "install" dictionary` }); continue; } for (const [k, v] of Object.entries(install)) { if (k === 'click' && !(typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 30)) f.push({ level: 'blocker', code: 'window-click', msg: `${at}.install.click: whole days 1-30` }); else if (k === 'view' && !(typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 7)) f.push({ level: 'blocker', code: 'window-view', msg: `${at}.install.view: whole days 1-7` }); else if (k === 'ignoreInteractionType') { if (v !== 'view' && v !== 'click') f.push({ level: 'blocker', code: 'ignore-type', msg: `${at}.install.ignoreInteractionType: view | click` }); if (who === 'global') f.push({ level: 'warn', code: 'ignore-global', msg: `${at}.install.ignoreInteractionType: only read in ad network entries, not in global` }); } else if (!['click', 'view', 'ignoreInteractionType'].includes(k)) f.push({ level: 'info', code: 'window-key', msg: `${at}.install.${k}: not a documented key` }); } } const cool = conf.AttributionCooldown; if (cool !== undefined) { if (!isDict(cool)) f.push({ level: 'blocker', code: 'cooldown-type', msg: 'AttributionCooldown must be a dictionary' }); else for (const [k, v] of Object.entries(cool)) { if (k !== 'install-cooldown-hours' && k !== 're-engagement-cooldown-hours') f.push({ level: 'info', code: 'cooldown-key', msg: `AttributionCooldown.${k}: not a documented key` }); else if (!(typeof v === 'number' && v >= 0 && v <= 720)) f.push({ level: 'blocker', code: 'cooldown-range', msg: `AttributionCooldown.${k}: hours 0-720` }); } } for (const k of Object.keys(conf)) if (!['AttributionWindows', 'AttributionCooldown'].includes(k)) f.push({ level: 'info', code: 'config-key', msg: `AdAttributionKitConfigurations.${k}: not a documented key` }); } } if (!skIds.length && !aakIds.length && !copyHost && !skHost && conf === undefined && !s.aakImport && !s.adSdks.length) f.push({ level: 'info', code: 'none', msg: 'no ad attribution setup found (fine for an app without ads and without ad campaigns)' }); if (typeof plist.NSUserTrackingUsageDescription === 'string' && !s.adSdks.length && !s.mmpSdks.length) f.push({ level: 'info', code: 'att-only-for-aak', msg: 'NSUserTrackingUsageDescription is set; AdAttributionKit and SKAdNetwork need no ATT prompt (keep it only if you track across apps)' }); return f; }