diff --git a/cli/lib/scan/declare.ts b/cli/lib/scan/declare.ts index 323ffbf..c381d65 100644 --- a/cli/lib/scan/declare.ts +++ b/cli/lib/scan/declare.ts @@ -8,7 +8,7 @@ import { ACCOUNT_CREATION, ACCOUNT_SIGN_UP, accountCreationOf, APPLE_PRIVACY, ap 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'; +import { SIGNALS, userFacing } 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 } @@ -83,7 +83,9 @@ export function proposeDeclarations(p: ProjectFacts, c: ShipConfig | null, confi if (accounts) { markAccount('PSL_USER_ACCOUNT', signUp.length ? signUp : [cfgAt(/accounts\s*:/, 'from config (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); + const all = grepOwn(p, SIGNALS.email, 30); + // server / config identities (a Pub/Sub push service account, SMTP, env values) are not collected from users + const em = { built: all.built, hits: userFacing(p.sources, all.hits).slice(0, 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' }); diff --git a/cli/lib/scan/rules.ts b/cli/lib/scan/rules.ts index 9d02a3d..75df08a 100644 --- a/cli/lib/scan/rules.ts +++ b/cli/lib/scan/rules.ts @@ -108,6 +108,18 @@ export function userLine(re: RegExp): RegExp { const c = codeLine(re); return new RegExp(c.source.replace(/^\^/, `^${skip}`), c.flags); } +/** A hit whose own line or the 3 lines above it (doc comment, the enclosing interface / object key) name a server or + * config identity: a game's `email: string | null;` in `interface RtdnAuthConfig` under "Push service account email + * ...". Pure. */ +export const SERVER_IDENTITY = /audience|\boidc\b|pub\/?sub|service[_ -]?account|client_email|\brtdn|\bsmtp|reply[_-]?to|\bsender\b|admin[_a-z]*email|email[_a-z]*from|process\.env|bun\.env|import\.meta\.env|deno\.env|getenv/i; +export function userFacing(sources: { path: string; text: string }[], hits: H[]): H[] { + const text = new Map(sources.map(s => [s.path, s.text.split('\n')])); + return hits.filter(h => { + const lines = text.get(h.file); + if (!lines) return true; + return !SERVER_IDENTITY.test(lines.slice(Math.max(0, h.line - 4), h.line).join('\n')); + }); +} export const SIGNALS = { appleSignIn: /ASAuthorizationAppleIDProvider|SignInWithAppleButton|AppleAuthenticationButton|AppleAuthentication\.signInAsync|appleAuth\.performRequest|SignInWithApple|OAuthProvider\(\s*['"]apple\.com|AppleAuthProvider|signInWithApple|AppleAuthManager/, thirdPartyLogin: /GIDSignIn|GoogleSignin\.signIn|GoogleSignIn\(|GoogleAuthProvider|signInWithGoogle|FacebookAuthProvider|LoginManager\(\)\.logIn|LoginManager\.logInWithPermissions|signInWithFacebook|FacebookLogin|TwitterAuthProvider|GithubAuthProvider|OAuthProvider\(\s*['"](microsoft\.com|yahoo\.com|twitter\.com|github\.com)/, diff --git a/tests/scan.test.ts b/tests/scan.test.ts index 22b3e92..2b81bc7 100644 --- a/tests/scan.test.ts +++ b/tests/scan.test.ts @@ -13,7 +13,7 @@ import { runScan } from '../cli/lib/scan'; import { PLAY_REFS } from '../cli/lib/scan/play-rules'; import { vaguePurpose } from '../cli/lib/scan/permissions'; import { detectProject, isBuilt, parsePlist } from '../cli/lib/scan/project'; -import { SIGNALS } from '../cli/lib/scan/rules'; +import { SIGNALS, userFacing } from '../cli/lib/scan/rules'; import { localSwiftPackages, nativeIosPlugins, workspaceDirs } from '../cli/lib/node-packages'; import type { ScanFinding } from '../cli/lib/scan/rules'; import { matchesSdk, SDKS, versionAtLeast } from '../cli/lib/scan/sdks'; @@ -395,6 +395,10 @@ describe('monorepo: username accounts without email, plugins in apps/native/node ' pubsub: { email: cfg.serviceAccount },', ' smtp: { host, email: env.SMTP_USER },']; for (const s of yes) expect({ s, hit: SIGNALS.email.test(s) }).toEqual({ s, hit: true }); for (const s of no) expect({ s, hit: SIGNALS.email.test(s) }).toEqual({ s, hit: false }); + // multi-line: the identity is named in the doc comment above (a game apps/server/src/iap/rtdn.ts) + const rtdn = 'export interface RtdnAuthConfig {\n /** OIDC audience of the push subscription. */\n audience: string | null;\n /** Push service account email the token must carry. */\n email: string | null;\n}\nconst body = { email: form.email };'; + const src = [{ path: 'rtdn.ts', text: rtdn }]; + expect(userFacing(src, [{ file: 'rtdn.ts', line: 5 }, { file: 'rtdn.ts', line: 7 }]).map(h => h.line)).toEqual([7]); }); test('built output is low trust', () => { expect(isBuilt('apps/native/web/bundle/routes-AbC12345.js')).toBe(true);