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.
- attackmap_analyzer_swift/__init__.py +4 -0
- attackmap_analyzer_swift/analyzer.py +296 -0
- attackmap_analyzer_swift/apple_surface.py +765 -0
- attackmap_analyzer_swift/objc.py +545 -0
- attackmap_analyzer_swift/routes.py +1195 -0
- attackmap_analyzer_swift/sbom.py +58 -0
- attackmap_analyzer_swift/swiftsrc.py +239 -0
- attackmap_analyzer_swift-0.2.0.dist-info/METADATA +175 -0
- attackmap_analyzer_swift-0.2.0.dist-info/RECORD +13 -0
- attackmap_analyzer_swift-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_swift-0.2.0.dist-info/entry_points.txt +3 -0
- attackmap_analyzer_swift-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_swift-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|