narvy-cli 1.0.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.
Files changed (93) hide show
  1. narvy/__init__.py +3 -0
  2. narvy/android/__init__.py +0 -0
  3. narvy/android/source_analyzer.py +268 -0
  4. narvy/android/split_bundle.py +475 -0
  5. narvy/android_rule_context.py +196 -0
  6. narvy/apk_memory_preflight.py +248 -0
  7. narvy/auth.py +40 -0
  8. narvy/ci_templates/bitbucket.yml +25 -0
  9. narvy/ci_templates/github.yml +60 -0
  10. narvy/ci_templates/gitlab.yml +32 -0
  11. narvy/cloud/__init__.py +0 -0
  12. narvy/cloud/aws_scan.py +1188 -0
  13. narvy/comment_filter.py +134 -0
  14. narvy/crypto_taint_lite.py +82 -0
  15. narvy/decompiler.py +337 -0
  16. narvy/doctor.py +244 -0
  17. narvy/host/__init__.py +0 -0
  18. narvy/host/audit.py +157 -0
  19. narvy/host/host_knowledge.py +982 -0
  20. narvy/host/lynis_bootstrap.py +400 -0
  21. narvy/host/lynis_parser.py +358 -0
  22. narvy/host/narvy_checks.py +789 -0
  23. narvy/host/report.py +69 -0
  24. narvy/host/ssh_exec.py +219 -0
  25. narvy/ios/__init__.py +0 -0
  26. narvy/ios/binary_analyzer.py +1201 -0
  27. narvy/ios/plist_checks.py +249 -0
  28. narvy/ios/source_analyzer.py +301 -0
  29. narvy/ios/third_party_filter.py +242 -0
  30. narvy/ios/trust_all_context.py +66 -0
  31. narvy/main.py +1983 -0
  32. narvy/native_hardening.py +503 -0
  33. narvy/reporter.py +151 -0
  34. narvy/rule_engine.py +57 -0
  35. narvy/rules/android/config.yml +77 -0
  36. narvy/rules/android/crypto.yml +34 -0
  37. narvy/rules/android/secrets.yml +161 -0
  38. narvy/rules/android/storage.yml +24 -0
  39. narvy/rules/android/webview.yml +23 -0
  40. narvy/rules/android.yml +219 -0
  41. narvy/rules/ios/objc/crypto.yml +56 -0
  42. narvy/rules/ios/objc/network.yml +67 -0
  43. narvy/rules/ios/objc/secrets.yml +79 -0
  44. narvy/rules/ios/objc/storage.yml +45 -0
  45. narvy/rules/ios/objc/webview.yml +45 -0
  46. narvy/rules/ios_swift.yml +327 -0
  47. narvy/rules/web/go.yml +2837 -0
  48. narvy/rules/web/java.yml +1576 -0
  49. narvy/rules/web/javascript.yml +3683 -0
  50. narvy/rules/web/kotlin.yml +413 -0
  51. narvy/rules/web/local/csharp_narvy/config.yml +61 -0
  52. narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
  53. narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
  54. narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
  55. narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
  56. narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
  57. narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
  58. narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
  59. narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
  60. narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
  61. narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
  62. narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
  63. narvy/rules/web/local/java_narvy/sqli.yml +235 -0
  64. narvy/rules/web/local/java_narvy/xxe.yml +212 -0
  65. narvy/rules/web/php.yml +1644 -0
  66. narvy/rules/web/python.yml +3967 -0
  67. narvy/rules/web/ruby.yml +703 -0
  68. narvy/rules/web/rust.yml +258 -0
  69. narvy/rules/web/secrets.yml +1420 -0
  70. narvy/rules/web/secrets_supplement.yml +383 -0
  71. narvy/sca/__init__.py +1 -0
  72. narvy/sca/android_deps.py +349 -0
  73. narvy/sca/ios_deps.py +578 -0
  74. narvy/sca/osv_client.py +617 -0
  75. narvy/sca/web_deps.py +955 -0
  76. narvy/scope_config.py +195 -0
  77. narvy/semgrep_engine.py +219 -0
  78. narvy/stack_protector_evidence.py +96 -0
  79. narvy/third_party_filter.py +211 -0
  80. narvy/uploader.py +92 -0
  81. narvy/weak_prng_context.py +270 -0
  82. narvy/web/__init__.py +0 -0
  83. narvy/web/nuclei_binary.py +105 -0
  84. narvy/web/scan_blocklist.py +96 -0
  85. narvy/web/scanner.py +842 -0
  86. narvy/web/source_analyzer.py +714 -0
  87. narvy/web/ssrf_guard.py +374 -0
  88. narvy_cli-1.0.0.dist-info/METADATA +165 -0
  89. narvy_cli-1.0.0.dist-info/RECORD +93 -0
  90. narvy_cli-1.0.0.dist-info/WHEEL +5 -0
  91. narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  92. narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  93. narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1201 @@
