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,503 @@
1
+ """ELF hardening checks (NX, PIE, RELRO, stack canary, RPATH) for the native
2
+ .so libraries shipped inside an APK/AAB.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ import zipfile
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+ from .stack_protector_evidence import has_instrumentable_stack_code
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ try:
19
+ import lief
20
+ LIEF_AVAILABLE = True
21
+ except ImportError:
22
+ LIEF_AVAILABLE = False
23
+
24
+
25
+ # Only one ABI is analyzed: per-ABI builds share the same hardening flags.
26
+ _ARCH_PREFERENCE = ("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
27
+
28
+ # Zip-bomb guard on a single extracted .so.
29
+ _MAX_SO_BYTES = 500 * 1024 * 1024
30
+
31
+ # Lower-cased, matched against the .so basename only.
32
+ _VENDOR_SO_EXACT = {
33
+ "libc++_shared.so", "libc++abi.so", "libstlport_shared.so", "libgnustl_shared.so",
34
+ "libssl.so", "libcrypto.so", "libsqlite3.so", "libsqlcipher.so",
35
+ "libconscrypt_jni.so", "libwebviewchromium.so",
36
+ }
37
+ _VENDOR_SO_PREFIXES = (
38
+ "libandroidx.",
39
+ "libflutter", "libapp",
40
+ "libhermes", "libjsc", "libreactnative", "libfb", "libyoga",
41
+ "libil2cpp", "libmono", "libunity",
42
+ "libcrashlytics", "libbugsnag", "libsentry",
43
+ "libcronet", "libquic",
44
+ "libdatadog", "libgojni",
45
+ )
46
+ # A .so whose JNI entry points all sit under these namespaces is bundled third-party code.
47
+ _VENDOR_JNI_PACKAGE_PREFIXES = (
48
+ "androidx.", "android.", "com.android.",
49
+ "com.google.", "com.googlecode.",
50
+ "io.flutter.", "com.facebook.", "com.reactnativecommunity.",
51
+ "org.chromium.", "com.unity3d.", "org.webrtc.",
52
+ "io.sentry.", "com.bugsnag.", "com.crashlytics.", "com.datadog.",
53
+ "org.sqlite.", "net.sqlcipher.", "org.conscrypt.",
54
+ )
55
+
56
+
57
+ def _jni_java_packages(symbol_names) -> List[str]:
58
+ """Java package prefixes of every `Java_<pkg>_<Class>_<method>` JNI export."""
59
+ # JNI mangling maps '.'->'_' and escapes '_' as '_1'; package recoverable as a prefix only.
60
+ out: List[str] = []
61
+ for name in symbol_names:
62
+ if not name.startswith("Java_"):
63
+ continue
64
+ body = name[len("Java_"):]
65
+ if not body:
66
+ continue
67
+ out.append(body.replace("_1", "\x00").replace("_", ".").replace("\x00", "_"))
68
+ return out
69
+
70
+
71
+ def _is_vendor_by_jni(symbol_names) -> bool:
72
+ packages = _jni_java_packages(symbol_names)
73
+ if not packages:
74
+ return False
75
+ return all(
76
+ any(pkg.startswith(prefix) for prefix in _VENDOR_JNI_PACKAGE_PREFIXES)
77
+ for pkg in packages
78
+ )
79
+
80
+
81
+ @dataclass
82
+ class _SoFacts:
83
+ basename: str
84
+ zip_member: str
85
+ is_vendor: bool
86
+ has_nx: bool
87
+ # ET_DYN is the PIE equivalent for a .so; LIEF's is_pie also requires PT_INTERP.
88
+ is_dyn: bool
89
+ relro: str # "full" | "partial" | "none"
90
+ has_canary: bool
91
+ rpath: Optional[str]
92
+ runpath: Optional[str]
93
+ # None means no analysis available for this architecture, not "no".
94
+ instrumentable: Optional[bool] = None
95
+
96
+
97
+ def _is_vendor_lib(basename: str, symbol_names=()) -> bool:
98
+ lower = basename.lower()
99
+ if lower in _VENDOR_SO_EXACT:
100
+ return True
101
+ if any(lower.startswith(p) for p in _VENDOR_SO_PREFIXES):
102
+ return True
103
+ return _is_vendor_by_jni(symbol_names)
104
+
105
+
106
+ def _is_weak_search_path(raw: str) -> bool:
107
+ if not raw:
108
+ return False
109
+ for entry in raw.split(":"):
110
+ entry = entry.strip()
111
+ if not entry:
112
+ continue
113
+ if not entry.startswith("/") and not entry.startswith("$ORIGIN"):
114
+ return True
115
+ if ".." in entry:
116
+ return True
117
+ lowered = entry.lower()
118
+ if any(bad in lowered for bad in ("/data/local/tmp", "/sdcard", "/storage/emulated", "/tmp")):
119
+ return True
120
+ return False
121
+
122
+
123
+ def _pick_arch(namelist: List[str]) -> Optional[str]:
124
+ available = set()
125
+ for name in namelist:
126
+ if name.startswith("lib/") and name.endswith(".so"):
127
+ parts = name.split("/")
128
+ if len(parts) >= 3 and parts[2]:
129
+ available.add(parts[1])
130
+ if not available:
131
+ return None
132
+ for pref in _ARCH_PREFERENCE:
133
+ if pref in available:
134
+ return pref
135
+ return sorted(available)[0]
136
+
137
+
138
+ def _safe_extract(zf: zipfile.ZipFile, member: str, dest_dir: str) -> Optional[str]:
139
+ try:
140
+ info = zf.getinfo(member)
141
+ except KeyError:
142
+ return None
143
+ if info.file_size > _MAX_SO_BYTES:
144
+ logger.debug(f"[native-hardening] {member} exceeds size cap, skipping")
145
+ return None
146
+ dest_path = os.path.join(dest_dir, os.path.basename(member) + f"_{abs(hash(member))}")
147
+ try:
148
+ with zf.open(member) as src, open(dest_path, "wb") as dst:
149
+ shutil.copyfileobj(src, dst, length=1 << 20)
150
+ except Exception as e:
151
+ logger.debug(f"[native-hardening] extraction failed for {member}: {e}")
152
+ return None
153
+ return dest_path
154
+
155
+
156
+ def _compute_relro(binary) -> str:
157
+ has_seg = any(seg.type == lief.ELF.Segment.TYPE.GNU_RELRO for seg in binary.segments)
158
+ if not has_seg:
159
+ return "none"
160
+ bind_now = False
161
+ try:
162
+ if binary.has(lief.ELF.DynamicEntry.TAG.FLAGS):
163
+ entry = binary.get(lief.ELF.DynamicEntry.TAG.FLAGS)
164
+ bind_now = bind_now or any("BIND_NOW" in str(f) for f in entry.flags)
165
+ if binary.has(lief.ELF.DynamicEntry.TAG.FLAGS_1):
166
+ entry = binary.get(lief.ELF.DynamicEntry.TAG.FLAGS_1)
167
+ bind_now = bind_now or any("NOW" in str(f) for f in entry.flags)
168
+ except Exception as e:
169
+ logger.debug(f"[native-hardening] RELRO/BIND_NOW inspection failed: {e}")
170
+ return "full" if bind_now else "partial"
171
+
172
+
173
+ def _analyze_so(local_path: str, basename: str, zip_member: str) -> Optional[_SoFacts]:
174
+ try:
175
+ binary = lief.ELF.parse(local_path)
176
+ except Exception as e:
177
+ logger.debug(f"[native-hardening] LIEF failed to parse {basename}: {e}")
178
+ return None
179
+ if binary is None:
180
+ return None
181
+
182
+ try:
183
+ has_nx = bool(binary.has_nx)
184
+ is_dyn = binary.header.file_type == lief.ELF.Header.FILE_TYPE.DYN
185
+ relro = _compute_relro(binary)
186
+ has_canary = bool(binary.has_symbol("__stack_chk_fail"))
187
+ # Only pay for the .text decode when it can still change the finding.
188
+ instrumentable: Optional[bool] = None
189
+ if not has_canary:
190
+ instrumentable = has_instrumentable_stack_code(binary)
191
+ symbol_names = [s.name for s in binary.dynamic_symbols]
192
+ rpath = None
193
+ runpath = None
194
+ if binary.has(lief.ELF.DynamicEntry.TAG.RPATH):
195
+ rpath = binary.get(lief.ELF.DynamicEntry.TAG.RPATH).rpath
196
+ if binary.has(lief.ELF.DynamicEntry.TAG.RUNPATH):
197
+ runpath = binary.get(lief.ELF.DynamicEntry.TAG.RUNPATH).runpath
198
+ except Exception as e:
199
+ logger.debug(f"[native-hardening] fact extraction failed for {basename}: {e}")
200
+ return None
201
+
202
+ return _SoFacts(
203
+ basename=basename,
204
+ zip_member=zip_member,
205
+ is_vendor=_is_vendor_lib(basename, symbol_names),
206
+ has_nx=has_nx,
207
+ is_dyn=is_dyn,
208
+ relro=relro,
209
+ has_canary=has_canary,
210
+ rpath=rpath,
211
+ runpath=runpath,
212
+ instrumentable=instrumentable,
213
+ )
214
+
215
+
216
+ def _mk_finding(facts: _SoFacts, rule_id: str, name: str, severity: str, cwe: str,
217
+ description: str, recommendation: str,
218
+ location_symbol: Optional[str] = None) -> Dict[str, Any]:
219
+ f: Dict[str, Any] = {
220
+ "rule_id": rule_id,
221
+ "file_path": facts.zip_member,
222
+ "name": name,
223
+ "severity": "LOW" if facts.is_vendor else severity,
224
+ "details": {
225
+ "description": description,
226
+ "recommendation": recommendation,
227
+ "cwe": cwe,
228
+ "masvs": "MSTG-CODE-9",
229
+ },
230
+ "line": 1,
231
+ "engine": "native-hardening",
232
+ "native_lib": {
233
+ "basename": facts.basename,
234
+ "own_code": not facts.is_vendor,
235
+ },
236
+ }
237
+ # Console display only; SARIF output keeps using `line`.
238
+ if location_symbol:
239
+ f["location_symbol"] = location_symbol
240
+ return f
241
+
242
+
243
+ def _canary_evidence_note(facts: _SoFacts) -> str:
244
+ """Explain why an absent stack-canary symbol is treated as a real finding."""
245
+ if facts.instrumentable is True:
246
+ return (
247
+ "This library does contain code the protector would have instrumented "
248
+ "(it materialises the address of a stack slot into a register - a local "
249
+ "array or an escaping local), so the absent symbol really does mean the "
250
+ "flag was off, not that there was nothing to protect. "
251
+ )
252
+ return (
253
+ "NOTE: this architecture has no instrumentable-code decoder in this "
254
+ "scanner, so the absent symbol has not been corroborated - verify the "
255
+ "build flags before acting on it. "
256
+ )
257
+
258
+
259
+ def _facts_to_findings(facts: _SoFacts) -> List[Dict[str, Any]]:
260
+ out: List[Dict[str, Any]] = []
261
+ tag = "[Third-party/bundled library] " if facts.is_vendor else ""
262
+ scope_note = (
263
+ "This is a well-known bundled/third-party native library, not code the app's own "
264
+ "developer built - not directly actionable by them (any fix belongs to the "
265
+ "upstream vendor); shown for completeness, not at the same urgency as a gap in "
266
+ "the app's own native code."
267
+ if facts.is_vendor else
268
+ "This is one of the app's own native libraries (not a recognized bundled/"
269
+ "third-party SDK)."
270
+ )
271
+
272
+ if not facts.has_nx:
273
+ out.append(_mk_finding(
274
+ facts, "AND-BIN-001", tag + "Native Library Missing NX (Executable Stack)",
275
+ severity="MEDIUM", cwe="CWE-119",
276
+ description=(
277
+ f"{facts.basename} does not have the NX bit enabled (its PT_GNU_STACK "
278
+ f"segment is marked executable, or is absent) - the stack is executable, "
279
+ f"making injected-shellcode exploitation of any memory-corruption bug in "
280
+ f"this library meaningfully easier. {scope_note}"
281
+ ),
282
+ recommendation=(
283
+ "Rebuild the native library without `-z execstack` - the modern Android "
284
+ "NDK toolchain disables this by default, so this usually indicates a "
285
+ "custom/legacy linker flag override."
286
+ ),
287
+ location_symbol="PT_GNU_STACK segment (NX flag)",
288
+ ))
289
+
290
+ if not facts.is_dyn:
291
+ out.append(_mk_finding(
292
+ facts, "AND-BIN-002", tag + "Native Library Not Built as Position-Independent (No PIE)",
293
+ severity="MEDIUM", cwe="CWE-693",
294
+ description=(
295
+ f"{facts.basename} is not a Dynamic Shared Object (ET_DYN) - unusual for "
296
+ f"a .so file, and it defeats ASLR for this library's mapped address "
297
+ f"range, making return-to-libc/ROP-style exploitation of any other bug in "
298
+ f"it meaningfully more reliable. {scope_note}"
299
+ ),
300
+ recommendation=(
301
+ "Rebuild with `-fPIC`/`-shared` (the standard, default way to build any "
302
+ "Android .so) - this typically indicates a broken or hand-rolled build "
303
+ "step, not a deliberate choice."
304
+ ),
305
+ location_symbol="ELF header (e_type != ET_DYN)",
306
+ ))
307
+
308
+ if facts.relro != "full":
309
+ is_none = facts.relro == "none"
310
+ label = "No RELRO" if is_none else "Partial RELRO (GOT Not Fully Read-Only)"
311
+ detail = (
312
+ "its Global Offset Table (GOT) is fully writable at runtime"
313
+ if is_none else
314
+ "its GOT is marked read-only after relocation (PT_GNU_RELRO present) but "
315
+ "lazy binding is still active (no DT_FLAGS/DT_FLAGS_1 BIND_NOW), leaving the "
316
+ ".got.plt section writable until first use"
317
+ )
318
+ out.append(_mk_finding(
319
+ facts, "AND-BIN-003", tag + label,
320
+ severity="MEDIUM" if is_none else "LOW", cwe="CWE-732",
321
+ description=(
322
+ f"{facts.basename} does not have full RELRO protection - {detail}, making "
323
+ f"GOT-overwrite exploitation of any other memory-corruption bug in this "
324
+ f"library easier. {scope_note}"
325
+ ),
326
+ recommendation=(
327
+ "Relink with `-Wl,-z,relro -Wl,-z,now` (Full RELRO) - standard practice, "
328
+ "no meaningful runtime cost for a mobile app."
329
+ ),
330
+ location_symbol="PT_GNU_RELRO segment / DT_FLAGS(_1) BIND_NOW",
331
+ ))
332
+
333
+ # Absent `__stack_chk_fail` only proves the protector was off if there was code to instrument.
334
+ if not facts.has_canary and facts.instrumentable is not False:
335
+ out.append(_mk_finding(
336
+ facts, "AND-BIN-004", tag + "Native Library Missing Stack Canary",
337
+ severity="MEDIUM", cwe="CWE-121",
338
+ description=(
339
+ f"{facts.basename} was not built with a stack protector (no "
340
+ f"`__stack_chk_fail` symbol) - a stack buffer overflow in this library's "
341
+ f"own code cannot be detected before a corrupted return address is used. "
342
+ f"{_canary_evidence_note(facts)}{scope_note}"
343
+ ),
344
+ recommendation=(
345
+ "Rebuild with `-fstack-protector-strong` (or `-all`) - on by default in a "
346
+ "modern NDK, so this usually means a custom/legacy toolchain or an "
347
+ "explicit override."
348
+ ),
349
+ location_symbol="symbol table (__stack_chk_fail absent)",
350
+ ))
351
+
352
+ weak_path = facts.rpath or facts.runpath
353
+ if weak_path:
354
+ which = "RPATH" if facts.rpath else "RUNPATH"
355
+ is_weak = _is_weak_search_path(weak_path)
356
+ extra = (
357
+ ", and this entry is relative and/or points at a location an on-device "
358
+ "attacker could plausibly write to (app-private external storage, a "
359
+ "world-writable tmp dir, ...) - a library planted there would be loaded "
360
+ "instead of the real one (uncontrolled search path / library injection)."
361
+ if is_weak else
362
+ ", an unusual thing for a well-hardened release Android library to ship at "
363
+ "all (Android apps normally rely on the default linker search order)."
364
+ )
365
+ out.append(_mk_finding(
366
+ facts, "AND-BIN-005", tag + f"{which} Set in Native Library",
367
+ severity="MEDIUM" if is_weak else "LOW", cwe="CWE-427",
368
+ description=(
369
+ f"{facts.basename} embeds a {which} value ('{weak_path}') - the dynamic "
370
+ f"linker will search this path for dependent libraries at load time{extra} "
371
+ f"{scope_note}"
372
+ ),
373
+ recommendation=(
374
+ "Remove the RPATH/RUNPATH from the link step - check for a stray "
375
+ "`-rpath`/`-Wl,-rpath` flag left over from a local/dev build config."
376
+ ),
377
+ location_symbol=f"DT_{which} entry: {weak_path}",
378
+ ))
379
+
380
+ return out
381
+
382
+
383
+ # Canonical rule catalog, SARIF "tool.driver.rules" style.
384
+ _RULE_CATALOG: Dict[str, Dict[str, Any]] = {
385
+ "AND-BIN-001": {
386
+ "id": "AND-BIN-001", "name": "Native Library Missing NX (Executable Stack)",
387
+ "severity": "MEDIUM", "masvs": "MSTG-CODE-9",
388
+ "details": {
389
+ "description": "A native (.so) library ships without the NX bit set, leaving its stack executable.",
390
+ "recommendation": "Rebuild without `-z execstack`; modern NDK toolchains disable this by default.",
391
+ "cwe": "CWE-119", "masvs": "MSTG-CODE-9",
392
+ },
393
+ },
394
+ "AND-BIN-002": {
395
+ "id": "AND-BIN-002", "name": "Native Library Not Built as Position-Independent (No PIE)",
396
+ "severity": "MEDIUM", "masvs": "MSTG-CODE-9",
397
+ "details": {
398
+ "description": "A native (.so) library is not a Dynamic Shared Object (ET_DYN), defeating ASLR for its mapped range.",
399
+ "recommendation": "Rebuild with `-fPIC`/`-shared`, the standard way to build any Android .so.",
400
+ "cwe": "CWE-693", "masvs": "MSTG-CODE-9",
401
+ },
402
+ },
403
+ "AND-BIN-003": {
404
+ "id": "AND-BIN-003", "name": "Native Library Missing/Partial RELRO",
405
+ "severity": "MEDIUM", "masvs": "MSTG-CODE-9",
406
+ "details": {
407
+ "description": "A native (.so) library's GOT is not fully read-only at runtime (no/partial RELRO), easing GOT-overwrite exploitation.",
408
+ "recommendation": "Relink with `-Wl,-z,relro -Wl,-z,now` for Full RELRO.",
409
+ "cwe": "CWE-732", "masvs": "MSTG-CODE-9",
410
+ },
411
+ },
412
+ "AND-BIN-004": {
413
+ "id": "AND-BIN-004", "name": "Native Library Missing Stack Canary",
414
+ "severity": "MEDIUM", "masvs": "MSTG-CODE-9",
415
+ "details": {
416
+ "description": "A native (.so) library was not built with a stack protector (no `__stack_chk_fail`), so stack buffer overflows go undetected.",
417
+ "recommendation": "Rebuild with `-fstack-protector-strong` (or `-all`).",
418
+ "cwe": "CWE-121", "masvs": "MSTG-CODE-9",
419
+ },
420
+ },
421
+ "AND-BIN-005": {
422
+ "id": "AND-BIN-005", "name": "RPATH/RUNPATH Set in Native Library",
423
+ "severity": "MEDIUM", "masvs": "MSTG-CODE-9",
424
+ "details": {
425
+ "description": "A native (.so) library embeds an RPATH/RUNPATH, an uncontrolled search path for the dynamic linker.",
426
+ "recommendation": "Remove the RPATH/RUNPATH from the link step.",
427
+ "cwe": "CWE-427", "masvs": "MSTG-CODE-9",
428
+ },
429
+ },
430
+ }
431
+
432
+
433
+ def scan(apk_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]:
434
+ """Run the ELF hardening checks over an APK/AAB. Returns (findings, rule_defs, stats). Never raises."""
435
+ stats: Dict[str, Any] = {
436
+ "lief_available": LIEF_AVAILABLE,
437
+ "arch_analyzed": None,
438
+ "libs_found_total": 0,
439
+ "libs_analyzed": 0,
440
+ "own_libs_analyzed": 0,
441
+ "vendor_libs_analyzed": 0,
442
+ "parse_failures": 0,
443
+ "findings": 0,
444
+ }
445
+ findings: List[Dict[str, Any]] = []
446
+
447
+ if not LIEF_AVAILABLE:
448
+ logger.debug("[native-hardening] lief not importable - skipping native hardening checks")
449
+ return findings, [], stats
450
+
451
+ used_rule_ids: set = set()
452
+
453
+ try:
454
+ with zipfile.ZipFile(apk_path, "r") as zf:
455
+ namelist = zf.namelist()
456
+ all_so = [n for n in namelist if n.startswith("lib/") and n.endswith(".so")]
457
+ stats["libs_found_total"] = len(all_so)
458
+
459
+ arch = _pick_arch(namelist)
460
+ if arch is None:
461
+ return findings, [], stats
462
+
463
+ stats["arch_analyzed"] = arch
464
+ prefix = f"lib/{arch}/"
465
+ arch_members: Dict[str, str] = {}
466
+ for n in all_so:
467
+ if n.startswith(prefix):
468
+ basename = n[len(prefix):]
469
+ if "/" in basename:
470
+ continue
471
+ arch_members.setdefault(basename, n)
472
+
473
+ if not arch_members:
474
+ return findings, [], stats
475
+
476
+ with tempfile.TemporaryDirectory(prefix="narvy-nh-") as tmpdir:
477
+ for basename in sorted(arch_members):
478
+ member = arch_members[basename]
479
+ local_path = _safe_extract(zf, member, tmpdir)
480
+ if local_path is None:
481
+ stats["parse_failures"] += 1
482
+ continue
483
+ facts = _analyze_so(local_path, basename, member)
484
+ if facts is None:
485
+ stats["parse_failures"] += 1
486
+ continue
487
+ stats["libs_analyzed"] += 1
488
+ if facts.is_vendor:
489
+ stats["vendor_libs_analyzed"] += 1
490
+ else:
491
+ stats["own_libs_analyzed"] += 1
492
+ lib_findings = _facts_to_findings(facts)
493
+ for f in lib_findings:
494
+ used_rule_ids.add(f["rule_id"])
495
+ findings.extend(lib_findings)
496
+ except zipfile.BadZipFile:
497
+ logger.error(f"[native-hardening] Invalid APK/AAB file: {apk_path}")
498
+ except Exception as e:
499
+ logger.error(f"[native-hardening] Unexpected error scanning {apk_path}: {e}")
500
+
501
+ rule_defs = [_RULE_CATALOG[rid] for rid in sorted(used_rule_ids)]
502
+ stats["findings"] = len(findings)
503
+ return findings, rule_defs, stats
narvy/reporter.py ADDED
@@ -0,0 +1,151 @@
1
+ import json
2
+ from typing import List, Dict, Any
3
+
4
+ from narvy import __version__
5
+
6
+ _FAIRPLAY_RULE_ID = "IOS-FAIRPLAY-ENCRYPTED"
7
+ _FAIRPLAY_COVERAGE_TEXT = (
8
+ "Partial coverage: this iOS binary is FairPlay-encrypted (App Store DRM, "
9
+ "cryptid=1), so its __TEXT code section is ciphertext and could not be read "
10
+ "statically. Info.plist, entitlements, URL schemes/ATS, load commands and "
11
+ "Objective-C metadata WERE analyzed. The app's own code and string literals "
12
+ "were NOT. Zero findings from the code/string rules on this run means 'not "
13
+ "visible', not 'clean' - do not treat this scan as a clean bill of health."
14
+ )
15
+
16
+
17
+ def generate_sarif_report(findings: List[Dict[str, Any]], rules: List[Dict[str, Any]],
18
+ encrypted: bool = False,
19
+ encrypted_artifact_uri: str = None) -> Dict[str, Any]:
20
+ """Generate a SARIF v2.1.0 report."""
21
+ _SARIF_PRECISION = {"LOW": "low", "MEDIUM": "medium", "HIGH": "high"}
22
+
23
+ # SARIF's standard per-result field; properties.severity keeps our own label.
24
+ _SARIF_LEVEL = {
25
+ "CRITICAL": "error", "HIGH": "error",
26
+ "MEDIUM": "warning",
27
+ "LOW": "note", "INFO": "note",
28
+ }
29
+
30
+ results = []
31
+ for finding in findings:
32
+ confidence = finding.get('confidence', 'MEDIUM')
33
+ _sev = str(finding.get('severity', '') or '').upper()
34
+ results.append({
35
+ "ruleId": finding['rule_id'],
36
+ "level": _SARIF_LEVEL.get(_sev, "warning"),
37
+ "message": {
38
+ "text": finding['details']['description']
39
+ },
40
+ "locations": [{
41
+ "physicalLocation": {
42
+ "artifactLocation": {
43
+ "uri": finding['file_path']
44
+ },
45
+ "region": {
46
+ "startLine": finding.get('line', 1)
47
+ }
48
+ }
49
+ }],
50
+ "properties": {
51
+ "confidence": confidence,
52
+ "precision": _SARIF_PRECISION.get(confidence, "medium"),
53
+ "severity": _sev or "UNKNOWN",
54
+ **({"prngContext": finding['details']['prng_context'],
55
+ "prngContextEvidence": finding['details'].get('prng_context_evidence', '')}
56
+ if isinstance(finding.get('details'), dict)
57
+ and finding['details'].get('prng_context') else {}),
58
+ }
59
+ })
60
+
61
+ if encrypted:
62
+ _coverage_result: Dict[str, Any] = {
63
+ "ruleId": _FAIRPLAY_RULE_ID,
64
+ "level": "note",
65
+ "message": {"text": _FAIRPLAY_COVERAGE_TEXT},
66
+ "properties": {
67
+ "confidence": "HIGH",
68
+ "precision": "very-high",
69
+ "severity": "INFO",
70
+ },
71
+ }
72
+ if encrypted_artifact_uri:
73
+ _coverage_result["locations"] = [{
74
+ "physicalLocation": {
75
+ "artifactLocation": {"uri": encrypted_artifact_uri},
76
+ "region": {"startLine": 1},
77
+ }
78
+ }]
79
+ results.append(_coverage_result)
80
+
81
+ tool_rules = []
82
+ for rule in rules:
83
+ rule_confidence = rule.get('confidence', 'MEDIUM')
84
+ tool_rules.append({
85
+ "id": rule['id'],
86
+ "name": rule['name'],
87
+ "shortDescription": {
88
+ "text": rule['details']['description']
89
+ },
90
+ "fullDescription": {
91
+ "text": rule['details']['recommendation']
92
+ },
93
+ "help": {
94
+ "text": f"CWE: {rule['details'].get('cwe')}\nMASVS: {rule['details'].get('masvs')}"
95
+ },
96
+ "properties": {
97
+ "tags": ["security", "android", rule.get('masvs', '').lower()],
98
+ "precision": _SARIF_PRECISION.get(rule_confidence, "medium"),
99
+ "problem.severity": rule.get('severity', 'UNKNOWN').lower()
100
+ }
101
+ })
102
+
103
+ if encrypted:
104
+ # GitHub's SARIF ingester resolves results[].ruleId against
105
+ # tool.driver.rules[].id, so the result above needs this descriptor.
106
+ tool_rules.append({
107
+ "id": _FAIRPLAY_RULE_ID,
108
+ "name": "FairPlayEncryptedBinaryPartialCoverage",
109
+ "shortDescription": {
110
+ "text": "Binary is FairPlay-encrypted - static analysis coverage is partial."
111
+ },
112
+ "fullDescription": {"text": _FAIRPLAY_COVERAGE_TEXT},
113
+ "help": {
114
+ "text": "Scan a decrypted binary (jailbroken-device dump) to analyze the "
115
+ "app's own code and string literals. This is a property of App "
116
+ "Store distribution, not a defect in the app."
117
+ },
118
+ "properties": {
119
+ "tags": ["security", "ios", "coverage"],
120
+ "precision": "very-high",
121
+ "problem.severity": "note",
122
+ },
123
+ })
124
+
125
+ run: Dict[str, Any] = {
126
+ "tool": {
127
+ "driver": {
128
+ "name": "Narvy CLI",
129
+ "version": __version__,
130
+ "informationUri": "https://narvy.io",
131
+ "rules": tool_rules
132
+ }
133
+ },
134
+ "results": results
135
+ }
136
+ if encrypted:
137
+ run["invocations"] = [{
138
+ # The invocation object's only required property (SARIF 2.1.0).
139
+ "executionSuccessful": True,
140
+ "toolExecutionNotifications": [{
141
+ "level": "note",
142
+ "message": {"text": _FAIRPLAY_COVERAGE_TEXT},
143
+ }],
144
+ }]
145
+
146
+ report = {
147
+ "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
148
+ "version": "2.1.0",
149
+ "runs": [run]
150
+ }
151
+ return report