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,475 @@
1
+ """Split-APK bundle support (.apkm / .xapk / .apks) for the Android scan path: extract the
2
+ app module, preferred ABI split and any dex-bearing feature module, without merging splits."""
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import shutil
9
+ import zipfile
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ SPLIT_BUNDLE_EXTS = (".apkm", ".xapk", ".apks")
17
+
18
+ # A bundle is untrusted input: never trust a member name or a declared size.
19
+ _MAX_TOTAL_UNCOMPRESSED = 6 * 1024 * 1024 * 1024
20
+ _MAX_MEMBER_BYTES = 2 * 1024 * 1024 * 1024
21
+ _MAX_COMPRESSION_RATIO = 200
22
+ _PEEK_MAX_BYTES = 64 * 1024 * 1024
23
+ _MAX_FEATURE_INPUT_BYTES = 128 * 1024 * 1024
24
+
25
+ # Duplicated (not imported) so this module stays importable without lief.
26
+ _ABI_PREFERENCE = ("arm64_v8a", "armeabi_v7a", "x86_64", "x86")
27
+
28
+ # config.<token>.apk, split_config.<token>.apk, <Feature>.config.<token>.apk.
29
+ _CONFIG_SPLIT_RE = re.compile(
30
+ r"^(?:(?P<owner>[A-Za-z0-9_\-]+)\.)?(?:split_)?config[.\-_](?P<token>[A-Za-z0-9_\-]+)\.apk$",
31
+ re.IGNORECASE,
32
+ )
33
+
34
+ _ABI_TOKENS = {"arm64_v8a", "armeabi_v7a", "armeabi", "x86", "x86_64", "mips", "mips64",
35
+ "arm64-v8a", "armeabi-v7a"}
36
+ _DENSITY_TOKENS = {"ldpi", "mdpi", "hdpi", "tvdpi", "xhdpi", "xxhdpi", "xxxhdpi",
37
+ "nodpi", "anydpi"}
38
+
39
+ _EXPLICIT_BASE_NAMES = ("base.apk", "base-master.apk", "universal.apk")
40
+
41
+ # bundletool emits one master split per device variant, carrying the same code.
42
+ _MASTER_VARIANT_RE = re.compile(r"^(?:base-)?master(?:_\d+)?\.apk$", re.IGNORECASE)
43
+
44
+
45
+ class SplitBundleError(Exception):
46
+ """Raised when a bundle cannot be turned into something scannable."""
47
+
48
+
49
+ @dataclass
50
+ class BundleMember:
51
+ """One inner .apk inside the bundle."""
52
+ name: str
53
+ size: int
54
+ kind: str
55
+ token: Optional[str] = None
56
+ owner: Optional[str] = None
57
+ has_dex: Optional[bool] = None
58
+ has_so: Optional[bool] = None
59
+
60
+
61
+ @dataclass
62
+ class ExtractedBundle:
63
+ """What extract_for_scan() produced, and what it left out."""
64
+ bundle_path: str
65
+ bundle_format: str
66
+ base_apk_path: str
67
+ base_member: str
68
+ native_apk_path: Optional[str] = None
69
+ native_abi: Optional[str] = None
70
+ native_source: str = "none"
71
+ package_name: Optional[str] = None
72
+ members: List[BundleMember] = field(default_factory=list)
73
+ dex_bearing_splits: List[str] = field(default_factory=list)
74
+ feature_apk_paths: List[str] = field(default_factory=list)
75
+ skipped_feature_splits: List[str] = field(default_factory=list)
76
+
77
+ @property
78
+ def split_count(self) -> int:
79
+ return sum(1 for m in self.members if m.kind != "base")
80
+
81
+
82
+ def is_split_bundle(path: str) -> bool:
83
+ """Extension-only check; real validation happens in extract_for_scan."""
84
+ return os.path.splitext(path)[1].lower() in SPLIT_BUNDLE_EXTS
85
+
86
+
87
+ def bundle_format(path: str) -> Optional[str]:
88
+ ext = os.path.splitext(path)[1].lower()
89
+ return ext.lstrip(".") if ext in SPLIT_BUNDLE_EXTS else None
90
+
91
+
92
+ def _classify_by_name(member: str) -> Tuple[str, Optional[str], Optional[str]]:
93
+ """Name-only classification of an inner .apk, as (kind, token, owner)."""
94
+ base = os.path.basename(member)
95
+ lowered = base.lower()
96
+
97
+ if lowered in _EXPLICIT_BASE_NAMES or _MASTER_VARIANT_RE.match(lowered):
98
+ return "base", None, None
99
+
100
+ def _kind_for(token: str) -> str:
101
+ if token in _ABI_TOKENS:
102
+ return "abi"
103
+ if token in _DENSITY_TOKENS:
104
+ return "density"
105
+ return "language"
106
+
107
+ # Matched against original basename: the owner group is shown to the user.
108
+ m = _CONFIG_SPLIT_RE.match(base)
109
+ if m:
110
+ token = m.group("token").lower()
111
+ owner = m.group("owner")
112
+ return _kind_for(token), token, owner
113
+
114
+ m2 = re.match(r"^base-([A-Za-z0-9_\-]+)\.apk$", lowered)
115
+ if m2:
116
+ token = m2.group(1).lower()
117
+ return _kind_for(token), token, None
118
+
119
+ return "unknown", None, None
120
+
121
+
122
+ def _read_packer_metadata(zf: zipfile.ZipFile, names: List[str]) -> Tuple[Optional[str], Optional[str]]:
123
+ if "manifest.json" in names:
124
+ try:
125
+ data = json.loads(zf.read("manifest.json").decode("utf-8", "replace"))
126
+ return data.get("package_name") or None, data.get("name") or None
127
+ except Exception as exc:
128
+ logger.debug(f"[split-bundle] unreadable manifest.json: {exc}")
129
+ if "info.json" in names:
130
+ try:
131
+ data = json.loads(zf.read("info.json").decode("utf-8", "replace"))
132
+ return data.get("pname") or None, data.get("app_name") or None
133
+ except Exception as exc:
134
+ logger.debug(f"[split-bundle] unreadable info.json: {exc}")
135
+ return None, None
136
+
137
+
138
+ def _validate_zip_safety(zf: zipfile.ZipFile, bundle_path: str) -> None:
139
+ """Zip-bomb / path-traversal guard. Raises SplitBundleError."""
140
+ total_uncompressed = 0
141
+ total_compressed = 0
142
+ for info in zf.infolist():
143
+ name = info.filename
144
+ if name.startswith("/") or name.startswith("\\") or ".." in name.replace("\\", "/").split("/"):
145
+ raise SplitBundleError(
146
+ f"Refusing to open {os.path.basename(bundle_path)}: it contains a member with a "
147
+ f"path-traversal name ({name!r}). This is not a normal split-APK bundle."
148
+ )
149
+ if info.file_size > _MAX_MEMBER_BYTES:
150
+ raise SplitBundleError(
151
+ f"Refusing to open {os.path.basename(bundle_path)}: member {name!r} declares "
152
+ f"{info.file_size / (1024 ** 3):.1f} GB uncompressed, over the "
153
+ f"{_MAX_MEMBER_BYTES / (1024 ** 3):.0f} GB per-file ceiling."
154
+ )
155
+ total_uncompressed += info.file_size
156
+ total_compressed += info.compress_size
157
+
158
+ if total_uncompressed > _MAX_TOTAL_UNCOMPRESSED:
159
+ raise SplitBundleError(
160
+ f"Refusing to open {os.path.basename(bundle_path)}: {total_uncompressed / (1024 ** 3):.1f} GB "
161
+ f"uncompressed, over the {_MAX_TOTAL_UNCOMPRESSED / (1024 ** 3):.0f} GB ceiling."
162
+ )
163
+ if total_compressed > 0 and (total_uncompressed / total_compressed) > _MAX_COMPRESSION_RATIO:
164
+ raise SplitBundleError(
165
+ f"Refusing to open {os.path.basename(bundle_path)}: compression ratio "
166
+ f"{total_uncompressed / total_compressed:.0f}:1 looks like a zip bomb "
167
+ f"(a real split bundle is ~2:1 - its members are already-compressed APKs)."
168
+ )
169
+
170
+
171
+ def _peek_member(zf: zipfile.ZipFile, member: str) -> Tuple[Optional[bool], Optional[bool]]:
172
+ """(has_dex, has_so) for a small inner APK, or (None, None) if not inspected."""
173
+ try:
174
+ info = zf.getinfo(member)
175
+ except KeyError:
176
+ return None, None
177
+ if info.file_size > _PEEK_MAX_BYTES:
178
+ return None, None
179
+ try:
180
+ with zf.open(member) as fh:
181
+ inner = zipfile.ZipFile(fh)
182
+ names = inner.namelist()
183
+ has_dex = any(n.endswith(".dex") for n in names)
184
+ has_so = any(n.startswith("lib/") and n.endswith(".so") for n in names)
185
+ return has_dex, has_so
186
+ except Exception as exc:
187
+ logger.debug(f"[split-bundle] could not peek into {member}: {exc}")
188
+ return None, None
189
+
190
+
191
+ def _validate_base_apk(path: str) -> Optional[str]:
192
+ """None if the picked member really is an app module, else a short reason."""
193
+ try:
194
+ with zipfile.ZipFile(path, "r") as zf:
195
+ names = zf.namelist()
196
+ except zipfile.BadZipFile:
197
+ return "not a valid ZIP/APK"
198
+ except OSError as exc:
199
+ return f"unreadable ({exc})"
200
+ if not any(n.endswith("AndroidManifest.xml") for n in names):
201
+ return "no AndroidManifest.xml"
202
+ if not any(n.endswith(".dex") for n in names):
203
+ return "no classes.dex (looks like a config split, not the app module)"
204
+ return None
205
+
206
+
207
+ def _base_candidates(members: List[BundleMember], package_name: Optional[str]) -> List[BundleMember]:
208
+ """Candidate app modules, most explicit signal first (caller content-verifies)."""
209
+ ordered: List[BundleMember] = []
210
+ seen = set()
211
+
212
+ def _add(m: BundleMember):
213
+ if m.name not in seen:
214
+ seen.add(m.name)
215
+ ordered.append(m)
216
+
217
+ for m in members:
218
+ if m.kind == "base" and "/" not in m.name:
219
+ _add(m)
220
+ if package_name:
221
+ want = f"{package_name.lower()}.apk"
222
+ for m in members:
223
+ if m.name.lower() == want:
224
+ _add(m)
225
+ # Sorted so the pick doesn't depend on zip member order.
226
+ for m in sorted((m for m in members if m.kind == "base"), key=lambda m: m.name):
227
+ _add(m)
228
+ for m in sorted((m for m in members if m.kind in ("unknown", "feature")),
229
+ key=lambda m: m.size, reverse=True):
230
+ _add(m)
231
+ return ordered
232
+
233
+
234
+ def _pick_abi_member(members: List[BundleMember]) -> Optional[BundleMember]:
235
+ """The app module's own ABI split to hand to native_hardening."""
236
+ # `owner is None` filters out feature-module ABI splits sharing the token.
237
+ by_token: Dict[str, BundleMember] = {}
238
+ for m in members:
239
+ if m.kind == "abi" and m.token and m.owner is None:
240
+ by_token.setdefault(m.token.replace("-", "_"), m)
241
+ for pref in _ABI_PREFERENCE:
242
+ if pref in by_token:
243
+ return by_token[pref]
244
+ if by_token:
245
+ return by_token[sorted(by_token)[0]]
246
+ return None
247
+
248
+
249
+ def _extract_member(zf: zipfile.ZipFile, member: str, dest_dir: str, as_name: str) -> str:
250
+ dest = os.path.join(dest_dir, as_name)
251
+ with zf.open(member) as src, open(dest, "wb") as dst:
252
+ shutil.copyfileobj(src, dst, length=1024 * 1024)
253
+ return dest
254
+
255
+
256
+ def extract_for_scan(bundle_path: str, dest_dir: str) -> ExtractedBundle:
257
+ """Extract the files the Android scan path needs from a split bundle."""
258
+ fmt = bundle_format(bundle_path)
259
+ if fmt is None:
260
+ raise SplitBundleError(f"{bundle_path} is not a .apkm/.xapk/.apks bundle.")
261
+
262
+ os.makedirs(dest_dir, exist_ok=True)
263
+
264
+ try:
265
+ zf = zipfile.ZipFile(bundle_path, "r")
266
+ except zipfile.BadZipFile:
267
+ raise SplitBundleError(
268
+ f"{os.path.basename(bundle_path)} is not a valid ZIP archive. A .{fmt} bundle is a ZIP "
269
+ f"containing base.apk plus its config splits - this file is either corrupt, truncated, "
270
+ f"or not really a {fmt.upper()} bundle."
271
+ )
272
+ except OSError as exc:
273
+ raise SplitBundleError(f"Could not read {bundle_path}: {exc}")
274
+
275
+ with zf:
276
+ _validate_zip_safety(zf, bundle_path)
277
+ names = zf.namelist()
278
+ package_name, _app_title = _read_packer_metadata(zf, names)
279
+
280
+ apk_names = [n for n in names if n.lower().endswith(".apk")]
281
+ if not apk_names:
282
+ raise SplitBundleError(
283
+ f"{os.path.basename(bundle_path)} contains no .apk files at all "
284
+ f"({len(names)} other entries). A .{fmt} bundle must contain at least a base APK - "
285
+ f"this file isn't one."
286
+ )
287
+
288
+ members: List[BundleMember] = []
289
+ for name in apk_names:
290
+ kind, token, owner = _classify_by_name(name)
291
+ member = BundleMember(name=name, size=zf.getinfo(name).file_size,
292
+ kind=kind, token=token, owner=owner)
293
+ if kind == "unknown":
294
+ member.has_dex, member.has_so = _peek_member(zf, name)
295
+ if member.has_dex is not None:
296
+ member.kind = "feature"
297
+ members.append(member)
298
+
299
+ base_path = None
300
+ base_member = None
301
+ rejected: List[str] = []
302
+ for cand in _base_candidates(members, package_name):
303
+ candidate_path = _extract_member(zf, cand.name, dest_dir, "base.apk")
304
+ reason = _validate_base_apk(candidate_path)
305
+ if reason is None:
306
+ base_path, base_member = candidate_path, cand
307
+ break
308
+ rejected.append(f"{cand.name} ({reason})")
309
+ try:
310
+ os.remove(candidate_path)
311
+ except OSError:
312
+ pass
313
+
314
+ if base_path is None:
315
+ detail = "; ".join(rejected) if rejected else "no candidate members"
316
+ raise SplitBundleError(
317
+ f"Could not find the app module (base.apk) inside "
318
+ f"{os.path.basename(bundle_path)}. Tried: {detail}. Every member either lacks an "
319
+ f"AndroidManifest.xml or lacks a classes.dex, so none of them is the real app - "
320
+ f"nothing was scanned rather than scanning a config split and reporting a "
321
+ f"misleading 'no issues found'."
322
+ )
323
+ base_member.kind = "base"
324
+ # Other app-module-looking members are device variants: don't scan twice.
325
+ for m in members:
326
+ if m.kind == "base" and m.name != base_member.name:
327
+ m.kind = "variant"
328
+
329
+ native_path: Optional[str] = None
330
+ native_abi: Optional[str] = None
331
+ native_source = "none"
332
+ try:
333
+ with zipfile.ZipFile(base_path, "r") as bzf:
334
+ base_has_so = any(n.startswith("lib/") and n.endswith(".so") for n in bzf.namelist())
335
+ except Exception:
336
+ base_has_so = False
337
+
338
+ if base_has_so:
339
+ # Pulling in an ABI split too would double-report the same libs.
340
+ native_source = "base"
341
+ else:
342
+ abi_member = _pick_abi_member(members)
343
+ if abi_member is not None:
344
+ native_path = _extract_member(
345
+ zf, abi_member.name, dest_dir, os.path.basename(abi_member.name)
346
+ )
347
+ native_abi = (abi_member.token or "").replace("_", "-")
348
+ native_source = "abi-split"
349
+
350
+ # Dex-bearing feature modules join the app module's jadx run (first input's manifest wins).
351
+ dex_bearing: List[str] = []
352
+ feature_paths: List[str] = []
353
+ skipped_features: List[str] = []
354
+ budget = _MAX_FEATURE_INPUT_BYTES
355
+ for m in sorted((m for m in members
356
+ if m.name != base_member.name and m.has_dex is True),
357
+ key=lambda m: m.size):
358
+ dex_bearing.append(m.name)
359
+ if m.size > budget:
360
+ skipped_features.append(m.name)
361
+ continue
362
+ budget -= m.size
363
+ # Prefixed so a feature named base.apk can't overwrite dest_dir files.
364
+ safe_name = "feature_" + os.path.basename(m.name)
365
+ feature_paths.append(_extract_member(zf, m.name, dest_dir, safe_name))
366
+
367
+ return ExtractedBundle(
368
+ bundle_path=bundle_path,
369
+ bundle_format=fmt,
370
+ base_apk_path=base_path,
371
+ base_member=base_member.name,
372
+ native_apk_path=native_path,
373
+ native_abi=native_abi,
374
+ native_source=native_source,
375
+ package_name=package_name,
376
+ members=members,
377
+ dex_bearing_splits=dex_bearing,
378
+ feature_apk_paths=feature_paths,
379
+ skipped_feature_splits=skipped_features,
380
+ )
381
+
382
+
383
+ def summary_lines(bundle: ExtractedBundle) -> List[Tuple[str, str]]:
384
+ """User-facing scope disclosure, as (style, text) pairs for rich."""
385
+ lines: List[Tuple[str, str]] = []
386
+ kinds: Dict[str, int] = {}
387
+ for m in bundle.members:
388
+ if m.kind != "base":
389
+ kinds[m.kind] = kinds.get(m.kind, 0) + 1
390
+
391
+ pieces = []
392
+ for kind, label in (("abi", "architecture"), ("density", "screen-density"),
393
+ ("language", "language"), ("feature", "feature-module"),
394
+ ("variant", "device-variant"), ("unknown", "unrecognised")):
395
+ if kinds.get(kind):
396
+ pieces.append(f"{kinds[kind]} {label}")
397
+ breakdown = ", ".join(pieces) if pieces else "no"
398
+
399
+ pkg = f" [{bundle.package_name}]" if bundle.package_name else ""
400
+ lines.append((
401
+ "cyan",
402
+ f"Detected .{bundle.bundle_format} split-APK bundle{pkg}: "
403
+ f"{bundle.split_count} split(s) alongside the app module ({breakdown}). "
404
+ f"Scanning '{bundle.base_member}' - it carries the full manifest, all "
405
+ f"Dalvik code and all dependency metadata."
406
+ ))
407
+
408
+ if bundle.native_source == "abi-split":
409
+ lines.append((
410
+ "cyan",
411
+ f"Native libraries live in the '{bundle.native_abi}' split (none in the app module) - "
412
+ f"analysing that split for NX/PIE/RELRO/canary/RPATH. Other architecture splits are "
413
+ f"not separately analysed."
414
+ ))
415
+ elif bundle.native_source == "base":
416
+ lines.append(("dim", "Native libraries ship inside the app module itself - analysed as usual."))
417
+ else:
418
+ abi_present = any(m.kind == "abi" for m in bundle.members)
419
+ if abi_present:
420
+ lines.append((
421
+ "yellow",
422
+ "This bundle has architecture splits but no usable one was extracted - "
423
+ "native-library hardening was NOT checked."
424
+ ))
425
+
426
+ if kinds.get("variant"):
427
+ lines.append((
428
+ "dim",
429
+ f"{kinds['variant']} additional device-variant cop{'y' if kinds['variant'] == 1 else 'ies'} "
430
+ f"of the same app module (bundletool emits one master split per device variant) - "
431
+ f"same code, scanned once."
432
+ ))
433
+
434
+ skipped = [k for k in ("density", "language") if kinds.get(k)]
435
+ if skipped:
436
+ lines.append((
437
+ "dim",
438
+ f"{'/'.join(skipped)} splits contain only resources (resources.arsc) - "
439
+ f"not separately analysed, they carry no code."
440
+ ))
441
+
442
+ if bundle.feature_apk_paths:
443
+ scanned = [n for n in bundle.dex_bearing_splits if n not in bundle.skipped_feature_splits]
444
+ lines.append((
445
+ "cyan",
446
+ f"{len(scanned)} dynamic feature module(s) carry their own Dalvik code and are "
447
+ f"decompiled together with the app module: " + ", ".join(scanned) + "."
448
+ ))
449
+
450
+ if bundle.skipped_feature_splits:
451
+ lines.append((
452
+ "bold yellow",
453
+ "INCOMPLETE SCAN: these dynamic feature module(s) carry their own Dalvik code but were "
454
+ "too large to add to this run and were NOT analysed: "
455
+ + ", ".join(bundle.skipped_feature_splits)
456
+ + ". Treat their code as UNSCANNED, not as clean."
457
+ ))
458
+
459
+ feature_configs = [m.name for m in bundle.members if m.owner]
460
+ if feature_configs:
461
+ lines.append((
462
+ "dim",
463
+ f"{len(feature_configs)} config split(s) belong to feature modules rather than the app "
464
+ f"module (e.g. {feature_configs[0]}) - not separately analysed."
465
+ ))
466
+
467
+ unclassified = [m.name for m in bundle.members if m.kind == "unknown"]
468
+ if unclassified:
469
+ lines.append((
470
+ "yellow",
471
+ "Could not determine what these bundle member(s) are, and they were NOT analysed: "
472
+ + ", ".join(unclassified) + "."
473
+ ))
474
+
475
+ return lines
@@ -0,0 +1,196 @@
1
+ """Call-site and element context gates for the Android regex rules: read the surrounding
2
+ call or manifest element and drop or re-grade the finding (rule_engine can't look around a match)."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Dict, Optional
7
+
8
+ # PendingIntent flags; jadx emits these as decimals (67108864 / 33554432).
9
+ _FLAG_IMMUTABLE = 0x04000000
10
+ _FLAG_MUTABLE = 0x02000000
11
+
12
+ # Runaway guard on how far past the match to look for the closing paren or tag.
13
+ _MAX_CALL_SPAN = 4000
14
+ _MAX_ELEMENT_SPAN = 20000
15
+
16
+ _LAUNCHER_CATEGORIES = (
17
+ "android.intent.category.LAUNCHER",
18
+ "android.intent.category.LEANBACK_LAUNCHER",
19
+ )
20
+
21
+
22
+ def _balanced_span(content: str, open_index: int, limit: int,
23
+ opener: str = "(", closer: str = ")") -> Optional[str]:
24
+ """Text between `open_index` and its matching closer, or None if unclosed."""
25
+ depth = 1
26
+ i = open_index
27
+ end = min(len(content), open_index + limit)
28
+ while i < end:
29
+ ch = content[i]
30
+ if ch == opener:
31
+ depth += 1
32
+ elif ch == closer:
33
+ depth -= 1
34
+ if depth == 0:
35
+ return content[open_index:i]
36
+ i += 1
37
+ return None
38
+
39
+
40
+ def _split_top_level_args(args: str):
41
+ parts, depth, cur = [], 0, ""
42
+ for ch in args:
43
+ if ch == "," and depth == 0:
44
+ parts.append(cur)
45
+ cur = ""
46
+ continue
47
+ if ch in "([{":
48
+ depth += 1
49
+ elif ch in ")]}":
50
+ depth -= 1
51
+ cur += ch
52
+ parts.append(cur)
53
+ return [p.strip() for p in parts]
54
+
55
+
56
+ def _pending_intent_gate(content: str, match: "re.Match",
57
+ finding: Dict[str, Any]) -> Optional[Dict[str, Any]]:
58
+ """AND-CONF-009: read the flags argument the rule is named after."""
59
+ args = _balanced_span(content, match.end(), _MAX_CALL_SPAN)
60
+ if args is None:
61
+ return finding
62
+ parts = _split_top_level_args(args)
63
+ # flags is the 4th arg of PendingIntent.getActivity(...), last as fallback.
64
+ candidates = []
65
+ if len(parts) >= 4:
66
+ candidates.append(parts[3])
67
+ if parts:
68
+ candidates.append(parts[-1])
69
+
70
+ for flag_arg in candidates:
71
+ if not flag_arg:
72
+ continue
73
+ if re.fullmatch(r"-?\d+", flag_arg):
74
+ value = int(flag_arg)
75
+ if value & _FLAG_IMMUTABLE:
76
+ return None
77
+ if value & _FLAG_MUTABLE:
78
+ return _mark_explicit_mutable(finding)
79
+ return finding
80
+ if "FLAG_IMMUTABLE" in flag_arg:
81
+ return None
82
+ if "FLAG_MUTABLE" in flag_arg:
83
+ return _mark_explicit_mutable(finding)
84
+ return finding
85
+
86
+
87
+ def _mark_explicit_mutable(finding: Dict[str, Any]) -> Dict[str, Any]:
88
+ out = dict(finding)
89
+ out["details"] = dict(finding.get("details") or {})
90
+ out["name"] = "PendingIntent Explicitly Created as Mutable"
91
+ out["details"]["description"] = (
92
+ "This PendingIntent is created with FLAG_MUTABLE, so whoever holds it can "
93
+ "fill in the unset parts of the wrapped Intent and have it sent with this "
94
+ "app's identity and permissions. That is sometimes deliberate and necessary "
95
+ "(inline notification replies, Wear OS complications, Slice/Bubble metadata), "
96
+ "but it is the mutability that makes PendingIntent hijacking possible, so the "
97
+ "wrapped Intent must have an explicit component or package set."
98
+ )
99
+ out["details"]["recommendation"] = (
100
+ "Confirm the mutability is required. If it is, make the wrapped Intent "
101
+ "explicit (setComponent/setPackage/setClass) so it cannot be redirected. If "
102
+ "it is not, switch to FLAG_IMMUTABLE."
103
+ )
104
+ return out
105
+
106
+
107
+ def _component_element(content: str, start: int) -> str:
108
+ """The full manifest element starting at `start`, self-closing or not."""
109
+ tag_match = re.match(r"<([\w.\-]+)", content[start:])
110
+ if not tag_match:
111
+ return content[start:start + _MAX_ELEMENT_SPAN]
112
+ tag = tag_match.group(1)
113
+ window = content[start:start + _MAX_ELEMENT_SPAN]
114
+ close = window.find(f"</{tag}>")
115
+ self_close = window.find("/>")
116
+ next_open = window.find("<", 1)
117
+ if self_close != -1 and (next_open == -1 or self_close < next_open):
118
+ return window[:self_close + 2]
119
+ if close != -1:
120
+ return window[:close + len(tag) + 3]
121
+ return window
122
+
123
+
124
+ def _exported_component_gate(content: str, match: "re.Match",
125
+ finding: Dict[str, Any]) -> Optional[Dict[str, Any]]:
126
+ """AND-CONF-004: a LAUNCHER entry point must be exported."""
127
+ element = _component_element(content, match.start())
128
+ if any(cat in element for cat in _LAUNCHER_CATEGORIES):
129
+ return None
130
+ return finding
131
+
132
+
133
+ # Firebase resource keys are identifiers, not secrets, per Google
134
+ # (firebase.google.com/docs/projects/api-keys): graded MEDIUM, not dropped.
135
+ _FIREBASE_KEY_RESOURCE_NAMES = (
136
+ "google_api_key", "google_crash_reporting_api_key", "google_app_id",
137
+ "google_maps_key", "com.google.android.geo.api_key",
138
+ "com.google.android.maps.v2.api_key",
139
+ )
140
+
141
+
142
+ def _google_api_key_gate(content: str, match: "re.Match",
143
+ finding: Dict[str, Any]) -> Optional[Dict[str, Any]]:
144
+ path = (finding.get("file_path") or "").replace("\\", "/").lower()
145
+ line_start = content.rfind("\n", 0, match.start()) + 1
146
+ line_end = content.find("\n", match.end())
147
+ line = content[line_start:line_end if line_end != -1 else len(content)]
148
+
149
+ in_google_services = path.endswith("google-services.json")
150
+ named_firebase = any(n in line for n in _FIREBASE_KEY_RESOURCE_NAMES)
151
+ if not (in_google_services or named_firebase):
152
+ return finding
153
+
154
+ out = dict(finding)
155
+ out["severity"] = "MEDIUM"
156
+ out["details"] = dict(finding.get("details") or {})
157
+ out["details"]["description"] = (
158
+ "A Google API key generated by the Google Services plugin (google-services.json "
159
+ "-> res/values/strings.xml) is embedded in the app. Google documents this key as "
160
+ "an IDENTIFIER rather than a secret - authorization for Firebase services comes "
161
+ "from Security Rules, IAM and App Check, not from key secrecy - so its mere "
162
+ "presence in the APK is expected and is not by itself an exposure. It is reported "
163
+ "at Medium rather than dropped because the one thing that does make it dangerous "
164
+ "cannot be seen from the binary: if the key is not restricted to this app's "
165
+ "package name and signing-certificate SHA-1, and not restricted to the specific "
166
+ "APIs it needs, anyone can lift it out of the APK and burn quota or billing "
167
+ "against your project (Maps/Places), or reach an unrestricted Identity Toolkit "
168
+ "endpoint."
169
+ )
170
+ out["details"]["recommendation"] = (
171
+ "Open Google Cloud Console -> APIs & Services -> Credentials and confirm this key "
172
+ "has (1) an Android application restriction listing this package name plus the "
173
+ "release signing certificate SHA-1, and (2) an API restriction limiting it to only "
174
+ "the APIs the app actually calls. If both are in place, this is working as "
175
+ "designed and can be accepted. Enable Firebase App Check for the backend services."
176
+ )
177
+ return out
178
+
179
+
180
+ RULE_GATES = {
181
+ "AND-CONF-009": _pending_intent_gate,
182
+ "AND-CONF-004": _exported_component_gate,
183
+ "AND-S-005": _google_api_key_gate,
184
+ }
185
+
186
+
187
+ def apply_gate(rule_id: str, content: str, match: "re.Match",
188
+ finding: Dict[str, Any]) -> Optional[Dict[str, Any]]:
189
+ """Return the finding (possibly re-graded) or None to drop it. Never raises."""
190
+ gate = RULE_GATES.get(rule_id)
191
+ if gate is None:
192
+ return finding
193
+ try:
194
+ return gate(content, match, finding)
195
+ except Exception:
196
+ return finding