1
+ """iOS IPA binary-mode SAST: symbol, string and Mach-O metadata checks on the
2
+ main executable and on first-party embedded frameworks.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import re
8
+ import struct
9
+ import shutil
10
+ import subprocess
11
+ import plistlib
12
+ import zipfile
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ try:
16
+ import lief
17
+ LIEF_AVAILABLE = True
18
+ except ImportError:
19
+ LIEF_AVAILABLE = False
20
+
21
+ try:
22
+ import icdump # noqa: F401
23
+ ICDUMP_AVAILABLE = True
24
+ except ImportError:
25
+ ICDUMP_AVAILABLE = False
26
+
27
+ from .third_party_filter import (
28
+ classify_ios_framework,
29
+ check_ios_override,
30
+ get_own_bundle_id,
31
+ TIER_FIRST_PARTY,
32
+ TIER_THIRD_PARTY,
33
+ TIER_UNCONFIRMED,
34
+ )
35
+ from ..scope_config import ScopeConfig
36
+
37
+
38
+ def icdump_available() -> bool:
39
+ return ICDUMP_AVAILABLE
40
+
41
+
42
+ # icdump.objc.parse() is a native extension that can SIGSEGV on some binaries,
43
+ # with no catchable Python exception, so it always runs out of process.
44
+ _ICDUMP_PROBE_SCRIPT = (
45
+ "import sys, json, icdump\n"
46
+ "md = icdump.objc.parse(sys.argv[1])\n"
47
+ "print(json.dumps({'classes': len(md.classes), "
48
+ "'methods': sum(len(c.methods) for c in md.classes)}))\n"
49
+ )
50
+
51
+
52
+ def _icdump_class_count(binary_path: str, timeout: int = 60
53
+ ) -> Tuple[bool, int, int, Optional[str]]:
54
+ """Returns (ok, n_classes, n_methods, error)."""
55
+ import sys as _sys
56
+ try:
57
+ proc = subprocess.run(
58
+ [_sys.executable, "-c", _ICDUMP_PROBE_SCRIPT, binary_path],
59
+ capture_output=True, text=True, timeout=timeout,
60
+ )
61
+ except subprocess.TimeoutExpired:
62
+ return False, 0, 0, f"timed out after {timeout}s"
63
+ except Exception as e:
64
+ return False, 0, 0, str(e)
65
+ if proc.returncode < 0:
66
+ return False, 0, 0, f"crashed (signal {-proc.returncode})"
67
+ if proc.returncode != 0:
68
+ detail = (proc.stderr or proc.stdout or "").strip().splitlines()
69
+ return False, 0, 0, (detail[-1] if detail else f"exit code {proc.returncode}")
70
+ try:
71
+ import json as _json
72
+ data = _json.loads(proc.stdout.strip().splitlines()[-1])
73
+ return True, int(data["classes"]), int(data["methods"]), None
74
+ except Exception as e:
75
+ return False, 0, 0, f"could not parse subprocess output: {e}"
76
+
77
+
78
+ _RULES: Dict[str, Dict[str, Any]] = {}
79
+
80
+
81
+ def _register(rule_id: str, name: str, severity: str, cwe: str, masvs: str,
82
+ description: str, recommendation: str) -> None:
83
+ _RULES[rule_id] = {
84
+ "id": rule_id,
85
+ "name": name,
86
+ "severity": severity,
87
+ "details": {
88
+ "cwe": cwe,
89
+ "masvs": masvs,
90
+ "description": description,
91
+ "recommendation": recommendation,
92
+ },
93
+ }
94
+
95
+
96
+ def _finding(rule_id: str, file_path: str, description: Optional[str] = None,
97
+ name: Optional[str] = None, severity: Optional[str] = None,
98
+ location_symbol: Optional[str] = None) -> Dict[str, Any]:
99
+ """`line` is a placeholder: a release binary carries no line-number table."""
100
+ r = _RULES[rule_id]
101
+ details = dict(r["details"])
102
+ if description:
103
+ details["description"] = description
104
+ f: Dict[str, Any] = {
105
+ "rule_id": rule_id,
106
+ "file_path": file_path,
107
+ "name": name or r["name"],
108
+ "severity": severity or r["severity"],
109
+ "line": 1,
110
+ "engine": "binary",
111
+ "details": details,
112
+ }
113
+ if location_symbol:
114
+ f["location_symbol"] = location_symbol
115
+ return f
116
+
117
+
118
+ def all_rule_defs() -> List[Dict[str, Any]]:
119
+ return list(_RULES.values())
120
+
121
+
122
+ _register("IOS-BIN-CRYPTO-001", "Weak Cryptographic Algorithm", "HIGH", "CWE-327",
123
+ "MSTG-CRYPTO-4",
124
+ "Binary imports/uses a weak cryptographic algorithm (DES/3DES/RC2/RC4/ECB mode).",
125
+ "Use AES-256 in GCM or CBC (with a random IV) mode instead.")
126
+ _register("IOS-BIN-CRYPTO-002", "Weak Hash Algorithm", "HIGH", "CWE-328",
127
+ "MSTG-CRYPTO-4",
128
+ "Binary imports/uses a weak or broken hash algorithm (MD2/MD4/MD5/SHA1).",
129
+ "Replace with SHA-256 or stronger.")
130
+ _register("IOS-BIN-CRYPTO-003", "Non-Cryptographic RNG API Linked", "INFO", "CWE-338",
131
+ "MSTG-CRYPTO-6",
132
+ "Binary links a non-cryptographic RNG API (rand/srand/random) and also links "
133
+ "cryptographic APIs. INFORMATIONAL ONLY: this is symbol co-presence, not "
134
+ "evidence of insecure key/nonce generation - establishing that needs call-site "
135
+ "analysis this binary-mode scanner does not perform. Statically linked zlib/"
136
+ "ffmpeg/sqlite pull in rand() on their own.",
137
+ "No action required on this finding alone. If you do generate keys, tokens, IVs "
138
+ "or nonces, confirm those specific call sites use SecRandomCopyBytes (or "
139
+ "arc4random_buf), not rand()/random().")
140
+
141
+ _register("IOS-BIN-SEC-001", "Missing PIE (Position Independent Executable)", "HIGH",
142
+ "CWE-693", "MSTG-CODE-9",
143
+ "Binary is not compiled as a Position Independent Executable.",
144
+ "Enable PIE (default in Xcode; verify no -Wl,-no_pie / legacy build setting).")
145
+ _register("IOS-BIN-SEC-002", "Missing Stack Canary", "HIGH", "CWE-693", "MSTG-CODE-9",
146
+ "Native (C/Objective-C) code in this binary is compiled without stack-smashing "
147
+ "protection (___stack_chk_guard/___stack_chk_fail absent).",
148
+ "Build with -fstack-protector-all (Xcode default; verify custom build flags "
149
+ "haven't disabled it).")
150
+ _register("IOS-BIN-SEC-003", "Missing ARC (Automatic Reference Counting)", "MEDIUM",
151
+ "CWE-401", "MSTG-CODE-9",
152
+ "Objective-C code in this binary appears to be compiled without ARC "
153
+ "(objc_retain/objc_release symbols absent despite custom Objective-C classes).",
154
+ "Enable ARC (CLANG_ENABLE_OBJC_ARC = YES) for automatic memory management and "
155
+ "use-after-free prevention.")
156
+ _register("IOS-BIN-SEC-004", "Binary Missing Code Signature", "HIGH", "CWE-345",
157
+ "MSTG-CODE-9",
158
+ "No LC_CODE_SIGNATURE load command found - this binary is not code-signed.",
159
+ "Ensure the IPA is built and exported for distribution (ad-hoc/App Store) with "
160
+ "a valid signing identity; iOS refuses to run unsigned binaries on real devices.")
161
+
162
+ _register("IOS-BIN-SENSITIVE-001", "Hardcoded Credential in Binary", "HIGH", "CWE-798",
163
+ "MSTG-STORAGE-1",
164
+ "A binary string looks like a hardcoded password/API key/secret-key assignment.",
165
+ "Remove hardcoded credentials; use Keychain for anything secret at runtime, and "
166
+ "fetch API keys from a backend/config service instead of compiling them in.")
167
+ _register("IOS-BIN-SENSITIVE-002", "SQL Query Built with String Formatting", "HIGH",
168
+ "CWE-89", "MSTG-CODE-8",
169
+ "A compiled-in SQL statement contains a format-string placeholder "
170
+ "(SELECT/INSERT/UPDATE/DELETE ... %), consistent with a query assembled by "
171
+ "string interpolation rather than parameter binding.",
172
+ "Use parameterized queries / prepared statements (sqlite3_bind_*, NSPredicate, "
173
+ "or an ORM) instead of formatting SQL text.")
174
+ _register("IOS-BIN-SENSITIVE-003", "Sensitive Data Logged", "MEDIUM", "CWE-532",
175
+ "MSTG-STORAGE-3",
176
+ "A compiled-in NSLog format string references password/token/secret, "
177
+ "consistent with sensitive data being written to the device console log.",
178
+ "Remove sensitive values from log statements, or strip logging in release "
179
+ "builds.")
180
+ _register("IOS-BIN-SENSITIVE-004", "Insecure Deserialization API", "MEDIUM", "CWE-502",
181
+ "MSTG-PLATFORM-8",
182
+ "Binary references the legacy NSKeyedUnarchiver "
183
+ "unarchiveObjectWithData:/unarchiveObjectWithFile: selectors, which decode "
184
+ "without a class allowlist unless the caller has opted into secure coding.",
185
+ "Use unarchivedObjectOfClasses:fromData:error: (NSSecureCoding) instead of the "
186
+ "legacy unarchiveObjectWithData:/unarchiveObjectWithFile: APIs.")
187
+
188
+ _register("IOS-BIN-API-001", "Insecure Keychain Accessibility (Always)", "HIGH", "CWE-522",
189
+ "MSTG-STORAGE-1",
190
+ "Binary references kSecAttrAccessibleAlways, which keeps Keychain items "
191
+ "readable even when the device is locked AND lets them sync to iCloud Keychain "
192
+ "and unencrypted backups (they leave the device).",
193
+ "Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly (or "
194
+ "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly for background needs).")
195
+ _register("IOS-BIN-API-006", "Keychain Accessibility Always (Device-Bound)", "MEDIUM",
196
+ "CWE-522", "MSTG-STORAGE-1",
197
+ "Binary references kSecAttrAccessibleAlwaysThisDeviceOnly: the item is readable "
198
+ "even while the device is locked. It is device-bound (never synced to iCloud "
199
+ "Keychain, never restored to another device from a backup), so the risk is "
200
+ "materially lower than the plain kSecAttrAccessibleAlways it is often confused "
201
+ "with, but a locked/stolen device still exposes it.",
202
+ "Prefer kSecAttrAccessibleWhenUnlockedThisDeviceOnly, or "
203
+ "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly if a background task genuinely "
204
+ "needs the item before first unlock.")
205
+ _register("IOS-BIN-API-002", "Deprecated Insecure WebView (UIWebView)", "HIGH", "CWE-79",
206
+ "MSTG-PLATFORM-2",
207
+ "Binary links against the deprecated, insecure UIWebView (Apple has rejected "
208
+ "App Store submissions using it since Dec 2020).",
209
+ "Migrate to WKWebView.")
210
+ _register("IOS-BIN-API-003", "No File Protection on Written Data", "HIGH", "CWE-311",
211
+ "MSTG-STORAGE-1",
212
+ "Binary references NSDataWritingFileProtectionNone, explicitly disabling "
213
+ "iOS Data Protection encryption for the written file.",
214
+ "Use NSDataWritingFileProtectionComplete (or "
215
+ "CompleteUnlessOpen/CompleteUntilFirstUserAuthentication as appropriate).")
216
+ _register("IOS-BIN-API-004", "Deprecated Networking API (NSURLConnection)", "MEDIUM",
217
+ "CWE-477", "MSTG-NETWORK-1",
218
+ "Binary references the deprecated NSURLConnection API (deprecated iOS 9+).",
219
+ "Migrate to URLSession.")
220
+ _register("IOS-BIN-API-005", "Deprecated Low-Level SSL/TLS API", "MEDIUM", "CWE-327",
221
+ "MSTG-NETWORK-2",
222
+ "Binary references the deprecated Secure Transport SSLCreateContext API.",
223
+ "Use URLSession/Network.framework, which negotiate current TLS versions and "
224
+ "handle certificate validation correctly by default.")
225
+
226
+ _register("IOS-BIN-RASP-001", "No Runtime Application Self-Protection Detected", "MEDIUM",
227
+ "CWE-693", "MSTG-RESILIENCE-1",
228
+ "No commercial RASP SDK and no anti-tampering signal (ptrace/jailbreak/"
229
+ "Frida-detection strings) found in this binary.",
230
+ "For high-risk apps (banking, health, payments) consider a RASP SDK "
231
+ "(commercial: Zimperium/Guardsquare/Digital.ai/Promon, or open-source "
232
+ "Talsec freeRASP) plus basic ptrace/jailbreak/Frida-detection.")
233
+
234
+ _register("IOS-BIN-URL-001", "Custom URL Scheme Registered", "MEDIUM", "CWE-284",
235
+ "MSTG-PLATFORM-3",
236
+ "App registers a custom (non-system) URL scheme. Any other app can register "
237
+ "the same scheme and potentially hijack deep links.",
238
+ "Prefer Universal Links (https://) for deep linking; if a custom scheme is "
239
+ "required, validate the calling app via options[.sourceApplication].")
240
+ _register("IOS-BIN-URL-002", "Custom URL Schemes Without Universal Links", "MEDIUM",
241
+ "CWE-284", "MSTG-PLATFORM-3",
242
+ "App declares custom URL scheme(s) but its entitlements carry no "
243
+ "applinks: associated domain, so it has no Universal Links fallback.",
244
+ "Implement Universal Links (com.apple.developer.associated-domains "
245
+ "entitlement + apple-app-site-association on your domain).")
246
+ _register("IOS-BIN-URL-003", "App Transport Security Disabled", "HIGH", "CWE-319",
247
+ "MSTG-NETWORK-2",
248
+ "NSAllowsArbitraryLoads is true in Info.plist - ATS is disabled app-wide, "
249
+ "permitting cleartext HTTP to any host.",
250
+ "Remove NSAllowsArbitraryLoads (or set it false); use per-domain exceptions "
251
+ "only where a real third-party requirement forces cleartext.")
252
+ _register("IOS-BIN-URL-004", "ATS Insecure HTTP Exception for Domain", "MEDIUM",
253
+ "CWE-319", "MSTG-NETWORK-2",
254
+ "NSExceptionAllowsInsecureHTTPLoads is true for a specific domain in "
255
+ "NSAppTransportSecurity.",
256
+ "Use HTTPS for this domain; remove the exception once the backend supports TLS.")
257
+ _register("IOS-BIN-URL-005", "ATS Insecure HTTP for All Subdomains", "HIGH", "CWE-319",
258
+ "MSTG-NETWORK-2",
259
+ "NSIncludesSubdomains + NSExceptionAllowsInsecureHTTPLoads together allow "
260
+ "cleartext HTTP to every subdomain of the exception domain.",
261
+ "Scope the exception to the exact subdomain that needs it; do not combine "
262
+ "NSIncludesSubdomains with an insecure-HTTP exception.")
263
+ _register("IOS-BIN-URL-006", "Querying Sensitive App URL Schemes", "MEDIUM", "CWE-200",
264
+ "MSTG-PLATFORM-3",
265
+ "LSApplicationQueriesSchemes includes schemes suggestive of banking/health/"
266
+ "finance/auth apps, which can be used to fingerprint installed apps.",
267
+ "Only query schemes that are essential for a real integration; avoid using "
268
+ "canOpenURL as an app-presence fingerprinting signal.")
269
+
270
+
271
+ _STRINGS_BIN = shutil.which("strings")
272
+
273
+ # Pure-Python fallback for `strings -a`: runs of >=4 printable ASCII bytes.
274
+ _PRINTABLE_RUN_RE = re.compile(rb"[\x20-\x7e]{4,}")
275
+
276
+
277
+ def _get_strings(binary_path: str) -> List[str]:
278
+ """ASCII strings in the file. Never raises: returns [] on any failure."""
279
+ if _STRINGS_BIN:
280
+ try:
281
+ out = subprocess.check_output(
282
+ [_STRINGS_BIN, "-a", binary_path],
283
+ stderr=subprocess.DEVNULL, timeout=60,
284
+ )
285
+ return out.decode("utf-8", errors="ignore").split("\n")
286
+ except Exception:
287
+ pass
288
+ try:
289
+ with open(binary_path, "rb") as f:
290
+ data = f.read()
291
+ return [m.decode("ascii") for m in _PRINTABLE_RUN_RE.findall(data)]
292
+ except OSError:
293
+ return []
294
+
295
+
296
+ class ParsedMachO:
297
+ """Keeps a Mach-O slice and the parent FatBinary it was yielded from alive
298
+ together: a slice whose FatBinary is collected dangles and segfaults."""
299
+
300
+ __slots__ = ("_fat", "binary")
301
+
302
+ def __init__(self, fat, binary):
303
+ self._fat = fat
304
+ self.binary = binary
305
+
306
+
307
+ def _pick_best_slice(binary_path: str) -> Optional[ParsedMachO]:
308
+ """Return the best single-arch slice of a Mach-O, arm64 first, or None."""
309
+ if not LIEF_AVAILABLE:
310
+ return None
311
+ try:
312
+ fat = lief.MachO.parse(binary_path)
313
+ except Exception:
314
+ return None
315
+ if not fat or len(fat) == 0:
316
+ return None
317
+ best = None
318
+ best_rank = -1
319
+ for b in fat:
320
+ try:
321
+ cpu = str(b.header.cpu_type)
322
+ except Exception:
323
+ cpu = ""
324
+ rank = 3 if "ARM64" in cpu else 2 if "ARM" in cpu else 1 if "X86_64" in cpu else 0
325
+ if rank > best_rank:
326
+ best, best_rank = b, rank
327
+ if best is None:
328
+ return None
329
+ return ParsedMachO(fat, best)
330
+
331
+
332
+ def _symbol_names(parsed: Optional["ParsedMachO"]) -> List[str]:
333
+ if parsed is None:
334
+ return []
335
+ try:
336
+ return [s.name for s in parsed.binary.symbols if getattr(s, "name", None)]
337
+ except Exception:
338
+ return []
339
+
340
+
341
+ # Mach-O nlist n_type bit fields (<mach-o/nlist.h>), read off `Symbol.raw_type`:
342
+ # LIEF's `Symbol.type` enum masks N_TYPE without first stripping the N_STAB bits.
343
+ _N_STAB = 0xe0
344
+ _N_TYPE = 0x0e
345
+ _N_UNDF = 0x00
346
+
347
+
348
+ def _symbol_is_defined(sym) -> bool:
349
+ """True if this Mach-O defines `sym`, rather than importing or debug-listing it."""
350
+ raw = getattr(sym, "raw_type", None)
351
+ if raw is not None:
352
+ try:
353
+ raw = int(raw)
354
+ if raw & _N_STAB:
355
+ return False
356
+ return (raw & _N_TYPE) != _N_UNDF
357
+ except (TypeError, ValueError):
358
+ pass
359
+ try:
360
+ return not (bool(getattr(sym, "is_external", False))
361
+ and int(getattr(sym, "value", 0)) == 0)
362
+ except Exception:
363
+ return True
364
+
365
+
366
+ def _defined_symbol_names(parsed: Optional["ParsedMachO"]) -> List[str]:
367
+ if parsed is None:
368
+ return []
369
+ try:
370
+ return [s.name for s in parsed.binary.symbols
371
+ if getattr(s, "name", None) and _symbol_is_defined(s)]
372
+ except Exception:
373
+ return []
374
+
375
+
376
+ # A Swift class exposed to the ObjC runtime gets an _OBJC_CLASS_$__TtC... symbol
377
+ # emitted by the Swift compiler, which is not hand-written Objective-C.
378
+ _SWIFT_OBJC_THUNK_MARKER = "_TtC"
379
+
380
+
381
+ def _has_custom_objc_classes(defined_symbol_names: List[str]) -> bool:
382
+ skip = ("NSObject", "NSString", "NSArray", "NSDictionary", "NSNumber",
383
+ "NSData", "NSError", "NSException", "NSValue", "NSURL", "NSDate",
384
+ "NSSet", "PodsDummy_", "Swift")
385
+ return any(
386
+ "_OBJC_CLASS_$_" in n
387
+ and _SWIFT_OBJC_THUNK_MARKER not in n
388
+ and not any(s in n for s in skip)
389
+ for n in defined_symbol_names
390
+ )
391
+
392
+
393
+ def _is_swift(symbol_names: List[str]) -> bool:
394
+ return any(n.startswith("_$s") or n.startswith("$s") for n in symbol_names)
395
+
396
+
397
+ def _has_arc_symbols(symbol_names: List[str]) -> bool:
398
+ return any(n in symbol_names for n in
399
+ ("_objc_release", "_objc_retain", "_objc_autoreleaseReturnValue"))
400
+
401
+
402
+ def _has_canary_symbols(symbol_names: List[str]) -> bool:
403
+ return any(n in symbol_names for n in ("___stack_chk_fail", "___stack_chk_guard"))
404
+
405
+
406
+ _WEAK_CRYPTO_ALGOS = {
407
+ "kCCAlgorithmDES": ("DES", "HIGH"), "CCAlgorithmDES": ("DES", "HIGH"),
408
+ "kCCAlgorithm3DES": ("3DES", "MEDIUM"), "CCAlgorithm3DES": ("3DES", "MEDIUM"),
409
+ "kCCAlgorithmRC2": ("RC2", "HIGH"),
410
+ "kCCAlgorithmRC4": ("RC4", "HIGH"),
411
+ "kCCOptionECBMode": ("ECB Mode", "HIGH"), "kCCModeECB": ("ECB Mode", "HIGH"),
412
+ }
413
+ _WEAK_HASH_ALGOS = {
414
+ "CC_MD2": ("MD2", "HIGH"), "CC_MD4": ("MD4", "HIGH"), "CC_MD5": ("MD5", "HIGH"),
415
+ "kCCHmacAlgMD5": ("HMAC-MD5", "HIGH"),
416
+ "CC_SHA1": ("SHA1", "MEDIUM"), "kCCHmacAlgSHA1": ("HMAC-SHA1", "MEDIUM"),
417
+ }
418
+ _INSECURE_RANDOM_SYMBOLS = ("_srand", "_random", "_rand")
419
+
420
+
421
+ def _check_crypto(corpus_blob: str, symbol_names: List[str], file_path: str,
422
+ findings: List[Dict[str, Any]]) -> None:
423
+ # Match against the joined symbol text, not exact token membership.
424
+ seen_algo = set()
425
+ for token, (algo_name, sev) in _WEAK_CRYPTO_ALGOS.items():
426
+ if token in corpus_blob and algo_name not in seen_algo:
427
+ seen_algo.add(algo_name)
428
+ findings.append(_finding(
429
+ "IOS-BIN-CRYPTO-001", file_path, severity=sev,
430
+ description=f"Binary references weak cryptographic algorithm: {algo_name} "
431
+ f"(symbol/constant: {token}).",
432
+ location_symbol=token))
433
+ seen_hash = set()
434
+ for token, (algo_name, sev) in _WEAK_HASH_ALGOS.items():
435
+ if token in corpus_blob and algo_name not in seen_hash:
436
+ seen_hash.add(algo_name)
437
+ findings.append(_finding(
438
+ "IOS-BIN-CRYPTO-002", file_path, severity=sev,
439
+ description=f"Binary references weak hash algorithm: {algo_name} "
440
+ f"(symbol/constant: {token}).",
441
+ location_symbol=token))
442
+ uses_crypto_api = any(t in corpus_blob for t in
443
+ ("CCCrypt", "SecKeyEncrypt", "kCCAlgorithmAES", "CC_SHA256"))
444
+ if uses_crypto_api:
445
+ for sym in _INSECURE_RANDOM_SYMBOLS:
446
+ if sym in symbol_names:
447
+ has_csprng = any(t in corpus_blob for t in
448
+ ("_arc4random", "arc4random_buf", "SecRandomCopyBytes"))
449
+ csprng_note = (
450
+ " This binary already links a CSPRNG (arc4random/SecRandomCopyBytes), "
451
+ "so the security-sensitive paths are most likely already correct."
452
+ if has_csprng else "")
453
+ findings.append(_finding(
454
+ "IOS-BIN-CRYPTO-003", file_path,
455
+ description=f"Binary links the non-cryptographic RNG API "
456
+ f"{sym.lstrip('_')}() and also links cryptographic APIs. "
457
+ f"Informational only - no evidence of insecure key/nonce "
458
+ f"generation: this is symbol co-presence with no call-site "
459
+ f"link, and statically linked zlib/ffmpeg/sqlite import "
460
+ f"rand() by themselves.{csprng_note}",
461
+ location_symbol=sym))
462
+ break
463
+
464
+
465
+ def _check_macho_security(parsed: "ParsedMachO", symbol_names: List[str], file_path: str,
466
+ is_main_binary: bool, findings: List[Dict[str, Any]]) -> None:
467
+ binary = parsed.binary
468
+ defined_names = _defined_symbol_names(parsed)
469
+ # PIE is only meaningful for the main executable: dylibs are always
470
+ # position-independent by construction.
471
+ if is_main_binary:
472
+ try:
473
+ pie = bool(binary.is_pie)
474
+ except Exception:
475
+ pie = True
476
+ if not pie:
477
+ findings.append(_finding("IOS-BIN-SEC-001", file_path,
478
+ location_symbol="Mach-O header (MH_PIE flag)"))
479
+
480
+ is_swift = _is_swift(symbol_names)
481
+ has_objc = _has_custom_objc_classes(defined_names)
482
+ is_swift_only = is_swift and not has_objc
483
+
484
+ # A fully stripped binary exposes no symbols at all, which is the absence of
485
+ # any signal, not evidence that a canary or ARC is missing.
486
+ has_any_symbols = bool(symbol_names)
487
+ # Stack canary and ARC only apply to native C/Obj-C code; Swift is memory-safe.
488
+ if has_any_symbols and not is_swift_only:
489
+ if not _has_canary_symbols(symbol_names):
490
+ # clang emits a canary only for functions owning a stack buffer, so a
491
+ # small framework can legitimately have none.
492
+ findings.append(_finding(
493
+ "IOS-BIN-SEC-002", file_path,
494
+ severity=None if is_main_binary else "INFO",
495
+ description=None if is_main_binary else (
496
+ "No ___stack_chk_guard/___stack_chk_fail reference in this embedded "
497
+ "framework. INFORMATIONAL ONLY: clang emits a stack canary only for "
498
+ "functions that own a stack buffer, so a small module can legitimately "
499
+ "have none while stack protection is enabled. This is not evidence "
500
+ "-fstack-protector was disabled - confirm against the module's build "
501
+ "settings before acting on it."),
502
+ location_symbol="symbol table (___stack_chk_fail/___stack_chk_guard absent)"))
503
+ if has_objc and not _has_arc_symbols(symbol_names):
504
+ findings.append(_finding(
505
+ "IOS-BIN-SEC-003", file_path,
506
+ location_symbol="symbol table (objc_retain/objc_release absent)"))
507
+
508
+ try:
509
+ signed = bool(binary.has_code_signature)
510
+ except Exception:
511
+ signed = True
512
+ if not signed:
513
+ findings.append(_finding(
514
+ "IOS-BIN-SEC-004", file_path,
515
+ location_symbol="code signature (LC_CODE_SIGNATURE load command absent)"))
516
+
517
+
518
+ _CRED_FP_MARKERS = (
519
+ "%@", "%s", "%d", "%ld", "%lld", "%lu", "%u", "%i", "%f", "{0}", "{1}",
520
+ "${", "$(", "<key>", "<string>", "placeholder",
521
+ "x-api-key", "authorization:", "content-type",
522
+ )
523
+ _CRED_FP_VALUES = {
524
+ "true", "false", "nil", "null", "none", "undefined", "value", "string",
525
+ "header", "token", "apikey", "secretkey", "password", "username",
526
+ "yes", "no", "example", "<value>",
527
+ }
528
+ _CRED_VALUE_RE = re.compile(
529
+ r"(?i)(?:api[_-]?key|secret[_-]?key|password|pwd|passwd)"
530
+ r"\s*[=:\"']\s*([A-Za-z0-9+/=._\-]{8,})"
531
+ )
532
+ # Display only: which keyword matched. Detection stays with _CRED_VALUE_RE.
533
+ _CRED_KEYWORD_RE = re.compile(r"(?i)(api[_-]?key|secret[_-]?key|password|pwd|passwd)")
534
+
535
+
536
+ def _looks_like_real_secret_assignment(line: str) -> Optional[str]:
537
+ """Return the assigned value, or None: a bare keyword is not a secret."""
538
+ low = line.lower().strip()
539
+ if any(m in low for m in _CRED_FP_MARKERS):
540
+ return None
541
+ m = _CRED_VALUE_RE.search(line)
542
+ if not m:
543
+ return None
544
+ val = m.group(1)
545
+ if val.lower() in _CRED_FP_VALUES:
546
+ return None
547
+ # Reject Obj-C selector fragments ("keyword:value:moreKeyword:"), where the
548
+ # captured value is immediately followed by another colon.
549
+ if m.end() < len(line) and line[m.end()] == ":":
550
+ return None
551
+ if any(c.isdigit() for c in val) or len(val) >= 16:
552
+ return val
553
+ return None
554
+
555
+
556
+ # `%` is also SQL's LIKE wildcard, so only a real conversion specifier outside a
557
+ # single-quoted literal counts as interpolation.
558
+ _SQL_FORMAT_SPEC = (
559
+ r"%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?(?:hh|h|ll|l|z|j|t|L|q)?[@dioufFeEgGxXsScCp]"
560
+ )
561
+ _SQL_FORMAT_SPEC_RE = re.compile(_SQL_FORMAT_SPEC)
562
+ _SQL_SINGLE_QUOTED_LITERAL_RE = re.compile(r"'[^']*'")
563
+
564
+ _SQL_FORMAT_RE = re.compile(
565
+ r"(?i)(?:SELECT\s+.+\s+FROM\s+.+\s+WHERE\s+.+" + _SQL_FORMAT_SPEC + r"|"
566
+ r"INSERT\s+INTO\s+.+\s+VALUES\s*\(.+" + _SQL_FORMAT_SPEC + r"|"
567
+ r"UPDATE\s+.+\s+SET\s+.+\s+WHERE\s+.+" + _SQL_FORMAT_SPEC + r"|"
568
+ r"DELETE\s+FROM\s+.+\s+WHERE\s+.+" + _SQL_FORMAT_SPEC + r")"
569
+ )
570
+
571
+
572
+ def _blank_sql_like_wildcard_literals(line: str) -> str:
573
+ def _repl(m: "re.Match") -> str:
574
+ body = m.group(0)
575
+ if "%" not in body:
576
+ return body
577
+ # Whatever stays percent-shaped after stripping real specifiers is a
578
+ # LIKE wildcard, making the whole literal a pattern rather than a slot.
579
+ residue = _SQL_FORMAT_SPEC_RE.sub("", body)
580
+ return "''" if "%" in residue else body
581
+ return _SQL_SINGLE_QUOTED_LITERAL_RE.sub(_repl, line)
582
+
583
+
584
+ def _sql_built_by_string_formatting(line: str) -> bool:
585
+ if "%" not in line:
586
+ return False
587
+ return bool(_SQL_FORMAT_RE.search(_blank_sql_like_wildcard_literals(line)))
588
+ _NSLOG_SENSITIVE_RE = re.compile(r"(?i)NSLog.{0,60}(?:password|token|secret)")
589
+ _DESERIALIZATION_RE = re.compile(r"unarchiveObjectWith(?:Data|File)")
590
+
591
+
592
+ def _check_sensitive_strings(strings_list: List[str], file_path: str,
593
+ findings: List[Dict[str, Any]]) -> None:
594
+ seen_cred = False
595
+ seen_sql = False
596
+ seen_log = False
597
+ seen_deser = False
598
+ for line in strings_list:
599
+ if not seen_cred and ("password" in line.lower() or "api" in line.lower()
600
+ or "secret" in line.lower()):
601
+ val = _looks_like_real_secret_assignment(line)
602
+ if val:
603
+ seen_cred = True
604
+ kw_match = _CRED_KEYWORD_RE.search(line)
605
+ kw = kw_match.group(1) if kw_match else "credential"
606
+ findings.append(_finding(
607
+ "IOS-BIN-SENSITIVE-001", file_path,
608
+ description=f"Hardcoded credential-like assignment found in binary "
609
+ f"strings: {line.strip()[:120]!r}.",
610
+ location_symbol=f"string constant (matched keyword: {kw})"))
611
+ if not seen_sql and _sql_built_by_string_formatting(line):
612
+ seen_sql = True
613
+ findings.append(_finding(
614
+ "IOS-BIN-SENSITIVE-002", file_path,
615
+ description=f"SQL statement with a format-string placeholder found in "
616
+ f"binary strings: {line.strip()[:150]!r}.",
617
+ location_symbol="string constant (SQL statement)"))
618
+ if not seen_log and _NSLOG_SENSITIVE_RE.search(line):
619
+ seen_log = True
620
+ findings.append(_finding(
621
+ "IOS-BIN-SENSITIVE-003", file_path,
622
+ description=f"Log format string references sensitive data: "
623
+ f"{line.strip()[:150]!r}.",
624
+ location_symbol="string constant (NSLog format string)"))
625
+ if not seen_deser and _DESERIALIZATION_RE.search(line):
626
+ seen_deser = True
627
+ deser_match = _DESERIALIZATION_RE.search(line)
628
+ findings.append(_finding(
629
+ "IOS-BIN-SENSITIVE-004", file_path,
630
+ location_symbol=f"selector: {deser_match.group(0)}"))
631
+ if seen_cred and seen_sql and seen_log and seen_deser:
632
+ break
633
+
634
+
635
+ # The bare name also shows up in unrelated allowlists. These linkage markers
636
+ # identify UIWebView usage via the binary's linkage/metadata sections.
637
+ _UIWEBVIEW_LINKAGE_MARKERS = (
638
+ "_OBJC_CLASS_$_UIWebView", "_OBJC_METACLASS_$_UIWebView",
639
+ '@"UIWebView"', "So9UIWebView", "UIWebViewDelegate",
640
+ )
641
+
642
+ _DANGEROUS_API_STRING_CHECKS = (
643
+ ("NSDataWritingFileProtectionNone", "IOS-BIN-API-003"),
644
+ ("NSURLConnection", "IOS-BIN-API-004"),
645
+ ("SSLCreateContext", "IOS-BIN-API-005"),
646
+ )
647
+
648
+ # The lookahead stops the bare constant matching inside the materially different
649
+ # kSecAttrAccessibleAlwaysThisDeviceOnly.
650
+ _KEYCHAIN_ALWAYS_BARE_RE = re.compile(r"kSecAttrAccessibleAlways(?!ThisDeviceOnly)")
651
+ _KEYCHAIN_ALWAYS_DEVICE_ONLY_RE = re.compile(r"kSecAttrAccessibleAlwaysThisDeviceOnly")
652
+
653
+
654
+ def _check_keychain_accessibility(corpus_blob: str, file_path: str,
655
+ findings: List[Dict[str, Any]]) -> None:
656
+ if _KEYCHAIN_ALWAYS_BARE_RE.search(corpus_blob):
657
+ findings.append(_finding(
658
+ "IOS-BIN-API-001", file_path,
659
+ description="Binary references kSecAttrAccessibleAlways (the bare constant, "
660
+ "not ...ThisDeviceOnly): Keychain items stay readable while the "
661
+ "device is locked AND are eligible for iCloud Keychain sync and "
662
+ "unencrypted backup restore onto another device.",
663
+ location_symbol="kSecAttrAccessibleAlways"))
664
+ if _KEYCHAIN_ALWAYS_DEVICE_ONLY_RE.search(corpus_blob):
665
+ findings.append(_finding(
666
+ "IOS-BIN-API-006", file_path,
667
+ description="Binary references kSecAttrAccessibleAlwaysThisDeviceOnly: the "
668
+ "Keychain item is readable while the device is locked, but it is "
669
+ "device-bound (no iCloud Keychain sync, not restorable to another "
670
+ "device), so the exfiltration risk of the plain "
671
+ "kSecAttrAccessibleAlways does not apply here.",
672
+ location_symbol="kSecAttrAccessibleAlwaysThisDeviceOnly"))
673
+
674
+
675
+ def _check_dangerous_apis(corpus_blob: str, file_path: str,
676
+ findings: List[Dict[str, Any]]) -> None:
677
+ uiwebview_hit = next((m for m in _UIWEBVIEW_LINKAGE_MARKERS if m in corpus_blob), None)
678
+ if uiwebview_hit:
679
+ findings.append(_finding("IOS-BIN-API-002", file_path,
680
+ location_symbol=f"symbol/string: {uiwebview_hit}"))
681
+ for token, rule_id in _DANGEROUS_API_STRING_CHECKS:
682
+ if token in corpus_blob:
683
+ findings.append(_finding(rule_id, file_path, location_symbol=token))
684
+ _check_keychain_accessibility(corpus_blob, file_path, findings)
685
+
686
+
687
+ _RASP_SIGNATURES = {
688
+ "Guardsquare iXGuard": ("ixguard", "iXGuard", "GuardsquareSDK", "dexguard", "DexGuard"),
689
+ "Zimperium zShield": ("zimperium", "Zimperium", "zShield", "zIPS", "ZDetection"),
690
+ "Digital.ai (Arxan)": ("arxan", "Arxan", "digital_ai", "TransformIT"),
691
+ "Promon SHIELD": ("promon", "Promon", "PromonShield"),
692
+ "Appdome": ("appdome", "Appdome", "APPDOME_"),
693
+ "Talsec freeRASP": ("talsec", "Talsec", "freeRASP", "FreeRasp", "TalsecSecurity"),
694
+ }
695
+ # Anti-tamper / anti-debug / jailbreak-detection indicator strings.
696
+ _ANTI_TAMPER_PATTERNS = (
697
+ "ptrace", "PT_DENY_ATTACH", "P_TRACED", "isDebugged", "debugger_attached",
698
+ "frida", "substrate", "cycript", "objection", "SSLKillSwitch", "trustkit",
699
+ "jailbroken", "jailbreak", "Cydia", "MobileSubstrate", "libhooker",
700
+ "/var/jb", "DYLD_INSERT",
701
+ )
702
+
703
+ # Unambiguous on their own, so one hit is enough where the generic list needs two.
704
+ _STRONG_ANTI_TAMPER_PATTERNS = (
705
+ "jailbroken", "jailbreak", "Cydia", "MobileSubstrate", "libhooker", "/var/jb",
706
+ )
707
+
708
+ # Matching is case-insensitive: the real symbols are CamelCase identifiers.
709
+
710
+ def _check_rasp(strings_list: List[str], symbol_names: List[str], file_path: str,
711
+ findings: List[Dict[str, Any]], notes: List[str],
712
+ encrypted: bool = False) -> None:
713
+ corpus = " ".join(strings_list) + " " + " ".join(symbol_names)
714
+ corpus_lower = corpus.lower()
715
+ detected_libs = []
716
+ for lib_name, sigs in _RASP_SIGNATURES.items():
717
+ if any(s in corpus for s in sigs):
718
+ detected_libs.append(lib_name)
719
+ anti_tamper_hits = [p for p in _ANTI_TAMPER_PATTERNS if p.lower() in corpus_lower]
720
+ strong_hits = [p for p in _STRONG_ANTI_TAMPER_PATTERNS if p.lower() in corpus_lower]
721
+
722
+ if detected_libs:
723
+ notes.append(f"RASP/anti-tampering SDK detected ({', '.join(detected_libs)}) - "
724
+ f"informational, no finding raised.")
725
+ elif strong_hits or len(anti_tamper_hits) >= 2:
726
+ notes.append(f"Custom anti-tampering signals present ({len(anti_tamper_hits)} "
727
+ f"patterns: {', '.join(anti_tamper_hits[:5])}) - informational, "
728
+ f"no finding raised.")
729
+ elif encrypted:
730
+ notes.append(
731
+ "RASP/anti-tampering: UNKNOWN, not assessed - this binary is FairPlay-encrypted "
732
+ "so its own strings are unreadable and this check cannot assess it. No finding "
733
+ "raised (an absence of evidence here is not evidence of absence).")
734
+ else:
735
+ findings.append(_finding(
736
+ "IOS-BIN-RASP-001", file_path,
737
+ location_symbol="binary-wide string/symbol scan (no signal found)"))
738
+
739
+
740
+ # com.apple.developer.associated-domains is an entitlements key, never an
741
+ # Info.plist key, so Universal Links can only be read out of the signature blob.
742
+ _CSMAGIC_EMBEDDED_ENTITLEMENTS = b"\xfa\xde\x71\x71"
743
+ _MAX_ENTITLEMENTS_BLOB = 1_000_000
744
+
745
+
746
+ def extract_entitlements_from_binary(binary_path: str) -> Dict[str, Any]:
747
+ """Read entitlements out of a Mach-O code signature. {} means unreadable."""
748
+ try:
749
+ with open(binary_path, "rb") as f:
750
+ data = f.read()
751
+ except OSError:
752
+ return {}
753
+ offset = 0
754
+ while True:
755
+ index = data.find(_CSMAGIC_EMBEDDED_ENTITLEMENTS, offset)
756
+ if index < 0:
757
+ return {}
758
+ offset = index + 4
759
+ try:
760
+ length = struct.unpack(">I", data[index + 4:index + 8])[0]
761
+ except struct.error:
762
+ continue
763
+ if not (8 < length < _MAX_ENTITLEMENTS_BLOB):
764
+ continue
765
+ blob = data[index + 8:index + length]
766
+ if b"<plist" not in blob:
767
+ continue
768
+ try:
769
+ parsed = plistlib.loads(blob)
770
+ except Exception:
771
+ continue
772
+ if isinstance(parsed, dict):
773
+ return parsed
774
+ return {}
775
+
776
+
777
+ # Apple back-deployment dylibs bundled directly under Frameworks/, matched by
778
+ # filename convention rather than by the ObjC/Swift class-prefix convention.
779
+ _APPLE_REDIST_DYLIB_PREFIXES = ("libswift", "libSystem", "libobjc", "libc++", "libz.",
780
+ "libsqlite3", "libicucore", "libnetwork", "libDER")
781
+
782
+ _SYSTEM_URL_SCHEMES = {"http", "https", "mailto", "tel", "sms", "facetime", "facetime-audio"}
783
+ _SENSITIVE_SCHEME_MARKERS = ("bank", "health", "medical", "finance", "password", "auth")
784
+
785
+
786
+ def _check_url_schemes(plist: Dict[str, Any], entitlements: Optional[Dict[str, Any]],
787
+ findings: List[Dict[str, Any]]) -> None:
788
+ file_path = "Info.plist"
789
+ url_types = plist.get("CFBundleURLTypes") or []
790
+ custom_schemes = []
791
+ for ut in url_types:
792
+ for scheme in ut.get("CFBundleURLSchemes") or []:
793
+ if isinstance(scheme, str) and scheme.lower() not in _SYSTEM_URL_SCHEMES:
794
+ custom_schemes.append(scheme)
795
+ if custom_schemes:
796
+ findings.append(_finding(
797
+ "IOS-BIN-URL-001", file_path,
798
+ description=f"Custom URL scheme(s) registered: {', '.join(custom_schemes)}.",
799
+ location_symbol="Info.plist key: CFBundleURLTypes/CFBundleURLSchemes"))
800
+
801
+ if custom_schemes and entitlements is not None:
802
+ domains = entitlements.get("com.apple.developer.associated-domains") or []
803
+ has_universal_links = any(
804
+ isinstance(d, str) and d.startswith("applinks:") for d in domains)
805
+ if not has_universal_links:
806
+ findings.append(_finding(
807
+ "IOS-BIN-URL-002", file_path,
808
+ location_symbol="entitlement: com.apple.developer.associated-domains (absent)"))
809
+
810
+ query_schemes = plist.get("LSApplicationQueriesSchemes") or []
811
+ sensitive = [s for s in query_schemes if isinstance(s, str)
812
+ and any(m in s.lower() for m in _SENSITIVE_SCHEME_MARKERS)]
813
+ if sensitive:
814
+ findings.append(_finding(
815
+ "IOS-BIN-URL-006", file_path,
816
+ description=f"LSApplicationQueriesSchemes includes schemes suggestive of "
817
+ f"sensitive apps: {', '.join(sensitive[:5])}.",
818
+ location_symbol="Info.plist key: LSApplicationQueriesSchemes"))
819
+
820
+ ats = plist.get("NSAppTransportSecurity") or {}
821
+ if ats.get("NSAllowsArbitraryLoads"):
822
+ findings.append(_finding(
823
+ "IOS-BIN-URL-003", file_path,
824
+ location_symbol="Info.plist key: NSAppTransportSecurity/NSAllowsArbitraryLoads"))
825
+ for domain, cfg in (ats.get("NSExceptionDomains") or {}).items():
826
+ if not isinstance(cfg, dict):
827
+ continue
828
+ insecure = bool(cfg.get("NSExceptionAllowsInsecureHTTPLoads"))
829
+ if insecure and cfg.get("NSIncludesSubdomains"):
830
+ findings.append(_finding(
831
+ "IOS-BIN-URL-005", file_path,
832
+ description=f"Insecure HTTP allowed for all subdomains of {domain!r} "
833
+ f"(NSIncludesSubdomains + NSExceptionAllowsInsecureHTTPLoads).",
834
+ location_symbol=f"Info.plist key: NSExceptionDomains.{domain}"))
835
+ elif insecure:
836
+ findings.append(_finding(
837
+ "IOS-BIN-URL-004", file_path,
838
+ description=f"Insecure HTTP allowed for domain {domain!r} "
839
+ f"(NSExceptionAllowsInsecureHTTPLoads).",
840
+ location_symbol=f"Info.plist key: NSExceptionDomains.{domain}"))
841
+
842
+
843
+ def _detect_cross_platform_framework(all_names: List[str], notes: List[str]) -> None:
844
+ lower_names = [n.lower() for n in all_names]
845
+ if any("hermes.framework" in n or "libhermes" in n or "react.framework" in n
846
+ or "main.jsbundle" in n for n in lower_names):
847
+ notes.append("React Native runtime detected (Hermes/React.framework/"
848
+ "main.jsbundle) - informational, not itself a vulnerability. "
849
+ "JS-bundle-level analysis is out of scope for binary-mode SAST.")
850
+ elif any("flutter.framework" in n or "app.framework" in n and "flutter" in n
851
+ for n in lower_names):
852
+ notes.append("Flutter runtime detected (Flutter.framework) - informational, not "
853
+ "itself a vulnerability. Dart AOT code is opaque to Mach-O-level "
854
+ "static analysis.")
855
+ elif any("cordova" in n or ("www" in n and n.endswith("/index.html")) for n in lower_names):
856
+ notes.append("Cordova/PhoneGap hybrid runtime detected - informational, not itself "
857
+ "a vulnerability. HTML/JS bundle content is out of scope for "
858
+ "binary-mode SAST.")
859
+
860
+
861
+ def _analyze_one_binary(binary_path: str, display_path: str, is_main_binary: bool
862
+ ) -> List[Dict[str, Any]]:
863
+ findings: List[Dict[str, Any]] = []
864
+ parsed = _pick_best_slice(binary_path)
865
+ symbol_names = _symbol_names(parsed)
866
+ strings_list = _get_strings(binary_path)
867
+ corpus_blob = "\n".join(strings_list) + "\n" + "\n".join(symbol_names)
868
+
869
+ if parsed is not None:
870
+ _check_macho_security(parsed, symbol_names, display_path, is_main_binary, findings)
871
+ _check_crypto(corpus_blob, symbol_names, display_path, findings)
872
+ _check_sensitive_strings(strings_list, display_path, findings)
873
+ _check_dangerous_apis(corpus_blob, display_path, findings)
874
+ return findings
875
+
876
+
877
+ def _read_framework_bundle_id(zf: zipfile.ZipFile, framework_dir_member: str) -> Optional[str]:
878
+ plist_member = framework_dir_member.rstrip("/") + "/Info.plist"
879
+ try:
880
+ data = zf.read(plist_member)
881
+ except KeyError:
882
+ return None
883
+ try:
884
+ pl = plistlib.loads(data)
885
+ except Exception:
886
+ return None
887
+ return get_own_bundle_id(pl)
888
+
889
+
890
+ _UNCONFIRMED_ORIGIN_SUMMARY = (
891
+ "this framework's origin (first-party vs third-party) could not be "
892
+ "automatically confirmed - verify before treating this as the app "
893
+ "developer's own code"
894
+ )
895
+
896
+
897
+ def _tag_unconfirmed_origin(findings: List[Dict[str, Any]], reason: str) -> None:
898
+ """Tag, never suppress, every finding from an unconfirmed-origin framework."""
899
+ for f in findings:
900
+ f["name"] = f"[Unconfirmed Origin] {f['name']}"
901
+ f["origin_uncertain"] = True
902
+ f["origin_note"] = f"{_UNCONFIRMED_ORIGIN_SUMMARY} ({reason})."
903
+ base_desc = f["details"].get("description", "")
904
+ f["details"]["description"] = (
905
+ f"{base_desc} NOTE: {_UNCONFIRMED_ORIGIN_SUMMARY} ({reason})."
906
+ ).strip()
907
+
908
+
909
+ def _dedupe_findings(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
910
+ seen = set()
911
+ out = []
912
+ for f in findings:
913
+ key = (f["rule_id"], f["file_path"], f["details"].get("description"))
914
+ if key in seen:
915
+ continue
916
+ seen.add(key)
917
+ out.append(f)
918
+ return out
919
+
920
+
921
+ def analyze_ipa(ipa_path: str, override_config: Optional[ScopeConfig] = None) -> Dict[str, Any]:
922
+ """Analyze a single .ipa file. override_config is consulted before the
923
+ automatic first-party/third-party classification of every framework."""
924
+ result: Dict[str, Any] = {
925
+ "ok": False,
926
+ "error": None,
927
+ "encrypted": False,
928
+ "encrypted_message": None,
929
+ "findings": [],
930
+ "rule_defs": all_rule_defs(),
931
+ "icdump_used": False,
932
+ "notes": [],
933
+ }
934
+
935
+ if not LIEF_AVAILABLE:
936
+ result["error"] = (
937
+ "The `lief` package is required for iOS binary-mode scanning but is not "
938
+ "installed. Run `pip install lief` (or reinstall narvy, which "
939
+ "declares it as a dependency)."
940
+ )
941
+ return result
942
+
943
+ if not os.path.isfile(ipa_path):
944
+ result["error"] = f"File not found: {ipa_path}"
945
+ return result
946
+
947
+ try:
948
+ zf = zipfile.ZipFile(ipa_path, "r")
949
+ except zipfile.BadZipFile as e:
950
+ result["error"] = f"Could not open {ipa_path} as a zip archive: {e}. Not a valid IPA?"
951
+ return result
952
+
953
+ try:
954
+ with zf:
955
+ names = zf.namelist()
956
+ info_plist_candidates = [
957
+ n for n in names
958
+ if n.endswith(".app/Info.plist") and n.count("/") == 2
959
+ and n.split("/", 1)[0].lower() == "payload"
960
+ ]
961
+ if not info_plist_candidates:
962
+ # Some re-zips do not keep a clean Payload/ root.
963
+ info_plist_candidates = sorted(
964
+ (n for n in names if n.endswith(".app/Info.plist")),
965
+ key=lambda n: n.count("/"))
966
+ if not info_plist_candidates:
967
+ result["error"] = (
968
+ f"No Payload/*.app/Info.plist found inside {ipa_path} - this doesn't "
969
+ f"look like a valid IPA (expected a Payload/ directory with exactly "
970
+ f"one .app bundle)."
971
+ )
972
+ return result
973
+
974
+ info_plist_member = info_plist_candidates[0]
975
+ app_dir_member = info_plist_member.rsplit("/Info.plist", 1)[0]
976
+
977
+ try:
978
+ plist = plistlib.loads(zf.read(info_plist_member))
979
+ except Exception as e:
980
+ result["error"] = f"Could not parse Info.plist inside {ipa_path}: {e}"
981
+ return result
982
+
983
+ app_bundle_id = get_own_bundle_id(plist)
984
+
985
+ exe_name = plist.get("CFBundleExecutable")
986
+ if not exe_name:
987
+ result["error"] = (
988
+ f"Info.plist inside {ipa_path} has no CFBundleExecutable key - cannot "
989
+ f"locate the main binary."
990
+ )
991
+ return result
992
+
993
+ main_binary_member = f"{app_dir_member}/{exe_name}"
994
+ if main_binary_member not in names:
995
+ result["error"] = (
996
+ f"Info.plist declares CFBundleExecutable={exe_name!r} but "
997
+ f"{main_binary_member} is not present in the archive."
998
+ )
999
+ return result
1000
+
1001
+ frameworks_prefix = app_dir_member + "/Frameworks/"
1002
+ framework_members = [n for n in names if n.startswith(frameworks_prefix)]
1003
+
1004
+ import tempfile
1005
+ with tempfile.TemporaryDirectory(prefix="narvy-ios-") as tmpdir:
1006
+ main_binary_path = _safe_extract_member(zf, main_binary_member, tmpdir)
1007
+ if main_binary_path is None:
1008
+ result["error"] = f"Failed to extract main binary {main_binary_member} " \
1009
+ f"from {ipa_path} (zip-bomb guard or I/O error)."
1010
+ return result
1011
+
1012
+ main_parsed = _pick_best_slice(main_binary_path)
1013
+ encrypted = False
1014
+ if main_parsed is not None:
1015
+ try:
1016
+ b = main_parsed.binary
1017
+ if b.has_encryption_info and b.encryption_info.crypt_id == 1:
1018
+ encrypted = True
1019
+ except Exception:
1020
+ pass
1021
+ result["encrypted"] = encrypted
1022
+ if encrypted:
1023
+ result["encrypted_message"] = (
1024
+ "This IPA is FairPlay-encrypted (Apple's App Store DRM, cryptid=1): "
1025
+ "the executable __TEXT code is ciphertext until the device that "
1026
+ "purchased/installed it decrypts it at launch time using a key Apple "
1027
+ "grants only to that device - no static analysis tool, Narvy "
1028
+ "included, can read the actual instruction stream of an encrypted "
1029
+ "App Store export. This is a physical limitation of the distribution "
1030
+ "format, not a gap in this scanner. What still works and was still "
1031
+ "run: Info.plist / entitlements / URL-scheme / ATS analysis (always "
1032
+ "cleartext), linked-framework and embedded-framework-name listing "
1033
+ "(load commands are never encrypted), and Objective-C class/method "
1034
+ "metadata recovery (this metadata typically lives outside the "
1035
+ "encrypted __TEXT range). What is unreliable: weak-"
1036
+ "crypto/hardcoded-secret/dangerous-API string matching against the "
1037
+ "app's OWN literal string constants, since those live inside the "
1038
+ "encrypted region and read as ciphertext noise - expect a near-empty "
1039
+ "result for those specific checks, not a false 'clean bill of "
1040
+ "health'. The ONLY way to read that encrypted code is a decrypted "
1041
+ "binary (a jailbroken-device dump). `--upload` does NOT decrypt "
1042
+ "anything: the same encrypted binary is analysed, not decrypted."
1043
+ )
1044
+
1045
+ findings = _analyze_one_binary(main_binary_path, exe_name, is_main_binary=True)
1046
+
1047
+ entitlements = extract_entitlements_from_binary(main_binary_path)
1048
+ if not entitlements:
1049
+ entitlements = None # unknown, not "no entitlements"
1050
+ _check_url_schemes(plist, entitlements, findings)
1051
+
1052
+ arch = "Unknown"
1053
+ linked_frameworks: List[str] = []
1054
+ if main_parsed is not None:
1055
+ b = main_parsed.binary
1056
+ try:
1057
+ arch = str(b.header.cpu_type).replace("CPU_TYPE.", "")
1058
+ except Exception:
1059
+ pass
1060
+ try:
1061
+ linked_frameworks = sorted({
1062
+ re.search(r"/([^/]+)\.framework/", lib.name).group(1)
1063
+ for lib in b.libraries
1064
+ if lib.name and "/System/Library/Frameworks/" in lib.name
1065
+ and re.search(r"/([^/]+)\.framework/", lib.name)
1066
+ })
1067
+ except Exception:
1068
+ pass
1069
+ result["notes"].append(
1070
+ f"Main binary: {exe_name} (arch: {arch}, "
1071
+ f"{len(linked_frameworks)} system frameworks linked).")
1072
+
1073
+ icdump_used = False
1074
+ if ICDUMP_AVAILABLE:
1075
+ ok_icdump, n_classes, n_methods, icdump_err = _icdump_class_count(main_binary_path)
1076
+ if ok_icdump:
1077
+ result["notes"].append(
1078
+ f"icdump recovered {n_classes} Objective-C classes / "
1079
+ f"{n_methods} methods from the main binary.")
1080
+ icdump_used = True
1081
+ else:
1082
+ result["notes"].append(
1083
+ f"icdump failed to parse the main binary ({icdump_err}) - "
1084
+ f"continuing with LIEF symbol-table-only Obj-C detection for "
1085
+ f"this binary.")
1086
+ else:
1087
+ result["notes"].append(
1088
+ "icdump not installed - full Obj-C class/method/property "
1089
+ "introspection needs Linux/macOS/WSL "
1090
+ "(pip install narvy[ios-full]). Falling back to binary "
1091
+ "symbol-table scanning only."
1092
+ )
1093
+ result["icdump_used"] = icdump_used
1094
+
1095
+ # Apple's two packaging shapes for an embedded dependency: a bare
1096
+ # "X.dylib" file, or an "X.framework/" directory.
1097
+ fw_entries: Dict[str, Tuple[str, str]] = {}
1098
+ for m in framework_members:
1099
+ rel = m[len(frameworks_prefix):]
1100
+ top = rel.split("/", 1)[0]
1101
+ if not top or top in fw_entries:
1102
+ continue
1103
+ if top.endswith(".dylib") and "/" not in rel:
1104
+ fw_entries[top] = ("dylib", m)
1105
+ elif top.endswith(".framework"):
1106
+ fw_stripped = top[: -len(".framework")]
1107
+ fw_entries[top] = ("framework", f"{frameworks_prefix}{top}/{fw_stripped}")
1108
+
1109
+ analyzed_fw = 0
1110
+ skipped_fw = 0
1111
+ unconfirmed_fw = 0
1112
+ override_own_fw = 0
1113
+ override_vendor_fw = 0
1114
+ for fw_entry, (kind, fw_binary_member) in sorted(fw_entries.items()):
1115
+ fw_stripped = fw_entry[: -len(".framework")] if kind == "framework" else fw_entry[: -len(".dylib")]
1116
+ # A bare dylib has no Info.plist and so no bundle id, so the
1117
+ # filename convention has to be checked before classification.
1118
+ if kind == "dylib" and fw_stripped.startswith(_APPLE_REDIST_DYLIB_PREFIXES):
1119
+ skipped_fw += 1
1120
+ continue
1121
+
1122
+ fw_bundle_id = None
1123
+ if kind == "framework":
1124
+ fw_dir_member = f"{frameworks_prefix}{fw_entry}"
1125
+ fw_bundle_id = _read_framework_bundle_id(zf, fw_dir_member)
1126
+
1127
+ override_tier = check_ios_override(fw_stripped, fw_bundle_id, override_config)
1128
+ if override_tier == "own":
1129
+ override_own_fw += 1
1130
+ tier, tier_reason = TIER_FIRST_PARTY, (
1131
+ "forced first-party by .narvy-scope.yml 'own' override")
1132
+ elif override_tier == "vendor":
1133
+ override_vendor_fw += 1
1134
+ tier, tier_reason = TIER_THIRD_PARTY, (
1135
+ "forced third-party by .narvy-scope.yml 'vendor' override")
1136
+ else:
1137
+ tier, tier_reason = classify_ios_framework(fw_stripped, fw_bundle_id, app_bundle_id)
1138
+
1139
+ if tier == TIER_THIRD_PARTY:
1140
+ skipped_fw += 1
1141
+ continue
1142
+ if fw_binary_member not in names:
1143
+ continue
1144
+ fw_local_path = _safe_extract_member(zf, fw_binary_member, tmpdir)
1145
+ if fw_local_path is None:
1146
+ continue
1147
+ fw_display = f"Frameworks/{fw_entry}"
1148
+ fw_findings = _analyze_one_binary(fw_local_path, fw_display, is_main_binary=False)
1149
+ if tier == TIER_UNCONFIRMED:
1150
+ _tag_unconfirmed_origin(fw_findings, tier_reason)
1151
+ unconfirmed_fw += 1
1152
+ findings.extend(fw_findings)
1153
+ analyzed_fw += 1
1154
+ fw_names = list(fw_entries.keys())
1155
+ if fw_names:
1156
+ result["notes"].append(
1157
+ f"Embedded frameworks: {analyzed_fw} binaries analyzed for content "
1158
+ f"(crypto/sensitive-strings/dangerous-API) - {unconfirmed_fw} of "
1159
+ f"those flagged [Unconfirmed Origin] (bundle id neither matched the "
1160
+ f"app's own reverse-DNS root nor a known vendor, see per-finding "
1161
+ f"notes), {skipped_fw} confirmed third-party SDKs skipped "
1162
+ f"(bundle-id and/or name match), out of {len(fw_names)} total.")
1163
+ if override_own_fw or override_vendor_fw:
1164
+ result["notes"].append(
1165
+ f".narvy-scope.yml overrides ({override_config.source_path}): "
1166
+ f"{override_own_fw} framework(s) forced first-party, "
1167
+ f"{override_vendor_fw} framework(s) forced third-party-excluded.")
1168
+
1169
+ main_strings = _get_strings(main_binary_path)
1170
+ main_symbols = _symbol_names(main_parsed)
1171
+ _check_rasp(main_strings, main_symbols, exe_name, findings,
1172
+ result["notes"], encrypted=encrypted)
1173
+
1174
+ _detect_cross_platform_framework(names + fw_names, result["notes"])
1175
+
1176
+ result["findings"] = _dedupe_findings(findings)
1177
+ result["ok"] = True
1178
+ return result
1179
+
1180
+ except Exception as e: # noqa: BLE001
1181
+ result["error"] = f"Unexpected error analyzing {ipa_path}: {e}"
1182
+ result["ok"] = False
1183
+ return result
1184
+
1185
+
1186
+ def _safe_extract_member(zf: zipfile.ZipFile, member: str, dest_dir: str,
1187
+ max_bytes: int = 3 * 1024 * 1024 * 1024) -> Optional[str]:
1188
+ """Stream one zip member to disk under a size cap. None means skip-this-one."""
1189
+ try:
1190
+ info = zf.getinfo(member)
1191
+ except KeyError:
1192
+ return None
1193
+ if info.file_size > max_bytes:
1194
+ return None
1195
+ dest_path = os.path.join(dest_dir, os.path.basename(member) + f"_{abs(hash(member))}")
1196
+ try:
1197
+ with zf.open(member) as src, open(dest_path, "wb") as dst:
1198
+ shutil.copyfileobj(src, dst, length=1 << 20)
1199
+ except Exception:
1200
+ return None
1201
+ return dest_path