199 lines
16 KiB
TypeScript
199 lines
16 KiB
TypeScript
// 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<DataSafety> & { data: Record<string, DataUsage> } | 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<Record<AppleDataTypeId, AppleTypeAnswer>> | 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<string, { type: string; note: string }> = {
|
|
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 = <T>(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<string, { collect: Set<PlayPurpose>; share: Set<PlayPurpose>; 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<PlayPurpose>(), share: new Set<PlayPurpose>(), 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<PlayPurpose>(), share: new Set<PlayPurpose>(), 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<string, DataUsage> = {};
|
|
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) });
|
|
items.push({ area: 'dataSafety', what: 'encryptedInTransit', value: c?.dataSafety ? String(c.dataSafety.encryptedInTransit) : '?', status: 'ask', evidence: [], note: 'all traffic over HTTPS / TLS? (ATS and the SDKs\' own endpoints)' });
|
|
if (accounts) items.push({ area: 'dataSafety', what: 'partialDeletion', value: c?.dataSafety?.partialDeletion !== undefined ? String(c.dataSafety.partialDeletion) : '?', status: 'ask', evidence: [],
|
|
note: "can people request that some or all of their data is deleted without deleting the account? true | false | 'auto90' (deleted automatically within 90 days)" });
|
|
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<Record<AppleDataTypeId, AppleTypeAnswer>> = {};
|
|
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<string, string[]>();
|
|
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 };
|
|
}
|