// Declarations proposal for `storebound scan`: from the detected SDKs, permissions and code, the Play Data safety and // App Store App Privacy answers, App Tracking Transparency and the privacy manifest entries the project needs, each // marked "detected (file:line)" or "ask owner", and a diff against the config's dataSafety / appPrivacy blocks. // Only detected items are ever written (`scan --write`); "ask owner" items stay questions (AGENTS.md rule 5). Pure. import type { AppPrivacyConfig, DataSafety, DataUsage, PlayPurpose, ShipConfig } from '../config'; import { type AppleAppPrivacyPurpose, type AppleDataTypeId, type AppleTypeAnswer, purposeIdOf, typeIdOf } from '../app-privacy'; import { ACCOUNT_CREATION, ACCOUNT_SIGN_UP, accountCreationOf, APPLE_PRIVACY, applePurpose } from '../declarations'; import { REQUIRED_REASON_APIS } from '../privacy-manifest'; import { IOS_PERMISSIONS } from './permissions'; import { grep, grepOwn, type Hit, lineOf, type ProjectFacts } from './project'; import { SIGNALS } from './rules'; export type DeclArea = 'dataSafety' | 'appPrivacy' | 'att' | 'privacyManifest'; export interface DeclItem { area: DeclArea; what: string; value: string; status: 'detected' | 'ask'; evidence: Hit[]; note?: string } export interface DataDiff { type: string; /** App Privacy category / type it maps to. */ apple: string; change: 'add' | 'merge' | 'same' | 'kept'; before: DataUsage | null; after: DataUsage; evidence: Hit[]; } export interface Proposal { items: DeclItem[]; diff: DataDiff[]; /** The dataSafety block --write would write (null when nothing was detected and the config has none). */ dataSafety: Partial & { data: Record } | null; appPrivacy: AppPrivacyConfig | null; appPrivacyChange: { from: boolean | null; to: boolean } | null; /** appPrivacy.types entries --write would add (types the config does not answer yet): collected + purposes from the * detected SDKs / code only. linked / tracking are never proposed: the SDK disclosures do not prove them. */ appPrivacyTypes: Partial> | null; /** dataSafety.accountCreation --write would set (from accounts.signUp, while the config says PSL_ACM_NONE or nothing). */ accountCreationChange: { from: string[] | null; to: string[] } | null; /** Types an SDK collects that the config's dataSafety does not declare (the privacy-labels rule). */ labelGaps: { type: string; apple: string; evidence: Hit[] }[]; /** --write would change the config. */ changed: boolean; } const PERMISSION_DATA: Record = { NSCameraUsageDescription: { type: 'PSL_PHOTOS', note: 'only if photos / videos leave the device (uploaded, sent to an API)' }, NSMicrophoneUsageDescription: { type: 'PSL_AUDIO', note: 'only if recordings leave the device' }, NSPhotoLibraryUsageDescription: { type: 'PSL_PHOTOS', note: 'only if picked photos leave the device' }, NSLocationWhenInUseUsageDescription: { type: 'PSL_APPROX_LOCATION', note: 'approximate or precise (PSL_PRECISE_LOCATION), only if it leaves the device' }, NSContactsUsageDescription: { type: 'PSL_CONTACTS', note: 'only if contacts leave the device' }, NSHealthShareUsageDescription: { type: 'PSL_HEALTH', note: 'only if health data leaves the device' }, NSCalendarsFullAccessUsageDescription: { type: 'PSL_CALENDAR', note: 'only if events leave the device' }, }; const uniq = (a: T[]): T[] => [...new Set(a)]; const sorted = (a: readonly string[] | undefined) => [...(a ?? [])].sort().join(); const usageEq = (a: DataUsage, b: DataUsage) => a.shared === b.shared && sorted(a.collect) === sorted(b.collect) && sorted(a.share) === sorted(b.share); export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, configFile?: { path: string; text: string } | null): Proposal { const items: DeclItem[] = []; const detected = new Map; share: Set; evidence: Hit[]; from: string[] }>(); for (const s of p.sdks) { const d0 = s.deps[0]!; const ev: Hit = { file: d0.file, line: d0.line, text: `${s.sdk.name}: ${d0.name}${d0.version ? ` ${d0.version}` : ''}` }; for (const d of s.sdk.data) { const e = detected.get(d.type) ?? { collect: new Set(), share: new Set(), evidence: [], from: [] }; for (const x of d.collect) e.collect.add(x); for (const x of d.share ?? []) e.share.add(x); if (!e.from.includes(s.sdk.name)) { e.from.push(s.sdk.name); e.evidence.push(ev); } detected.set(d.type, e); } } // accounts proven by the config or sign-up code: a user id. Email (or phone) only with real evidence: an email field, // property, validation, endpoint or sign-in call in the own sources, or accounts.signUp saying so. Built output // (bundles, the Capacitor webDir) is low trust: alone it only makes a question. const su = grepOwn(p, SIGNALS.signUp, 1); const signUp = su.built ? [] : su.hits; const cfgAt = (re: RegExp, text: string): Hit => ({ file: configFile?.path ?? 'storebound.config.ts', line: configFile ? lineOf(configFile.text, re) : 1, text }); const accounts = !!c?.accounts?.enabled || signUp.length > 0; const signUpCfg = c?.accounts?.signUp ?? []; const markAccount = (t: string, ev: Hit[], from: string) => { const e = detected.get(t) ?? { collect: new Set(), share: new Set(), evidence: [], from: [] }; e.collect.add('account'); e.collect.add('functionality'); e.evidence.push(...ev); if (!e.from.includes(from)) e.from.push(from); detected.set(t, e); }; if (accounts) { markAccount('PSL_USER_ACCOUNT', signUp.length ? signUp : [cfgAt(/accounts\s*:/, 'accounts.enabled')], 'accounts'); for (const s of signUpCfg) for (const t of ACCOUNT_SIGN_UP[s]?.data ?? []) if (t !== 'PSL_USER_ACCOUNT') markAccount(t, [cfgAt(/signUp\s*:/, `accounts.signUp: ${s}`)], `accounts.signUp ${s}`); const em = grepOwn(p, SIGNALS.email, 2); if (em.hits.length && !em.built) markAccount('PSL_EMAIL', em.hits, 'email in the sources'); else if (!detected.has('PSL_EMAIL')) { if (em.hits.length) items.push({ area: 'dataSafety', what: 'PSL_EMAIL', value: 'collected? an email field shows up only in built output', status: 'ask', evidence: em.hits.slice(0, 1), note: 'check the source; not written' }); else if (!signUpCfg.length) items.push({ area: 'dataSafety', what: 'PSL_EMAIL', value: 'collected? only if sign-up, sign-in or support asks for an email address (none found in the sources)', status: 'ask', evidence: [], note: 'set accounts.signUp to say how people sign up; not written' }); } } const have = c?.dataSafety?.data ?? {}; const diff: DataDiff[] = []; const out: Record = {}; for (const [type, e] of detected) { const share = [...e.share].sort(); const det: DataUsage = { shared: share.length > 0, collect: [...e.collect].sort(), ...(share.length ? { share } : {}) }; const before = have[type] ?? null; const after: DataUsage = before ? { ...before, shared: before.shared || det.shared, collect: uniq([...before.collect, ...det.collect]).sort(), ...((before.share?.length || share.length) ? { share: uniq([...(before.share ?? []), ...share]).sort() } : {}), } : det; out[type] = after; const [cat, name] = APPLE_PRIVACY[type] ?? ['Other Data', 'Other Data Types']; diff.push({ type, apple: `${cat} / ${name}`, change: !before ? 'add' : usageEq(before, after) ? 'same' : 'merge', before, after, evidence: e.evidence.slice(0, 3) }); items.push({ area: 'dataSafety', what: type, value: `collect: ${after.collect.join(', ')}${after.shared ? `; shared: ${(after.share ?? []).join(', ')}` : ''}`, status: 'detected', evidence: e.evidence.slice(0, 2), note: `from ${e.from.join(', ')}; ephemeral / optional: ask owner` }); } for (const [type, u] of Object.entries(have)) if (!detected.has(type)) { out[type] = u; const [cat, name] = APPLE_PRIVACY[type] ?? ['Other Data', 'Other Data Types']; diff.push({ type, apple: `${cat} / ${name}`, change: 'kept', before: u, after: u, evidence: [] }); } // permissions that may mean collected data: questions, never written for (const perm of IOS_PERMISSIONS) for (const k of perm.keys) { const e = p.plist.get(k), pd = PERMISSION_DATA[k]; if (!e || !pd || out[pd.type]) continue; items.push({ area: 'dataSafety', what: pd.type, value: `collected? ${pd.note}`, status: 'ask', evidence: [{ ...e.at, text: k }] }); } const labelGaps = c?.dataSafety ? diff.filter(d => d.change === 'add').map(d => ({ type: d.type, apple: d.apple, evidence: d.evidence })) : []; // top-level Data safety answers const anyData = Object.keys(out).length > 0; if (anyData) items.push({ area: 'dataSafety', what: 'collectsData', value: 'true', status: 'detected', evidence: diff.flatMap(d => d.evidence).slice(0, 1) }); for (const k of ['encryptedInTransit', 'deletionRequest'] as const) items.push({ area: 'dataSafety', what: k, value: c?.dataSafety ? String(c.dataSafety[k]) : '?', status: 'ask', evidence: [], note: k === 'encryptedInTransit' ? 'all traffic over HTTPS / TLS? (ATS and the SDKs\' own endpoints)' : 'can users ask for their data to be deleted?' }); const acmFromCfg = accountCreationOf(signUpCfg); const acmNone = (c?.dataSafety?.accountCreation ?? ['PSL_ACM_NONE']).includes('PSL_ACM_NONE'); let accountCreation: string[] | null = null; if (accounts && acmNone) { if (acmFromCfg) { accountCreation = acmFromCfg; items.push({ area: 'dataSafety', what: 'accountCreation', value: `${JSON.stringify(acmFromCfg)} (${acmFromCfg.map(x => ACCOUNT_CREATION[x] ?? x).join('; ')})`, status: 'detected', evidence: [cfgAt(/signUp\s*:/, 'accounts.signUp')], note: 'from accounts.signUp' }); } else items.push({ area: 'dataSafety', what: 'accountCreation', value: 'which sign-up methods? set accounts.signUp (\'username\' = username, no password: PSL_ACM_USER_ID_OTHER_AUTH; \'email-password\': PSL_ACM_USER_ID_PASSWORD; \'oauth\': PSL_ACM_OAUTH ...)', status: 'ask', evidence: signUp.slice(0, 1) }); } // App Privacy per type (linked / tracking are owner answers) for (const d of diff) { const shared = d.after.shared ? d.after.share ?? [] : []; // one App Privacy purpose per Play purpose (AdMob: Third-Party Advertising, never also the developer's) const purposes = uniq([...d.after.collect, ...shared].map(x => applePurpose(x))).sort(); items.push({ area: 'appPrivacy', what: d.apple, value: purposes.join(', '), status: d.change === 'kept' ? 'ask' : 'detected', evidence: d.evidence.slice(0, 1), note: 'linked to identity? used for tracking? ask owner' }); } // appPrivacy.types: propose collected + purposes for detected types the config does not answer (Apple apps only) const haveTypes = c?.appPrivacy?.types ?? {}; const typesAdded: Partial> = {}; if (c?.apple) for (const d of diff) { if (d.change === 'kept') continue; const id = typeIdOf(d.apple.split(' / ')[1]!); if (!id || id === 'OTHER_DATA_TYPES' || haveTypes[id]) continue; const shared = d.after.shared ? d.after.share ?? [] : []; const ps = [...d.after.collect, ...shared].map(x => purposeIdOf(applePurpose(x))).filter((x): x is AppleAppPrivacyPurpose => !!x); typesAdded[id] = { collected: true, purposes: uniq([...(typesAdded[id]?.purposes ?? []), ...ps]).sort() }; } for (const [id, a] of Object.entries(typesAdded)) items.push({ area: 'appPrivacy', what: `types.${id}`, value: `collected, purposes: ${a!.purposes!.join(', ')}`, status: 'detected', evidence: [], note: `linked / tracking: ask owner (set appPrivacy.types.${id}.linked / .tracking)` }); const appPrivacyTypes = Object.keys(typesAdded).length ? typesAdded : null; // tracking + ATT const attReq = grep(p.sources, SIGNALS.attRequest, 1); const attStr = p.plist.get('NSUserTrackingUsageDescription'); const trackers = p.sdks.filter(s => s.sdk.tracking); let tracking: boolean | null = null; if (attReq.length) { tracking = true; items.push({ area: 'appPrivacy', what: 'tracking', value: 'true (the app asks for App Tracking Transparency)', status: 'detected', evidence: attReq }); } else if (trackers.length) items.push({ area: 'appPrivacy', what: 'tracking', value: `${trackers.map(s => s.sdk.name).join(', ')} can track: personalized ads / attribution on?`, status: 'ask', evidence: trackers.map(s => ({ file: s.deps[0]!.file, line: s.deps[0]!.line, text: s.sdk.name })).slice(0, 2) }); 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: [] }); // privacy manifest if (p.ios) { const declared = new Map(); for (const m of p.privacyManifests) for (const [k, v] of Object.entries(m.apis)) declared.set(k, v); const nativeSrc = p.sources.filter(s => /\.(swift|m|mm|h)$/.test(s.path)); for (const [cat, def] of Object.entries(REQUIRED_REASON_APIS)) { const h = grep(nativeSrc, def.symbols, 1); if (!h.length) continue; const have = declared.get(cat); 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' }] : [] }); 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' }); } const dsChanged = diff.some(d => d.change === 'add' || d.change === 'merge') || (anyData && c?.dataSafety?.collectsData === false) || !!accountCreation; const dataSafety = anyData || c?.dataSafety ? { ...(c?.dataSafety ?? {}), collectsData: anyData || !!c?.dataSafety?.collectsData, ...(accountCreation ? { accountCreation } : {}), data: out } : null; const from = c?.appPrivacy?.tracking ?? null; const appPrivacyChange = tracking !== null && from !== tracking ? { from, to: tracking } : null; const appPrivacy = appPrivacyChange || appPrivacyTypes ? { ...(c?.appPrivacy ?? {}), ...(appPrivacyChange ? { tracking: appPrivacyChange.to } : {}), ...(appPrivacyTypes ? { types: { ...haveTypes, ...appPrivacyTypes } } : {}), } : c?.appPrivacy ?? null; const accountCreationChange = accountCreation ? { from: c?.dataSafety?.accountCreation ?? null, to: accountCreation } : null; return { items, diff, dataSafety, appPrivacy, appPrivacyChange, appPrivacyTypes, accountCreationChange, labelGaps, changed: dsChanged || !!appPrivacyChange || !!appPrivacyTypes }; }