attackmap-analyzer-swift 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,765 @@
1
+ """The iOS/macOS app attack surface (AttackMap#188), shared by the Swift and
2
+ Objective-C analyzers.
3
+
4
+ Project files (any language):
5
+
6
+ - ``Info.plist`` (XML or binary, read with ``plistlib``, never executed):
7
+ App Transport Security exceptions and ``CFBundleURLTypes`` custom URL
8
+ schemes;
9
+ - ``*.entitlements``: hardened-runtime exceptions, ``get-task-allow``, and
10
+ universal-link domains (``applinks:``);
11
+ - ``*.xcconfig``: hard-coded secrets in build settings;
12
+ - ``Cartfile.resolved``: the Carthage SBOM.
13
+
14
+ Swift sources: URL-scheme / universal-link handlers and XPC listeners (entry
15
+ points), WKWebView script built by interpolating into a JS string literal,
16
+ secrets written to UserDefaults or the pasteboard, XPC listeners that accept
17
+ every peer, and classes / selectors / libraries resolved from strings built at
18
+ the call site.
19
+
20
+ Every issue is a construct that is present (precision over recall). The
21
+ patterns are linear and bounded, and garbage input never raises.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import plistlib
27
+ import posixpath
28
+ import re
29
+ from collections.abc import Iterable
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+ from attackmap.sdk import (
34
+ AppSurfaceIssue,
35
+ DependencyHint,
36
+ EntrypointHint,
37
+ iter_repo_files,
38
+ line_of,
39
+ read_source,
40
+ rel,
41
+ ScanResult,
42
+ SecretHint,
43
+ )
44
+
45
+ from .swiftsrc import clip
46
+
47
+ _PLIST_MAX_BYTES = 1_000_000
48
+ _EVIDENCE_MAX = 200
49
+
50
+ # ---------- Secret-shaped names ----------
51
+
52
+ _CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|[_\-\s.]+")
53
+ # Name endings that hold a secret value (tokens of the split identifier).
54
+ _SECRET_TAILS = (
55
+ ("password",), ("passwd",), ("passcode",), ("secret",), ("client", "secret"),
56
+ ("api", "key"), ("apikey",), ("private", "key"), ("secret", "key"), ("access", "key"),
57
+ ("access", "token"), ("refresh", "token"), ("auth", "token"), ("id", "token"),
58
+ ("session", "token"), ("bearer", "token"), ("oauth", "token"), ("api", "token"),
59
+ ("jwt",), ("credential",), ("credentials",),
60
+ )
61
+ # Leading words that make the name a flag or a label, not the secret itself.
62
+ _NOT_SECRET_HEADS = {"has", "is", "did", "should", "show", "can", "needs", "use", "uses", "enable", "enabled"}
63
+
64
+
65
+ def name_tokens(name: str) -> list[str]:
66
+ return [t.lower() for t in _CAMEL.split(name.strip()) if t]
67
+
68
+
69
+ def is_secret_name(name: str) -> bool:
70
+ """`accessToken`, `user_password`, `API_KEY` -> True; `deviceToken`,
71
+ `passwordKey`, `hasPassword`, `tokenExpiry` -> False."""
72
+ tokens = name_tokens(name)
73
+ if not tokens or len(tokens) > 8 or tokens[0] in _NOT_SECRET_HEADS:
74
+ return False
75
+ return any(tuple(tokens[-len(tail):]) == tail for tail in _SECRET_TAILS)
76
+
77
+
78
+ # ---------- Info.plist ----------
79
+
80
+ _SCHEME_HINT = "url_scheme: "
81
+ _ATS_GLOBAL = "NSAllowsArbitraryLoads"
82
+ _ATS_SCOPED = ("NSAllowsArbitraryLoadsInWebContent", "NSAllowsArbitraryLoadsForMedia", "NSAllowsLocalNetworking")
83
+ _TLS_WEAK = {"TLSv1.0", "TLSv1.1"}
84
+
85
+
86
+ def _key_line(text: str | None, *keys: str) -> int | None:
87
+ """Line of the last of ``keys``, found in order (`<key>A</key>` ... `<key>B</key>`)."""
88
+ if not text:
89
+ return None
90
+ pos = 0
91
+ found = -1
92
+ for key in keys:
93
+ found = text.find(f"<key>{key}</key>", pos)
94
+ if found < 0:
95
+ return None
96
+ pos = found + 1
97
+ return line_of(text, found)
98
+
99
+
100
+ def _string_line(text: str | None, after_key: str, value: str) -> int | None:
101
+ if not text:
102
+ return None
103
+ start = text.find(f"<key>{after_key}</key>")
104
+ if start < 0:
105
+ return None
106
+ found = text.find(f"<string>{value}</string>", start)
107
+ return line_of(text, found) if found >= 0 else None
108
+
109
+
110
+ def _load_plist(path: Path) -> tuple[object | None, str | None]:
111
+ """(parsed plist, XML text or None for binary); (None, None) on any failure."""
112
+ try:
113
+ data = path.read_bytes()
114
+ except OSError:
115
+ return None, None
116
+ if len(data) > _PLIST_MAX_BYTES:
117
+ return None, None
118
+ try:
119
+ parsed = plistlib.loads(data)
120
+ except Exception: # malformed / hostile plist: skip it, never fail the scan
121
+ return None, None
122
+ text = None
123
+ if not data.startswith(b"bplist"):
124
+ text = data.decode("utf-8", errors="replace")
125
+ return parsed, text
126
+
127
+
128
+ def _is_true(value: object) -> bool:
129
+ return value is True
130
+
131
+
132
+ def analyze_info_plist(rel_path: str, parsed: object, text: str | None, result: ScanResult) -> None:
133
+ """ATS exceptions and custom URL schemes (as entry points)."""
134
+ if not isinstance(parsed, dict):
135
+ return
136
+ ats = parsed.get("NSAppTransportSecurity")
137
+ if isinstance(ats, dict):
138
+ scoped = [k for k in _ATS_SCOPED if k in ats]
139
+ if _is_true(ats.get(_ATS_GLOBAL)):
140
+ if scoped:
141
+ # iOS 10+/macOS 10.12+ ignore NSAllowsArbitraryLoads when a scoped key is present.
142
+ severity, note = "low", f" (ignored on iOS 10+ because {scoped[0]} is set)"
143
+ else:
144
+ severity, note = "medium", ""
145
+ result.app_surface_issues.append(AppSurfaceIssue(
146
+ kind="apple_ats_insecure_loads", file=rel_path,
147
+ line=_key_line(text, "NSAppTransportSecurity", _ATS_GLOBAL), severity=severity,
148
+ evidence_text=f"NSAppTransportSecurity.{_ATS_GLOBAL} = true{note}",
149
+ ))
150
+ for key in ("NSAllowsArbitraryLoadsInWebContent", "NSAllowsArbitraryLoadsForMedia"):
151
+ if _is_true(ats.get(key)):
152
+ result.app_surface_issues.append(AppSurfaceIssue(
153
+ kind="apple_ats_insecure_loads", file=rel_path, severity="low",
154
+ line=_key_line(text, "NSAppTransportSecurity", key),
155
+ evidence_text=f"NSAppTransportSecurity.{key} = true",
156
+ ))
157
+ domains = ats.get("NSExceptionDomains")
158
+ if isinstance(domains, dict):
159
+ for domain, settings in list(domains.items())[:200]:
160
+ if not isinstance(settings, dict):
161
+ continue
162
+ reasons = [
163
+ k for k in ("NSExceptionAllowsInsecureHTTPLoads", "NSTemporaryExceptionAllowsInsecureHTTPLoads",
164
+ "NSThirdPartyExceptionAllowsInsecureHTTPLoads")
165
+ if _is_true(settings.get(k))
166
+ ]
167
+ tls = settings.get("NSExceptionMinimumTLSVersion") or settings.get("NSTemporaryExceptionMinimumTLSVersion")
168
+ if isinstance(tls, str) and tls in _TLS_WEAK:
169
+ reasons.append(f"NSExceptionMinimumTLSVersion = {tls}")
170
+ if not reasons:
171
+ continue
172
+ result.app_surface_issues.append(AppSurfaceIssue(
173
+ kind="apple_ats_insecure_loads", file=rel_path, severity="low",
174
+ line=_key_line(text, "NSExceptionDomains", str(domain)),
175
+ evidence_text=clip(f"NSExceptionDomains.{domain}: {', '.join(reasons)}", _EVIDENCE_MAX),
176
+ ))
177
+ url_types = parsed.get("CFBundleURLTypes")
178
+ if isinstance(url_types, list):
179
+ for entry in url_types[:50]:
180
+ if not isinstance(entry, dict):
181
+ continue
182
+ for scheme in (entry.get("CFBundleURLSchemes") or [])[:20] if isinstance(entry.get("CFBundleURLSchemes"), list) else []:
183
+ if not isinstance(scheme, str) or not scheme.strip() or scheme.lower() in ("http", "https"):
184
+ continue
185
+ line = _string_line(text, "CFBundleURLSchemes", scheme)
186
+ result.entrypoint_hints.append(EntrypointHint(
187
+ hint=f"{_SCHEME_HINT}{clip(scheme, 80)}://", file=rel_path, line=line,
188
+ evidence_text="CFBundleURLTypes.CFBundleURLSchemes", confidence=0.95,
189
+ ))
190
+
191
+
192
+ # ---------- Entitlements ----------
193
+
194
+ _HARDENED_RUNTIME = (
195
+ "com.apple.security.cs.disable-library-validation",
196
+ "com.apple.security.cs.allow-dyld-environment-variables",
197
+ "com.apple.security.cs.allow-unsigned-executable-memory",
198
+ "com.apple.security.cs.disable-executable-page-protection",
199
+ )
200
+ _DEBUG_ENTITLEMENTS = ("get-task-allow", "com.apple.security.get-task-allow")
201
+ _DEBUG_NAME = re.compile(r"(?i)debug|develop|\bdev\b")
202
+
203
+
204
+ def analyze_entitlements(rel_path: str, parsed: object, text: str | None, result: ScanResult) -> None:
205
+ if not isinstance(parsed, dict):
206
+ return
207
+ for key in _HARDENED_RUNTIME:
208
+ if _is_true(parsed.get(key)):
209
+ result.app_surface_issues.append(AppSurfaceIssue(
210
+ kind="apple_hardened_runtime_exception", file=rel_path, line=_key_line(text, key),
211
+ severity="medium", evidence_text=f"{key} = true",
212
+ ))
213
+ # Xcode adds get-task-allow to Debug builds itself; a Debug-only file is expected.
214
+ if not _DEBUG_NAME.search(posixpath.basename(rel_path)):
215
+ for key in _DEBUG_ENTITLEMENTS:
216
+ if _is_true(parsed.get(key)):
217
+ result.app_surface_issues.append(AppSurfaceIssue(
218
+ kind="apple_debuggable_entitlement", file=rel_path, line=_key_line(text, key),
219
+ severity="low", evidence_text=f"{key} = true",
220
+ ))
221
+ domains = parsed.get("com.apple.developer.associated-domains")
222
+ if isinstance(domains, list):
223
+ for domain in domains[:100]:
224
+ if isinstance(domain, str) and domain.startswith("applinks:"):
225
+ result.entrypoint_hints.append(EntrypointHint(
226
+ hint=f"universal_link: {clip(domain, 120)}", file=rel_path,
227
+ line=_string_line(text, "com.apple.developer.associated-domains", domain),
228
+ evidence_text="com.apple.developer.associated-domains", confidence=0.95,
229
+ ))
230
+
231
+
232
+ # ---------- xcconfig ----------
233
+
234
+ _XCCONFIG_SETTING = re.compile(r"^[ \t]*([A-Za-z_][A-Za-z0-9_]{0,120})(?:\[[^\]\n]{0,120}\])?[ \t]*=[ \t]*([^\n]{0,500})$", re.MULTILINE)
235
+ _PLACEHOLDER = re.compile(r"(?i)^(?:your|my|<|\[|\{|x{3,}|\*{3,}|\.{3}|todo|changeme|change_me|placeholder|example|dummy|test|none|null|nil|false|true|yes|no)")
236
+
237
+
238
+ def analyze_xcconfig(rel_path: str, text: str, result: ScanResult) -> None:
239
+ for m in _XCCONFIG_SETTING.finditer(text):
240
+ key, raw = m.group(1), m.group(2)
241
+ value = raw.split("//", 1)[0].strip().strip('"')
242
+ if not is_secret_name(key) or len(value) < 8 or "$(" in value or "${" in value:
243
+ continue
244
+ if _PLACEHOLDER.match(value) or value.isdigit() or " " in value:
245
+ continue
246
+ masked = value[:4] + "…"
247
+ result.secret_hints.append(SecretHint(
248
+ name=key, file=rel_path, line=line_of(text, m.start()), confidence=0.8,
249
+ kind="xcconfig_secret", evidence_text=f"{key} = {masked}",
250
+ ))
251
+
252
+
253
+ # ---------- Cartfile.resolved ----------
254
+
255
+ _CARTFILE_LINE = re.compile(r'^[ \t]*(github|git|binary)[ \t]+"([^"\n]{1,300})"[ \t]+"([^"\n]{1,100})"', re.MULTILINE)
256
+ # Cartfile (not resolved): the version requirement is optional and unquoted.
257
+ _CARTFILE_DEP = re.compile(r'^[ \t]*(github|git|binary)[ \t]+"([^"\n]{1,300})"', re.MULTILINE)
258
+ _SEMVER_V = re.compile(r"^v(?=\d)")
259
+
260
+
261
+ def _carthage_identity(kind: str, origin: str) -> tuple[str, str | None]:
262
+ """(name, repository URL) for one Cartfile origin."""
263
+ if kind == "github":
264
+ if "://" in origin:
265
+ url = origin.rstrip("/")
266
+ name = "/".join(url.split("/")[-2:])
267
+ else:
268
+ name = origin.strip("/")
269
+ url = f"https://github.com/{name}"
270
+ return name.removesuffix(".git"), url.removesuffix(".git")
271
+ if kind == "git":
272
+ tail = origin.rstrip("/").removesuffix(".git").split("/")
273
+ name = "/".join(tail[-2:]) if len(tail) >= 2 else tail[-1]
274
+ return name.split(":")[-1], origin
275
+ return posixpath.basename(origin).removesuffix(".json"), None # binary: not a repository
276
+
277
+
278
+ def analyze_cartfile(rel_path: str, resolved: str, cartfile: str | None, result: ScanResult) -> None:
279
+ direct = {(m.group(1), m.group(2)) for m in _CARTFILE_DEP.finditer(cartfile or "")}
280
+ direct_names = {_carthage_identity(k, o)[0] for k, o in direct}
281
+ for m in _CARTFILE_LINE.finditer(resolved):
282
+ kind, origin, version = m.group(1), m.group(2), m.group(3)
283
+ name, url = _carthage_identity(kind, origin)
284
+ if not name:
285
+ continue
286
+ result.dependencies.append(DependencyHint(
287
+ name=name, version=_SEMVER_V.sub("", version), ecosystem="carthage", file=rel_path,
288
+ line=line_of(resolved, m.start()), resolved=True,
289
+ direct=(name in direct_names) if cartfile is not None else True,
290
+ evidence_text=url, source_analyzer="swift",
291
+ ))
292
+
293
+
294
+ # ---------- Project walk ----------
295
+
296
+ def analyze_project_files(root: Path, result: ScanResult, skip_dirs: Iterable[str], *, is_test) -> None:
297
+ """Info.plist, entitlements, xcconfig and Cartfile.resolved."""
298
+ cartfiles: dict[str, str] = {}
299
+ resolved: list[tuple[str, str]] = []
300
+ for path in iter_repo_files(
301
+ root, suffixes={".plist", ".entitlements", ".xcconfig"}, names={"Cartfile", "Cartfile.resolved"},
302
+ skip_dirs=set(skip_dirs),
303
+ ):
304
+ rel_path = rel(path, root)
305
+ if is_test(rel_path):
306
+ continue
307
+ if path.name in ("Cartfile", "Cartfile.resolved"):
308
+ text = read_source(path)
309
+ if text is None:
310
+ continue
311
+ if path.name == "Cartfile":
312
+ cartfiles[posixpath.dirname(rel_path)] = text
313
+ else:
314
+ resolved.append((rel_path, text))
315
+ elif path.suffix == ".xcconfig":
316
+ text = read_source(path)
317
+ if text is not None:
318
+ analyze_xcconfig(rel_path, text, result)
319
+ elif path.suffix == ".entitlements":
320
+ parsed, text = _load_plist(path)
321
+ analyze_entitlements(rel_path, parsed, text, result)
322
+ elif path.name.endswith("Info.plist"):
323
+ parsed, text = _load_plist(path)
324
+ analyze_info_plist(rel_path, parsed, text, result)
325
+ for rel_path, text in resolved:
326
+ analyze_cartfile(rel_path, text, cartfiles.get(posixpath.dirname(rel_path)), result)
327
+
328
+
329
+ # ---------- Swift sources ----------
330
+
331
+ @dataclass
332
+ class Handler:
333
+ hint: str
334
+ file: str
335
+ line: int
336
+
337
+
338
+ _URL_HANDLERS = re.compile(
339
+ r"\bfunc[ \t]+application[ \t]*\([^)]{0,200}\bopen[ \t]+(?:url|urls)[ \t]*:"
340
+ r"|\bfunc[ \t]+scene[ \t]*\([^)]{0,200}\bopenURLContexts\b"
341
+ r"|\.onOpenURL[ \t]*(?:\(|\{)"
342
+ r"|\bhandleGetURLEvent\b"
343
+ )
344
+ _UNIVERSAL_LINK_HANDLERS = re.compile(
345
+ r"\bfunc[ \t]+(?:application|scene)[ \t]*\([^)]{0,200}\bcontinue[ \t]+userActivity\b"
346
+ r"|\.onContinueUserActivity[ \t]*\([ \t]*NSUserActivityTypeBrowsingWeb"
347
+ )
348
+ _XPC_MACH_LISTENER = re.compile(r"\bNSXPCListener[ \t]*\([ \t]*machServiceName[ \t]*:")
349
+ _XPC_LOCAL_LISTENER = re.compile(r"\bNSXPCListener[ \t]*\.[ \t]*(service|anonymous)[ \t]*\(")
350
+ _XPC_ACCEPT = re.compile(
351
+ r"\bfunc[ \t]+listener[ \t]*\([^)]{0,200}\bshouldAcceptNewConnection[ \t]+(\w{1,60})[ \t]*:[^{]{0,200}\{"
352
+ )
353
+ XPC_MACH_HINT = "xpc_service: mach service (NSXPCListener(machServiceName:))"
354
+ # Every XPC entry point starts with this; all but the mach one are local
355
+ # (a bundled XPC service or an anonymous listener), in Swift and ObjC.
356
+ _XPC_HINT_PREFIX = "xpc_service: "
357
+ _XPC_PEER_CHECK = re.compile(
358
+ r"(?i)auditToken|processIdentifier|effectiveUserIdentifier|effectiveGroupIdentifier|CodeSigningRequirement"
359
+ r"|\bSecCode|\bSecStaticCode|\bSecRequirement|\bSecTask|kSecGuestAttribute"
360
+ r"|(?:valid|verif|authoriz|authentic|trust)\w{0,40}[ \t]*\("
361
+ )
362
+ _RETURN_TRUE = re.compile(r"\breturn[ \t]+true\b")
363
+
364
+ _EVAL_JS = re.compile(r"\.[ \t]*evaluateJavaScript[ \t]*\(")
365
+ _SET_CALL = re.compile(r"\.[ \t]*(?:set|setValue)[ \t]*\(")
366
+ # What sits between `...Defaults` and `.set(`: `.standard`, `(suiteName: "x")?`, ...
367
+ _DEFAULTS_RECEIVER_TAIL = re.compile(r"\w{0,40}(?:\([^()\n]{0,120}\))?[?!]?(?:\.\w{1,40}){0,3}[ \t]*")
368
+ _APP_STORAGE = re.compile(r'@AppStorage[ \t]*\([ \t]*"([^"\n]{1,120})"')
369
+ _PASTEBOARD_SET = re.compile(
370
+ r"\b(?:UIPasteboard|NSPasteboard)[ \t]*\.[ \t]*general[ \t]*\.[ \t]*"
371
+ r"(?:(?:string|url)[ \t]*=[ \t]*([^\n;]{1,200})|set(?:String|Value)[ \t]*\(([^\n]{1,200}))"
372
+ )
373
+ _DYNAMIC = re.compile(r"\b(NSClassFromString|NSSelectorFromString|dlopen)[ \t]*\(")
374
+ _BUILT_STRING = re.compile(r'\\\(|"[ \t]*\+|\+[ \t]*"|String[ \t]*\([ \t]*format[ \t]*:|\.appending\(|stringWithFormat')
375
+ _SIMPLE_EXPR = re.compile(r"[\w.?!]{1,200}")
376
+ _EXPR_SEP = re.compile(r"[.?!]+")
377
+ _SANITIZED = re.compile(r"(?i)saniti|escap|encod|json|quoted|jsString|jsLiteral|jsonString")
378
+
379
+
380
+ def xpc_peer_checked(body: str, connection: str) -> bool:
381
+ """True when the accept body checks the peer: audit token / PID /
382
+ code-signing APIs, a validate/verify/... helper call, or any call the
383
+ connection is passed to (`isTrusted(newConnection)`, `[self check:conn]`)
384
+ rather than called on (`newConnection.resume()`)."""
385
+ if _XPC_PEER_CHECK.search(body):
386
+ return True
387
+ if not connection.isidentifier():
388
+ return False
389
+ at = body.find(connection)
390
+ for _ in range(200):
391
+ if at < 0:
392
+ return False
393
+ end = at + len(connection)
394
+ before = body[max(0, at - 1)]
395
+ after = body[end] if end < len(body) else ""
396
+ if (at == 0 or not (before.isalnum() or before == "_")) and not (after.isalnum() or after == "_"):
397
+ j = at - 1
398
+ while j >= 0 and body[j] in " \t\n":
399
+ j -= 1
400
+ k = end
401
+ while k < len(body) and body[k] in " \t\n":
402
+ k += 1
403
+ passed_in = j >= 0 and body[j] in "(,:" and not (k < len(body) and body[k] == ".")
404
+ if passed_in:
405
+ return True
406
+ at = body.find(connection, end)
407
+ return False
408
+
409
+
410
+ def _xpc_target(path: str) -> str:
411
+ """The target a source belongs to: `Sources/<Target>` for SwiftPM layouts,
412
+ else the file's directory (one Xcode target folder)."""
413
+ parts = path.split("/")
414
+ if "Sources" in parts[:-2]:
415
+ i = parts.index("Sources")
416
+ return "/".join(parts[: i + 2])
417
+ return posixpath.dirname(path)
418
+
419
+
420
+ def finalize_xpc(result: ScanResult) -> None:
421
+ """An XPC listener delegate is high only when a mach service
422
+ (`NSXPCListener(machServiceName:)`) is registered in the same file, or in
423
+ the same target when the delegate's own file doesn't set up a bundled or
424
+ anonymous listener: any local process can look a mach service up. A
425
+ bundled XPC service or an anonymous listener is reachable only by its
426
+ parent app or an endpoint holder, so the issue drops to low."""
427
+ mach_files: set[str] = set()
428
+ local_files: set[str] = set()
429
+ for hint in result.entrypoint_hints:
430
+ if hint.hint == XPC_MACH_HINT:
431
+ mach_files.add(hint.file)
432
+ elif hint.hint.startswith(_XPC_HINT_PREFIX):
433
+ local_files.add(hint.file)
434
+ mach_targets = {_xpc_target(f) for f in mach_files}
435
+ for issue in result.app_surface_issues:
436
+ if issue.kind != "apple_xpc_unvalidated_peer" or issue.severity != "high":
437
+ continue
438
+ if issue.file in mach_files:
439
+ continue
440
+ if issue.file not in local_files and _xpc_target(issue.file) in mach_targets:
441
+ continue
442
+ issue.severity = "low"
443
+ issue.evidence_text = (issue.evidence_text or "") + (
444
+ "; no NSXPCListener(machServiceName:) in this file or target, so likely a bundled XPC service or "
445
+ "anonymous listener reachable only by its parent app"
446
+ )
447
+
448
+
449
+ def _ident_name(expr: str) -> str | None:
450
+ """Last identifier of a simple expression (`self.user.accessToken` -> `accessToken`)."""
451
+ expr = expr.strip()
452
+ if not _SIMPLE_EXPR.fullmatch(expr):
453
+ return None
454
+ parts = [p for p in _EXPR_SEP.split(expr) if p]
455
+ if not parts or not (parts[-1][0].isalpha() or parts[-1][0] == "_"):
456
+ return None
457
+ return parts[-1]
458
+
459
+
460
+ def _is_defaults_receiver(text: str, dot: int) -> bool:
461
+ """`UserDefaults.standard.set(`, `defaults.set(`, `UserDefaults(suiteName: x)?.set(`."""
462
+ window = text[max(0, dot - 200):dot]
463
+ at = window.rfind("efaults")
464
+ if at < 0 or (at > 0 and not window[at - 1] in "Dd"):
465
+ return False
466
+ return _DEFAULTS_RECEIVER_TAIL.fullmatch(window, at + len("efaults")) is not None
467
+
468
+
469
+ def _call_args(text: str, open_paren: int, limit: int = 2000) -> str | None:
470
+ """Text inside the parentheses opening at ``open_paren`` (strings respected)."""
471
+ depth = 0
472
+ i = open_paren
473
+ end = min(len(text), open_paren + limit)
474
+ in_str = False
475
+ while i < end:
476
+ ch = text[i]
477
+ if in_str:
478
+ if ch == "\\":
479
+ i += 2
480
+ continue
481
+ if ch == '"':
482
+ in_str = False
483
+ elif ch == '"':
484
+ in_str = True
485
+ elif ch == "(":
486
+ depth += 1
487
+ elif ch == ")":
488
+ depth -= 1
489
+ if depth == 0:
490
+ return text[open_paren + 1:i]
491
+ i += 1
492
+ return None
493
+
494
+
495
+ def _swift_interpolations_in_js_quotes(literal: str) -> list[str]:
496
+ """Interpolated expressions that sit inside a JS string literal ('…', "…",
497
+ `…`) in the Swift string ``literal`` (the text between its quotes)."""
498
+ out: list[str] = []
499
+ quote: str | None = None
500
+ i = 0
501
+ n = len(literal)
502
+ while i < n:
503
+ ch = literal[i]
504
+ if ch == "\\" and i + 1 < n:
505
+ nxt = literal[i + 1]
506
+ if nxt == "(":
507
+ depth = 0
508
+ j = i + 1
509
+ while j < n:
510
+ if literal[j] == "(":
511
+ depth += 1
512
+ elif literal[j] == ")":
513
+ depth -= 1
514
+ if depth == 0:
515
+ break
516
+ j += 1
517
+ if quote is not None:
518
+ out.append(literal[i + 2:j])
519
+ i = j + 1
520
+ continue
521
+ if nxt == '"': # \" is a JS double quote inside the Swift string
522
+ quote = None if quote == '"' else (quote or '"')
523
+ i += 2
524
+ continue
525
+ if ch in "'`":
526
+ quote = None if quote == ch else (quote or ch)
527
+ i += 1
528
+ return out
529
+
530
+
531
+ def js_injection_exprs(values: Iterable[str], before: str = "") -> list[str]:
532
+ """The interpolated values that aren't visibly encoded for JavaScript,
533
+ either inline (`\\(term.jsEscaped)`) or where the local was bound
534
+ (`guard let term = raw?.sanitizedForJavaScript`, found in ``before``)."""
535
+ out = []
536
+ for value in values:
537
+ if not value.strip() or _SANITIZED.search(value):
538
+ continue
539
+ name = _ident_name(value)
540
+ if name and _bound_sanitized(name, before):
541
+ continue
542
+ out.append(value)
543
+ return out
544
+
545
+
546
+ def _bound_sanitized(name: str, before: str) -> bool:
547
+ """True when the nearest `let/var name = ...` in ``before`` encodes the value."""
548
+ best = -1
549
+ for keyword in ("let ", "var "):
550
+ at = before.rfind(keyword + name)
551
+ while at >= 0:
552
+ end = at + len(keyword) + len(name)
553
+ if end >= len(before) or not (before[end].isalnum() or before[end] == "_"):
554
+ best = max(best, at)
555
+ break
556
+ at = before.rfind(keyword + name, 0, at)
557
+ if best < 0:
558
+ return False
559
+ line_end = before.find("\n", best)
560
+ return bool(_SANITIZED.search(before[best:line_end if line_end >= 0 else len(before)]))
561
+
562
+
563
+ # Names that suggest the value comes from the user, the page, a URL or a message.
564
+ _INPUT_NAME = re.compile(
565
+ r"(?i)query|param|url|uri|fragment|anchor|term|search|input|text|message|msg|payload|body|title|name|user|"
566
+ r"link|path|href|html|content|value|data|callback"
567
+ )
568
+
569
+
570
+ def js_injection_severity(values: Iterable[str]) -> str:
571
+ """`medium` when an interpolated value is named like external data, else `low`."""
572
+ return "medium" if any(_INPUT_NAME.search(v) for v in values) else "low"
573
+
574
+
575
+ def analyze_swift_source(rel_path: str, text: str, result: ScanResult, handlers: list[Handler]) -> None:
576
+ from .swiftsrc import parse
577
+
578
+ src = parse(text)
579
+
580
+ def code(offset: int) -> bool:
581
+ return not src.in_masked(offset)
582
+
583
+ def snippet(offset: int) -> str:
584
+ start = text.rfind("\n", 0, offset) + 1
585
+ end = text.find("\n", offset)
586
+ return clip(text[start:end if end >= 0 else len(text)], _EVIDENCE_MAX)
587
+
588
+ for m in _URL_HANDLERS.finditer(text):
589
+ if code(m.start()):
590
+ handlers.append(Handler("url_scheme_handler", rel_path, line_of(text, m.start())))
591
+ result.entrypoint_hints.append(EntrypointHint(
592
+ hint="url_scheme_handler (opens URLs from other apps)", file=rel_path,
593
+ line=line_of(text, m.start()), evidence_text=snippet(m.start()), confidence=0.9,
594
+ ))
595
+ for m in _UNIVERSAL_LINK_HANDLERS.finditer(text):
596
+ if code(m.start()):
597
+ result.entrypoint_hints.append(EntrypointHint(
598
+ hint="universal_link_handler (continue userActivity)", file=rel_path,
599
+ line=line_of(text, m.start()), evidence_text=snippet(m.start()), confidence=0.8,
600
+ ))
601
+ for m in _XPC_MACH_LISTENER.finditer(text):
602
+ if code(m.start()):
603
+ result.entrypoint_hints.append(EntrypointHint(
604
+ hint=XPC_MACH_HINT, file=rel_path, line=line_of(text, m.start()),
605
+ evidence_text=snippet(m.start()), confidence=0.9,
606
+ ))
607
+ for m in _XPC_LOCAL_LISTENER.finditer(text):
608
+ if code(m.start()):
609
+ kind = "bundled XPC service" if m.group(1) == "service" else "anonymous listener"
610
+ result.entrypoint_hints.append(EntrypointHint(
611
+ hint=f"xpc_service: {kind} (NSXPCListener.{m.group(1)}())", file=rel_path,
612
+ line=line_of(text, m.start()), evidence_text=snippet(m.start()), confidence=0.9,
613
+ ))
614
+ for m in _XPC_ACCEPT.finditer(text):
615
+ brace = m.end() - 1
616
+ close = src.close_of(brace)
617
+ if close < 0 or not code(m.start()):
618
+ continue
619
+ body = text[brace:close]
620
+ if _RETURN_TRUE.search(body) and not xpc_peer_checked(body, m.group(1)):
621
+ result.app_surface_issues.append(AppSurfaceIssue(
622
+ kind="apple_xpc_unvalidated_peer", file=rel_path, line=line_of(text, m.start()), severity="high",
623
+ evidence_text="listener(_:shouldAcceptNewConnection:) returns true without checking the peer's audit token or code signature",
624
+ ))
625
+
626
+ # WKWebView: a value interpolated into a JS string literal.
627
+ for m in _EVAL_JS.finditer(text):
628
+ if not code(m.start()):
629
+ continue
630
+ args = _call_args(text, m.end() - 1)
631
+ if not args:
632
+ continue
633
+ first = args.lstrip()
634
+ if not first.startswith('"') or first.startswith('"""'):
635
+ continue
636
+ end = _swift_string_end(first)
637
+ if end < 0:
638
+ continue
639
+ exprs = js_injection_exprs(_swift_interpolations_in_js_quotes(first[1:end]), text[max(0, m.start() - 4000):m.start()])
640
+ if exprs:
641
+ result.app_surface_issues.append(AppSurfaceIssue(
642
+ kind="apple_webview_js_injection", file=rel_path, line=line_of(text, m.start()),
643
+ severity=js_injection_severity(exprs),
644
+ evidence_text=clip(f"{snippet(m.start())} [interpolated: {', '.join(e.strip() for e in exprs[:3])}]", _EVIDENCE_MAX),
645
+ ))
646
+
647
+ # UserDefaults: a secret-named value or key.
648
+ for m in _SET_CALL.finditer(text):
649
+ if not code(m.start()) or not _is_defaults_receiver(text, m.start()):
650
+ continue
651
+ args = _call_args(text, m.end() - 1)
652
+ if args is None or "forKey" not in args:
653
+ continue
654
+ value, _, key = args.partition("forKey")
655
+ key = key.lstrip(" \t:").strip()
656
+ value = value.rstrip(" \t,")
657
+ key_literal = key[1:-1] if len(key) > 2 and key[0] == key[-1] == '"' else None
658
+ value_name = _ident_name(value)
659
+ if (key_literal and is_secret_name(key_literal)) or (value_name and is_secret_name(value_name)):
660
+ _add_unique(result, AppSurfaceIssue(
661
+ kind="apple_insecure_secret_storage", file=rel_path, line=line_of(text, m.start()), severity="medium",
662
+ evidence_text=snippet(m.start()),
663
+ ))
664
+ for m in _APP_STORAGE.finditer(text):
665
+ if code(m.start()) and is_secret_name(m.group(1)):
666
+ _add_unique(result, AppSurfaceIssue(
667
+ kind="apple_insecure_secret_storage", file=rel_path, line=line_of(text, m.start()), severity="medium",
668
+ evidence_text=snippet(m.start()),
669
+ ))
670
+
671
+ # Pasteboard: a secret-named value copied to the general pasteboard.
672
+ for m in _PASTEBOARD_SET.finditer(text):
673
+ if not code(m.start()):
674
+ continue
675
+ value = m.group(1) if m.group(1) is not None else (m.group(2) or "").split(",", 1)[0]
676
+ name = _ident_name(value)
677
+ if name and is_secret_name(name):
678
+ result.app_surface_issues.append(AppSurfaceIssue(
679
+ kind="apple_pasteboard_secret", file=rel_path, line=line_of(text, m.start()), severity="low",
680
+ evidence_text=snippet(m.start()),
681
+ ))
682
+
683
+ # Dynamic dispatch on a string built at the call site.
684
+ for m in _DYNAMIC.finditer(text):
685
+ if not code(m.start()):
686
+ continue
687
+ args = _call_args(text, m.end() - 1)
688
+ if args is None:
689
+ continue
690
+ arg = args.split(",", 1)[0] if m.group(1) == "dlopen" else args
691
+ if _BUILT_STRING.search(arg):
692
+ result.app_surface_issues.append(AppSurfaceIssue(
693
+ kind="apple_dynamic_dispatch", file=rel_path, line=line_of(text, m.start()), severity="medium",
694
+ evidence_text=snippet(m.start()),
695
+ ))
696
+
697
+
698
+ def _swift_string_end(text: str) -> int:
699
+ """Index of the closing quote of the Swift string literal starting at text[0]."""
700
+ i = 1
701
+ n = len(text)
702
+ depth = 0
703
+ while i < n:
704
+ ch = text[i]
705
+ if ch == "\\" and i + 1 < n:
706
+ if text[i + 1] == "(":
707
+ depth += 1
708
+ i += 2
709
+ continue
710
+ i += 2
711
+ continue
712
+ if depth:
713
+ if ch == "(":
714
+ depth += 1
715
+ elif ch == ")":
716
+ depth -= 1
717
+ elif ch == '"':
718
+ # a string inside an interpolation: skip it
719
+ close = text.find('"', i + 1)
720
+ if close < 0:
721
+ return -1
722
+ i = close
723
+ elif ch == '"':
724
+ return i
725
+ elif ch == "\n":
726
+ return -1
727
+ i += 1
728
+ return -1
729
+
730
+
731
+ def _add_unique(result: ScanResult, issue: AppSurfaceIssue) -> None:
732
+ for existing in result.app_surface_issues:
733
+ if (existing.kind, existing.file, existing.line, existing.evidence_text) == (
734
+ issue.kind, issue.file, issue.line, issue.evidence_text
735
+ ):
736
+ return
737
+ result.app_surface_issues.append(issue)
738
+
739
+
740
+ def report_url_schemes(result: ScanResult, handlers: list[Handler]) -> None:
741
+ """One `apple_custom_url_scheme` issue per scheme declared in an
742
+ Info.plist. The handlers that open the URLs are entry points of their own;
743
+ they aren't folded into the evidence, so the Swift and Objective-C
744
+ analyzers report the same issue and core's merge keeps one."""
745
+ for hint in list(result.entrypoint_hints):
746
+ if not hint.hint.startswith(_SCHEME_HINT):
747
+ continue
748
+ scheme = hint.hint[len(_SCHEME_HINT):]
749
+ _add_unique(result, AppSurfaceIssue(
750
+ kind="apple_custom_url_scheme", file=hint.file, line=hint.line, severity="low",
751
+ evidence_text=clip(f"CFBundleURLSchemes: {scheme}", _EVIDENCE_MAX),
752
+ ))
753
+
754
+
755
+ __all__ = [
756
+ "analyze_cartfile",
757
+ "analyze_entitlements",
758
+ "analyze_info_plist",
759
+ "analyze_project_files",
760
+ "analyze_swift_source",
761
+ "analyze_xcconfig",
762
+ "is_secret_name",
763
+ "js_injection_exprs",
764
+ "report_url_schemes",
765
+ ]