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,545 @@
1
+ """Objective-C / Objective-C++ analyzer (AttackMap#190), the package's second
2
+ analyzer entry point (``objc``). It shares the iOS/macOS surface model with the
3
+ Swift analyzer (``apple_surface.py``): the same project-file checks
4
+ (Info.plist, entitlements, xcconfig, Cartfile.resolved) and the same issue
5
+ kinds, applied to ``.m`` / ``.mm`` sources:
6
+
7
+ - entry points: URL-scheme handlers (``application:openURL:options:``,
8
+ ``application:handleOpenURL:``, ``scene:openURLContexts:``,
9
+ ``handleGetURLEvent:withReplyEvent:``), universal-link handlers
10
+ (``application:continueUserActivity:...``) and ``NSXPCListener`` services;
11
+ - ``evaluateJavaScript:`` / ``stringByEvaluatingJavaScriptFromString:`` with a
12
+ ``stringWithFormat:`` whose ``%@`` sits inside a JS string literal and whose
13
+ argument isn't visibly encoded;
14
+ - secret-named values or keys written to ``NSUserDefaults`` or the general
15
+ pasteboard;
16
+ - an XPC ``listener:shouldAcceptNewConnection:`` that returns ``YES`` without
17
+ checking the peer;
18
+ - ``NSClassFromString`` / ``NSSelectorFromString`` / ``dlopen`` on a string
19
+ built at the call site;
20
+ - native weaknesses shared with the C/C++ plugins (``CodeWeakness``): a
21
+ non-literal, non-localized format string with no arguments
22
+ (``[NSString stringWithFormat:msg]``, ``NSLog(msg)``) and a shell command
23
+ built at runtime (``/bin/sh -c`` with a non-literal command, ``system()`` /
24
+ ``popen()`` on a non-literal).
25
+
26
+ ``NSFileManager`` / ``NSURL`` file sinks aren't reported: without a taint
27
+ pass for Objective-C they'd be guesses (precision over recall).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import functools
33
+ import re
34
+ from pathlib import Path
35
+
36
+ from attackmap.sdk import iter_repo_files, line_of, read_source, rel
37
+
38
+ from . import apple_surface
39
+ from .analyzer import SKIP_DIRS, _is_test_path
40
+ from .apple_surface import Handler, is_secret_name, js_injection_exprs, js_injection_severity
41
+ from attackmap.sdk import (
42
+ AnalyzerMetadata,
43
+ AppSurfaceIssue,
44
+ CodeWeakness,
45
+ EntrypointHint,
46
+ ScanResult,
47
+ )
48
+ from .swiftsrc import Source, clip
49
+
50
+ OBJC_SUFFIXES = {".m", ".mm"}
51
+ _EVIDENCE_MAX = 200
52
+ # An Objective-C file, not MATLAB/Octave/Mercury `.m`.
53
+ _OBJC_MARKER = re.compile(r"@(?:implementation|interface|protocol|end)\b|^[ \t]*#import\b", re.MULTILINE)
54
+
55
+
56
+ # ---------- C-family masking (strings, chars, comments) and brackets ----------
57
+
58
+ _TOKEN = re.compile(r'//|/\*|@?"|\'|[()\[\]{}]')
59
+ _STRING_BODY = re.compile(r'(?:[^"\\\n]|\\.)*"?')
60
+ _CHAR_BODY = re.compile(r"(?:[^'\\\n]|\\.){0,10}'?")
61
+ _OPEN_TO_CLOSE = {"(": ")", "[": "]", "{": "}"}
62
+
63
+
64
+ @functools.lru_cache(maxsize=8)
65
+ def parse(text: str) -> Source:
66
+ src = Source(text)
67
+ stack: list[tuple[str, int]] = []
68
+ pos = 0
69
+ n = len(text)
70
+ while pos < n:
71
+ m = _TOKEN.search(text, pos)
72
+ if m is None:
73
+ break
74
+ tok, start = m.group(0), m.start()
75
+ if tok == "//":
76
+ end = text.find("\n", start)
77
+ end = n if end < 0 else end
78
+ src.masked.append((start, end))
79
+ elif tok == "/*":
80
+ end = text.find("*/", start + 2)
81
+ end = n if end < 0 else end + 2
82
+ src.masked.append((start, end))
83
+ elif tok.endswith('"'):
84
+ end = _STRING_BODY.match(text, m.end()).end()
85
+ src.masked.append((start, end))
86
+ src.strings.append((start, end))
87
+ elif tok == "'":
88
+ end = _CHAR_BODY.match(text, m.end()).end()
89
+ src.masked.append((start, end))
90
+ elif tok in _OPEN_TO_CLOSE:
91
+ stack.append((tok, start))
92
+ end = m.end()
93
+ else:
94
+ for depth in range(len(stack) - 1, max(-1, len(stack) - 8), -1):
95
+ if _OPEN_TO_CLOSE[stack[depth][0]] == tok:
96
+ open_at = stack[depth][1]
97
+ del stack[depth:]
98
+ src.pairs[open_at] = start
99
+ src.rpairs[start] = open_at
100
+ break
101
+ end = m.end()
102
+ pos = max(end, start + 1)
103
+ src._starts = [s for s, _ in src.masked]
104
+ return src
105
+
106
+
107
+ def _enclosing_bracket(src: Source, offset: int) -> tuple[int, int] | None:
108
+ """The innermost `[...]` message send containing ``offset``."""
109
+ text = src.text
110
+ i = offset
111
+ lo = max(0, offset - 2000)
112
+ while i > lo:
113
+ i -= 1
114
+ ch = text[i]
115
+ if ch in ")]}":
116
+ open_ = src.open_of(i)
117
+ if open_ >= 0:
118
+ i = open_
119
+ continue
120
+ if ch == "[" and not src.in_masked(i):
121
+ close = src.close_of(i)
122
+ if close > offset:
123
+ return (i, close)
124
+ return None
125
+
126
+
127
+ # ---------- Patterns ----------
128
+
129
+ _URL_HANDLER_DEF = re.compile(
130
+ r"^[ \t]*-[ \t]*\([ \t]*(?:BOOL|void)[ \t]*\)[ \t]*(?:application[ \t]*:[^{;]{0,300}?\b(?:openURL|handleOpenURL)[ \t]*:"
131
+ r"|scene[ \t]*:[^{;]{0,200}?\bopenURLContexts[ \t]*:|handleGetURLEvent[ \t]*:)",
132
+ re.MULTILINE,
133
+ )
134
+ _UNIVERSAL_LINK_DEF = re.compile(
135
+ r"^[ \t]*-[ \t]*\([ \t]*BOOL[ \t]*\)[ \t]*application[ \t]*:[^{;]{0,300}?\bcontinueUserActivity[ \t]*:",
136
+ re.MULTILINE,
137
+ )
138
+ _XPC_MACH_LISTENER = re.compile(r"\bNSXPCListener[ \t]+alloc[ \t]*\][ \t]*initWithMachServiceName[ \t]*:")
139
+ _XPC_LOCAL_LISTENER = re.compile(r"\[[ \t]*NSXPCListener[ \t]+(serviceListener|anonymousListener)[ \t]*\]")
140
+ _XPC_ACCEPT = re.compile(
141
+ r"^[ \t]*-[ \t]*\([ \t]*BOOL[ \t]*\)[ \t]*listener[ \t]*:[^{;]{0,200}?\bshouldAcceptNewConnection[ \t]*:"
142
+ r"[ \t]*\([^)\n]{0,80}\)[ \t]*(\w{1,60})[^{;]{0,200}\{",
143
+ re.MULTILINE,
144
+ )
145
+ _RETURN_YES = re.compile(r"\breturn[ \t]+(?:YES|true)\b")
146
+
147
+ _EVAL_JS = re.compile(r"\b(?:evaluateJavaScript|stringByEvaluatingJavaScriptFromString)[ \t]*:[ \t]*\[[ \t]*NSString[ \t]+stringWithFormat[ \t]*:[ \t]*@\"")
148
+ _FORMAT_SPEC = re.compile(r"%(?:%|[-+ #0]{0,4}\d{0,4}(?:\.\d{1,4})?(?:hh|h|ll|l|q|z|t|j|L)?([@dDiuUxXoOfFeEgGcCsSpaA]))")
149
+
150
+ _DEFAULTS_VAR = re.compile(r"\bNSUserDefaults[ \t]*\*[ \t]*(\w{1,60})[ \t]*=")
151
+ _PASTEBOARD_VAR = re.compile(r"\b(?:UIPasteboard|NSPasteboard)[ \t]*\*[ \t]*(\w{1,60})[ \t]*=")
152
+ _SET_FOR_KEY = re.compile(
153
+ r"\[[ \t]*(\[[^\[\]\n]{1,120}\]|[\w.]{1,120})[ \t]+set(?:Object|Value)[ \t]*:[ \t]*"
154
+ r"(\[[^\[\]\n]{1,160}\]|[^\s\[\]]{1,160})[ \t]+forKey[ \t]*:[ \t]*(@\"[^\"\n]{0,120}\"|[\w.]{1,120})[ \t]*\]"
155
+ )
156
+ _PASTEBOARD_SET = re.compile(
157
+ r"(\[[ \t]*(?:UIPasteboard|NSPasteboard)[ \t]+generalPasteboard[ \t]*\]|(?:UIPasteboard|NSPasteboard)\.generalPasteboard|\b\w{1,60})"
158
+ r"(?:[ \t]*\.[ \t]*string[ \t]*=[ \t]*([^;\n]{1,160});|[ \t]+setString[ \t]*:[ \t]*([^\s\]]{1,160}))"
159
+ )
160
+ _DYNAMIC = re.compile(r"\b(NSClassFromString|NSSelectorFromString|dlopen)[ \t]*\(")
161
+ _BUILT_STRING = re.compile(r"stringWithFormat|initWithFormat|stringByAppending|\+[ \t]*@?\"")
162
+
163
+ _FORMAT_CALL = re.compile(
164
+ r"\b(?:stringWithFormat|initWithFormat|appendFormat|stringByAppendingFormat|localizedStringWithFormat)[ \t]*:"
165
+ )
166
+ _NSLOG = re.compile(r"\bNSLog[ \t]*\(")
167
+ _LOCALIZED = re.compile(r"(?i)locali[sz]ed|\bNSLocalizedString|WMFLocalized")
168
+ # `initWithFormat:fmt arguments:va` / `locale:` forward a format the caller built.
169
+ _FORWARDED_FORMAT = re.compile(r"\b(?:arguments|locale)[ \t]*:")
170
+
171
+ _SHELL_PATH = re.compile(r'@"/(?:usr/)?bin/(?:ba|z|k|da)?sh"')
172
+ _SHELL_ARGS = re.compile(r'@\[[ \t]*@"-c"[ \t]*,[ \t]*([^\]\n]{1,200})\]')
173
+ _SYSTEM_CALL = re.compile(r"(?<![\w.])(system|popen)[ \t]*\(")
174
+
175
+
176
+ def _first_arg(text: str, open_paren: int, src: Source) -> str | None:
177
+ close = src.close_of(open_paren)
178
+ if close < 0:
179
+ return None
180
+ inner = text[open_paren + 1:close]
181
+ depth = 0
182
+ for i, ch in enumerate(inner):
183
+ if ch in "([{":
184
+ depth += 1
185
+ elif ch in ")]}":
186
+ depth -= 1
187
+ elif ch == "," and depth == 0 and not src.in_masked(open_paren + 1 + i):
188
+ return inner[:i]
189
+ return inner
190
+
191
+
192
+ def _is_literal(expr: str) -> bool:
193
+ return expr.strip().startswith(('@"', '"'))
194
+
195
+
196
+ _SELECTOR_TAIL = re.compile(r"@selector[ \t]*\([ \t]*$")
197
+ _NULL_ARGS = frozenset({"NULL", "nil", "0", "nullptr", "(char *)NULL", "(const char *)NULL"})
198
+ _NUMBER = re.compile(r"@?\(?-?\d+(?:\.\d+)?\)?[a-zA-Z]{0,3}")
199
+
200
+
201
+ def _is_constant(expr: str) -> bool:
202
+ """`@"dark"`, `"x"`, `@42`, `3`: nothing a caller controls."""
203
+ expr = expr.strip()
204
+ return _is_literal(expr) or _NUMBER.fullmatch(expr) is not None or expr in ("YES", "NO", "nil", "NULL")
205
+
206
+
207
+ def _objc_bound_sanitized(expr: str, before: str) -> bool:
208
+ """True when the local ``expr`` was last assigned an encoded value:
209
+ `NSString *term = [raw wmf_stringByEscapingForJavaScript];`."""
210
+ name = expr.strip()
211
+ if not name.isidentifier():
212
+ return False
213
+ at = len(before)
214
+ for _ in range(64):
215
+ at = before.rfind(name, 0, at)
216
+ if at < 0:
217
+ return False
218
+ end = at + len(name)
219
+ prev = before[at - 1] if at > 0 else " "
220
+ if prev.isalnum() or prev == "_" or (end < len(before) and (before[end].isalnum() or before[end] == "_")):
221
+ continue
222
+ k = end
223
+ while k < len(before) and before[k] in " \t":
224
+ k += 1
225
+ if k < len(before) and before[k] == "=" and before[k + 1:k + 2] != "=":
226
+ line_end = before.find("\n", k)
227
+ return bool(apple_surface._SANITIZED.search(before[k:line_end if line_end >= 0 else len(before)]))
228
+ return False
229
+
230
+
231
+ def _split_message_args(body: str) -> list[str]:
232
+ """Top-level comma-separated parts of ``body`` (outside brackets/strings)."""
233
+ parts: list[str] = []
234
+ depth = 0
235
+ start = 0
236
+ in_str = False
237
+ i = 0
238
+ while i < len(body):
239
+ ch = body[i]
240
+ if in_str:
241
+ if ch == "\\":
242
+ i += 2
243
+ continue
244
+ if ch == '"':
245
+ in_str = False
246
+ elif ch == '"':
247
+ in_str = True
248
+ elif ch in "([{":
249
+ depth += 1
250
+ elif ch in ")]}":
251
+ depth -= 1
252
+ elif ch == "," and depth == 0:
253
+ parts.append(body[start:i])
254
+ start = i + 1
255
+ i += 1
256
+ parts.append(body[start:])
257
+ return parts
258
+
259
+
260
+ def _objc_string_value(text: str, at: int) -> tuple[str, int] | None:
261
+ """(contents, end offset) of the `@"..."` literal starting at ``at``
262
+ (adjacent literals `@"a" @"b"` are concatenated)."""
263
+ pieces: list[str] = []
264
+ pos = at
265
+ n = len(text)
266
+ for _ in range(32):
267
+ while pos < n and text[pos] in " \t\r\n":
268
+ pos += 1
269
+ if text.startswith('@"', pos):
270
+ pos += 2
271
+ elif text.startswith('"', pos) and pieces:
272
+ pos += 1
273
+ else:
274
+ break
275
+ m = _STRING_BODY.match(text, pos)
276
+ end = m.end()
277
+ pieces.append(text[pos:end - 1] if text[end - 1:end] == '"' else text[pos:end])
278
+ pos = end
279
+ return ("".join(pieces), pos) if pieces else None
280
+
281
+
282
+ def _quoted_format_args(fmt: str, args: list[str]) -> list[str]:
283
+ """Arguments of the `%@` specifiers that sit inside a JS string literal."""
284
+ out: list[str] = []
285
+ quote: str | None = None
286
+ index = 0
287
+ i = 0
288
+ n = len(fmt)
289
+ while i < n:
290
+ ch = fmt[i]
291
+ if ch == "\\" and i + 1 < n:
292
+ if fmt[i + 1] == '"':
293
+ quote = None if quote == '"' else (quote or '"')
294
+ i += 2
295
+ continue
296
+ if ch == "%":
297
+ m = _FORMAT_SPEC.match(fmt, i)
298
+ if m is None:
299
+ i += 1
300
+ continue
301
+ conv = m.group(1)
302
+ if conv is not None:
303
+ if conv == "@" and quote is not None and index < len(args):
304
+ out.append(args[index])
305
+ index += 1
306
+ i = m.end()
307
+ continue
308
+ if ch in "'`":
309
+ quote = None if quote == ch else (quote or ch)
310
+ i += 1
311
+ return out
312
+
313
+
314
+ def _value_name(expr: str) -> str | None:
315
+ """The identifier a value expression ends in: `self.token`, `[user password]`."""
316
+ expr = expr.strip().rstrip(";")
317
+ if expr.startswith("[") and expr.endswith("]"):
318
+ words = expr[1:-1].split()
319
+ return words[-1] if len(words) == 2 and words[-1].isidentifier() else None
320
+ tail = expr.rsplit(".", 1)[-1].strip()
321
+ return tail if tail.isidentifier() else None
322
+
323
+
324
+ # ---------- Per-file analysis ----------
325
+
326
+ def analyze_objc_source(rel_path: str, text: str, result: ScanResult, handlers: list[Handler]) -> None:
327
+ src = parse(text)
328
+
329
+ def code(offset: int) -> bool:
330
+ return not src.in_masked(offset)
331
+
332
+ def snippet(offset: int) -> str:
333
+ start = text.rfind("\n", 0, offset) + 1
334
+ end = text.find("\n", offset)
335
+ return clip(text[start:end if end >= 0 else len(text)], _EVIDENCE_MAX)
336
+
337
+ def issue(kind: str, offset: int, severity: str, evidence: str | None = None) -> None:
338
+ result.app_surface_issues.append(AppSurfaceIssue(
339
+ kind=kind, file=rel_path, line=line_of(text, offset), severity=severity, # type: ignore[arg-type]
340
+ evidence_text=evidence or snippet(offset),
341
+ ))
342
+
343
+ def weakness(kind: str, offset: int, evidence: str | None = None) -> None:
344
+ result.code_weaknesses.append(CodeWeakness(
345
+ kind=kind, file=rel_path, line=line_of(text, offset), severity="medium", # type: ignore[arg-type]
346
+ confidence="medium", evidence_text=evidence or snippet(offset),
347
+ ))
348
+
349
+ # Entry points.
350
+ for m in _URL_HANDLER_DEF.finditer(text):
351
+ if code(m.start()):
352
+ line = line_of(text, m.start())
353
+ handlers.append(Handler("url_scheme_handler", rel_path, line))
354
+ result.entrypoint_hints.append(EntrypointHint(
355
+ hint="url_scheme_handler (opens URLs from other apps)", file=rel_path, line=line,
356
+ evidence_text=snippet(m.start()), confidence=0.9,
357
+ ))
358
+ for m in _UNIVERSAL_LINK_DEF.finditer(text):
359
+ if code(m.start()):
360
+ result.entrypoint_hints.append(EntrypointHint(
361
+ hint="universal_link_handler (continueUserActivity)", file=rel_path,
362
+ line=line_of(text, m.start()), evidence_text=snippet(m.start()), confidence=0.8,
363
+ ))
364
+ for m in _XPC_MACH_LISTENER.finditer(text):
365
+ if code(m.start()):
366
+ result.entrypoint_hints.append(EntrypointHint(
367
+ hint=apple_surface.XPC_MACH_HINT, file=rel_path, line=line_of(text, m.start()),
368
+ evidence_text=snippet(m.start()), confidence=0.9,
369
+ ))
370
+ for m in _XPC_LOCAL_LISTENER.finditer(text):
371
+ if code(m.start()):
372
+ kind = "bundled XPC service" if m.group(1) == "serviceListener" else "anonymous listener"
373
+ result.entrypoint_hints.append(EntrypointHint(
374
+ hint=f"xpc_service: {kind} ([NSXPCListener {m.group(1)}])", file=rel_path,
375
+ line=line_of(text, m.start()), evidence_text=snippet(m.start()), confidence=0.9,
376
+ ))
377
+ for m in _XPC_ACCEPT.finditer(text):
378
+ brace = m.end() - 1
379
+ close = src.close_of(brace)
380
+ if close < 0 or not code(brace):
381
+ continue
382
+ body = text[brace:close]
383
+ if _RETURN_YES.search(body) and not apple_surface.xpc_peer_checked(body, m.group(1)):
384
+ issue("apple_xpc_unvalidated_peer", m.start(), "high",
385
+ "listener:shouldAcceptNewConnection: returns YES without checking the peer's audit token or code signature")
386
+
387
+ # WKWebView / UIWebView script from stringWithFormat.
388
+ for m in _EVAL_JS.finditer(text):
389
+ if not code(m.start()):
390
+ continue
391
+ literal = _objc_string_value(text, m.end() - 2)
392
+ if literal is None:
393
+ continue
394
+ fmt, after = literal
395
+ bracket = _enclosing_bracket(src, after)
396
+ if bracket is None:
397
+ continue
398
+ rest = text[after:bracket[1]]
399
+ args = [a.strip() for a in _split_message_args(rest)[1:]] if rest.lstrip().startswith(",") else []
400
+ before = text[max(0, m.start() - 4000):m.start()]
401
+ exprs = [
402
+ e for e in js_injection_exprs(_quoted_format_args(fmt, args), before)
403
+ if not _is_constant(e) and not _objc_bound_sanitized(e, before)
404
+ ]
405
+ if exprs:
406
+ issue("apple_webview_js_injection", m.start(), js_injection_severity(exprs),
407
+ clip(f"{snippet(m.start())} [interpolated: {', '.join(exprs[:3])}]", _EVIDENCE_MAX))
408
+
409
+ # NSUserDefaults: a secret-named value or key.
410
+ defaults_vars = {v.group(1) for v in _DEFAULTS_VAR.finditer(text)}
411
+ for m in _SET_FOR_KEY.finditer(text):
412
+ if not code(m.start()):
413
+ continue
414
+ receiver, value, key = m.group(1), m.group(2), m.group(3)
415
+ if "efaults" not in receiver and receiver not in defaults_vars:
416
+ continue
417
+ key_literal = key[2:-1] if key.startswith('@"') else None
418
+ name = _value_name(value)
419
+ if (key_literal and is_secret_name(key_literal)) or (name and is_secret_name(name)):
420
+ issue("apple_insecure_secret_storage", m.start(), "medium")
421
+
422
+ # Pasteboard: a secret-named value.
423
+ pasteboard_vars = {v.group(1) for v in _PASTEBOARD_VAR.finditer(text)}
424
+ for m in _PASTEBOARD_SET.finditer(text):
425
+ if not code(m.start()):
426
+ continue
427
+ target = m.group(1)
428
+ if "Pasteboard" not in target and target not in pasteboard_vars:
429
+ continue
430
+ name = _value_name(m.group(2) or m.group(3) or "")
431
+ if name and is_secret_name(name):
432
+ issue("apple_pasteboard_secret", m.start(), "low")
433
+
434
+ # Dynamic dispatch on a string built at the call site.
435
+ for m in _DYNAMIC.finditer(text):
436
+ if not code(m.start()):
437
+ continue
438
+ arg = _first_arg(text, m.end() - 1, src)
439
+ if arg is not None and _BUILT_STRING.search(arg):
440
+ issue("apple_dynamic_dispatch", m.start(), "medium")
441
+
442
+ # Format strings: a non-literal, non-localized format with nothing to format.
443
+ for m in _FORMAT_CALL.finditer(text):
444
+ if not code(m.start()):
445
+ continue
446
+ bracket = _enclosing_bracket(src, m.start())
447
+ if bracket is None:
448
+ continue
449
+ rest = text[m.end():bracket[1]]
450
+ parts = _split_message_args(rest)
451
+ fmt = parts[0].strip()
452
+ if _SELECTOR_TAIL.search(text, max(0, m.start() - 40), m.start()):
453
+ continue # `@selector(stringWithFormat:)` names the method, it doesn't call it
454
+ if (
455
+ len(parts) == 1 and fmt and not _is_literal(fmt) and not _LOCALIZED.search(fmt)
456
+ and not _FORWARDED_FORMAT.search(rest)
457
+ ):
458
+ weakness("format_string", m.start())
459
+ for m in _NSLOG.finditer(text):
460
+ if not code(m.start()):
461
+ continue
462
+ close = src.close_of(m.end() - 1)
463
+ if close < 0:
464
+ continue
465
+ parts = _split_message_args(text[m.end():close])
466
+ fmt = parts[0].strip()
467
+ if len(parts) == 1 and fmt and not _is_literal(fmt) and not _LOCALIZED.search(fmt):
468
+ weakness("format_string", m.start())
469
+
470
+ # Shell commands built at runtime.
471
+ for m in _SHELL_ARGS.finditer(text):
472
+ if not code(m.start()):
473
+ continue
474
+ command = m.group(1).split(",", 1)[0].strip()
475
+ window = text[max(0, m.start() - 1500):m.end() + 300]
476
+ if command and not _is_literal(command) and _SHELL_PATH.search(window):
477
+ weakness("native_command_exec", m.start())
478
+ for m in _SYSTEM_CALL.finditer(text):
479
+ if not code(m.start()):
480
+ continue
481
+ arg = _first_arg(text, m.end() - 1, src)
482
+ if arg is not None and arg.strip() and not _is_literal(arg) and arg.strip() not in _NULL_ARGS:
483
+ weakness("native_command_exec", m.start()) # `system(NULL)` only asks for a shell
484
+
485
+
486
+ def looks_like_objc(text: str) -> bool:
487
+ return _OBJC_MARKER.search(text) is not None
488
+
489
+
490
+ class ObjCAnalyzer:
491
+ metadata = AnalyzerMetadata(
492
+ name="objc",
493
+ display_name="Objective-C Analyzer",
494
+ version="0.2.0",
495
+ description="Objective-C / Objective-C++ analyzer: the iOS/macOS app attack surface (ATS, URL schemes, entitlements, WKWebView, NSUserDefaults, XPC), format strings, shell commands and dynamic dispatch.",
496
+ scope="Objective-C and Objective-C++ (.m/.mm) iOS/macOS apps and frameworks, with their Info.plist, entitlements, xcconfig and Carthage files.",
497
+ targets=["ios", "macos", "cocoa"],
498
+ languages=["objective-c", "objective-c++"],
499
+ priority=21, # after `swift`, which wins a duplicate project-file signal
500
+ experimental=True,
501
+ enabled_by_default=False, # opt-in via `-m objc` while experimental
502
+ )
503
+
504
+ @property
505
+ def name(self) -> str:
506
+ return self.metadata.name
507
+
508
+ def detect(self, repo_path: str | Path) -> bool:
509
+ root = Path(repo_path).resolve()
510
+ if not root.exists() or not root.is_dir():
511
+ return False
512
+ checked = 0
513
+ for path in iter_repo_files(root, suffixes=OBJC_SUFFIXES, skip_dirs=SKIP_DIRS):
514
+ text = read_source(path)
515
+ if text is not None and looks_like_objc(text):
516
+ return True
517
+ checked += 1
518
+ if checked >= 64:
519
+ break
520
+ return False
521
+
522
+ def analyze(self, repo_path: str | Path) -> ScanResult:
523
+ root = Path(repo_path).resolve()
524
+ result = ScanResult(root=str(root))
525
+ if not root.exists() or not root.is_dir():
526
+ return result
527
+ apple_surface.analyze_project_files(root, result, SKIP_DIRS, is_test=_is_test_path)
528
+ handlers: list[Handler] = []
529
+ for path in iter_repo_files(root, suffixes=OBJC_SUFFIXES, skip_dirs=SKIP_DIRS):
530
+ rel_path = rel(path, root)
531
+ if _is_test_path(rel_path):
532
+ continue
533
+ text = read_source(path)
534
+ if text is None or not looks_like_objc(text):
535
+ continue
536
+ result.files_scanned += 1
537
+ analyze_objc_source(rel_path, text, result, handlers)
538
+ apple_surface.report_url_schemes(result, handlers)
539
+ apple_surface.finalize_xpc(result)
540
+ if result.files_scanned:
541
+ result.languages.append("objective-c")
542
+ return result
543
+
544
+
545
+ __all__ = ["ObjCAnalyzer", "analyze_objc_source", "looks_like_objc", "parse"]