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,349 @@
1
+ """Android SCA: detect third-party dependencies in an APK/AAB zip and look up
2
+ their known CVEs via OSV.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import logging
9
+ import re
10
+ import zipfile
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, List, Set, Tuple
13
+
14
+ from .osv_client import OSVClient, get_default_client
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Maven coordinates (group:artifact:version) found as .dex string constants.
19
+ _MAVEN_COORD_PATTERN = re.compile(r'([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+):([0-9][a-zA-Z0-9_.-]*)')
20
+
21
+ # Lower number = more authoritative, wins dedup.
22
+ _SOURCE_PRIORITY = {
23
+ "pom.properties": 0,
24
+ "version-string": 1,
25
+ "version-file": 2,
26
+ "gradle-metadata": 3,
27
+ "dex-strings": 4,
28
+ }
29
+
30
+ # Hard backstop so one scan can never turn into thousands of HTTP calls.
31
+ _MAX_DETAIL_QUERIES = 400
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class _Dep:
36
+ group_id: str
37
+ artifact_id: str
38
+ version: str
39
+ source: str
40
+
41
+ @property
42
+ def maven_name(self) -> str:
43
+ return f"{self.group_id}:{self.artifact_id}"
44
+
45
+
46
+ def _extract_from_version_files(zf: zipfile.ZipFile) -> List[_Dep]:
47
+ deps = []
48
+ GROUP_MAPPINGS = {
49
+ 'kotlinx_coroutines': 'org.jetbrains.kotlinx',
50
+ 'kotlinx.coroutines': 'org.jetbrains.kotlinx',
51
+ 'kotlin': 'org.jetbrains.kotlin',
52
+ }
53
+ for name in zf.namelist():
54
+ if not (name.startswith('META-INF/') and name.endswith('.version')):
55
+ continue
56
+ try:
57
+ filename = name.replace('META-INF/', '').replace('.version', '')
58
+ if '_' in filename:
59
+ parts = filename.rsplit('_', 1)
60
+ group_id = parts[0]
61
+ artifact_id = parts[1] if len(parts) > 1 else filename
62
+ else:
63
+ group_id = filename
64
+ artifact_id = filename.split('.')[-1]
65
+
66
+ version = zf.read(name).decode('utf-8', errors='ignore').strip()
67
+ if not version or len(version) > 20 or 'task' in version.lower() or ':' in version:
68
+ continue
69
+ if len(group_id) <= 3 or len(artifact_id) <= 2:
70
+ continue
71
+
72
+ for prefix, mapped_group in GROUP_MAPPINGS.items():
73
+ if group_id.startswith(prefix):
74
+ if 'kotlinx' in group_id.lower():
75
+ artifact_id = f'kotlinx-coroutines-{artifact_id}'
76
+ group_id = mapped_group
77
+ break
78
+
79
+ if version and group_id and artifact_id:
80
+ deps.append(_Dep(group_id, artifact_id, version, 'version-file'))
81
+ except Exception as e:
82
+ logger.debug(f"[SCA] Error reading {name}: {e}")
83
+ return deps
84
+
85
+
86
+ def _extract_from_pom_properties(zf: zipfile.ZipFile) -> List[_Dep]:
87
+ deps = []
88
+ for name in zf.namelist():
89
+ if 'pom.properties' not in name:
90
+ continue
91
+ try:
92
+ content = zf.read(name).decode('utf-8', errors='ignore')
93
+ group_id = artifact_id = version = None
94
+ for line in content.split('\n'):
95
+ line = line.strip()
96
+ if line.startswith('groupId='):
97
+ group_id = line.split('=', 1)[1].strip()
98
+ elif line.startswith('artifactId='):
99
+ artifact_id = line.split('=', 1)[1].strip()
100
+ elif line.startswith('version='):
101
+ version = line.split('=', 1)[1].strip()
102
+ if group_id and artifact_id and version:
103
+ deps.append(_Dep(group_id, artifact_id, version, 'pom.properties'))
104
+ except Exception as e:
105
+ logger.debug(f"[SCA] Error reading {name}: {e}")
106
+ return deps
107
+
108
+
109
+ def _extract_from_gradle_metadata(zf: zipfile.ZipFile) -> List[_Dep]:
110
+ deps = []
111
+ for name in zf.namelist():
112
+ if not (name.endswith('.module') or 'gradle-metadata' in name.lower()):
113
+ continue
114
+ try:
115
+ content = zf.read(name).decode('utf-8', errors='ignore')
116
+ data = json.loads(content)
117
+ component = data.get('component', {})
118
+ group = component.get('group')
119
+ module = component.get('module')
120
+ version = component.get('version')
121
+ if group and module and version:
122
+ deps.append(_Dep(group, module, version, 'gradle-metadata'))
123
+ except Exception as e:
124
+ logger.debug(f"[SCA] Error parsing {name}: {e}")
125
+ return deps
126
+
127
+
128
+ def _is_valid_dependency(group_id: str, artifact_id: str) -> bool:
129
+ """Filter noise coordinates from the dex-string regex pass (a dotless group id
130
+ is almost always an obfuscated class-name fragment, not a real Maven group)."""
131
+ invalid_patterns = (
132
+ 'example', 'test', 'sample', 'demo', 'mock',
133
+ 'android.support', 'androidx.', 'com.android.',
134
+ 'kotlin.', 'kotlinx.',
135
+ )
136
+ full_name = f"{group_id}.{artifact_id}".lower()
137
+ if any(p in full_name for p in invalid_patterns):
138
+ return False
139
+ if '.' not in group_id:
140
+ return False
141
+ return True
142
+
143
+
144
+ def _extract_dex_strings(dex_data: bytes) -> List[str]:
145
+ strings = []
146
+ current = []
147
+ for byte in dex_data:
148
+ if 32 <= byte <= 126:
149
+ current.append(chr(byte))
150
+ else:
151
+ if len(current) >= 10:
152
+ strings.append(''.join(current))
153
+ current = []
154
+ if len(current) >= 10:
155
+ strings.append(''.join(current))
156
+ return strings
157
+
158
+
159
+ def _extract_from_dex_packages(zf: zipfile.ZipFile) -> List[_Dep]:
160
+ deps = []
161
+ for name in zf.namelist():
162
+ if not name.endswith('.dex'):
163
+ continue
164
+ try:
165
+ dex_data = zf.read(name)
166
+ strings = _extract_dex_strings(dex_data)
167
+ for s in strings:
168
+ for group, artifact, version in _MAVEN_COORD_PATTERN.findall(s):
169
+ if _is_valid_dependency(group, artifact):
170
+ deps.append(_Dep(group, artifact, version, 'dex-strings'))
171
+ except Exception as e:
172
+ logger.debug(f"[SCA] Error reading DEX {name}: {e}")
173
+ return deps
174
+
175
+
176
+ # Recovers a version from a runtime string constant on R8-shrunk builds that
177
+ # strip pom.properties and carry no literal Maven coordinate. Kept narrow.
178
+ _VERSION_STRING_PATTERNS: List[Tuple[str, str, str]] = [
179
+ # (regex, group:artifact, description)
180
+ (r'okhttp/(\d+\.\d+\.\d+)', 'com.squareup.okhttp3:okhttp', 'OkHttp User-Agent literal'),
181
+ (r'Retrofit/(\d+\.\d+\.\d+)', 'com.squareup.retrofit2:retrofit', 'Retrofit User-Agent literal'),
182
+ ]
183
+
184
+
185
+ def _extract_from_version_strings(zf: zipfile.ZipFile) -> List[_Dep]:
186
+ deps = []
187
+ compiled = [(re.compile(p, re.IGNORECASE), coord, desc) for p, coord, desc in _VERSION_STRING_PATTERNS]
188
+ found_coords: Set[str] = set()
189
+ for name in zf.namelist():
190
+ if not name.endswith('.dex'):
191
+ continue
192
+ try:
193
+ dex_data = zf.read(name)
194
+ strings = _extract_dex_strings(dex_data)
195
+ for s in strings:
196
+ if len(s) > 200:
197
+ continue
198
+ for pattern, coord, desc in compiled:
199
+ if coord in found_coords:
200
+ continue
201
+ m = pattern.search(s)
202
+ if m:
203
+ group_id, artifact_id = coord.split(':', 1)
204
+ deps.append(_Dep(group_id, artifact_id, m.group(1), 'version-string'))
205
+ found_coords.add(coord)
206
+ logger.debug(f"[SCA] Found {coord} version {m.group(1)} via {desc}")
207
+ except Exception as e:
208
+ logger.debug(f"[SCA] Error reading DEX {name} for version strings: {e}")
209
+ return deps
210
+
211
+
212
+ def extract_dependencies(apk_path: str) -> List[_Dep]:
213
+ all_deps: List[_Dep] = []
214
+ try:
215
+ with zipfile.ZipFile(apk_path, 'r') as zf:
216
+ all_deps.extend(_extract_from_version_files(zf))
217
+ all_deps.extend(_extract_from_pom_properties(zf))
218
+ all_deps.extend(_extract_from_gradle_metadata(zf))
219
+ all_deps.extend(_extract_from_version_strings(zf))
220
+ all_deps.extend(_extract_from_dex_packages(zf))
221
+ except zipfile.BadZipFile:
222
+ logger.error(f"[SCA] Invalid APK/AAB file: {apk_path}")
223
+ except Exception as e:
224
+ logger.error(f"[SCA] Error extracting dependencies from {apk_path}: {e}")
225
+ return all_deps
226
+
227
+
228
+ def _dedupe_by_coordinate(deps: List[_Dep]) -> List[_Dep]:
229
+ best: Dict[Tuple[str, str], _Dep] = {}
230
+ for dep in deps:
231
+ key = (dep.group_id, dep.artifact_id)
232
+ current = best.get(key)
233
+ if current is None or _SOURCE_PRIORITY.get(dep.source, 9) < _SOURCE_PRIORITY.get(current.source, 9):
234
+ best[key] = dep
235
+ return list(best.values())
236
+
237
+
238
+ def _severity_style_default(sev: str) -> str:
239
+ return sev if sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW") else "MEDIUM"
240
+
241
+
242
+ def _finding_from_osv(dep: _Dep, osv_finding_dict: Dict[str, Any]) -> Dict[str, Any]:
243
+ cve_id = osv_finding_dict.get("cve") or osv_finding_dict.get("osv_id") or "UNKNOWN"
244
+ severity = _severity_style_default(osv_finding_dict.get("severity", "MEDIUM"))
245
+ fixed_version = osv_finding_dict.get("fixed_version")
246
+ summary = osv_finding_dict.get("summary") or osv_finding_dict.get("details", "")[:400] or "No summary available."
247
+
248
+ description = (
249
+ f"{dep.maven_name}@{dep.version} is affected by {cve_id} "
250
+ f"(detected via {dep.source}). {summary}"
251
+ )
252
+ recommendation = (
253
+ f"Update {dep.maven_name} to version {fixed_version} or later."
254
+ if fixed_version
255
+ else f"No fixed version is listed by OSV yet for {dep.maven_name}. "
256
+ f"Check {dep.group_id}:{dep.artifact_id}'s release notes / consider an alternative library."
257
+ )
258
+
259
+ rule_id = f"SCA-{cve_id}"
260
+ return {
261
+ "rule_id": rule_id,
262
+ "file_path": dep.maven_name,
263
+ "name": f"{cve_id}: {dep.artifact_id}@{dep.version} (vulnerable dependency)",
264
+ "severity": severity,
265
+ "details": {
266
+ "description": description,
267
+ "recommendation": recommendation,
268
+ "cwe": osv_finding_dict.get("cwe", "CWE-1104"),
269
+ "masvs": "MSTG-CODE-5",
270
+ },
271
+ # SARIF startLine requires an int; a dependency finding has no source line.
272
+ "line": 1,
273
+ "engine": "sca",
274
+ "sca": {
275
+ "package": dep.maven_name,
276
+ "version": dep.version,
277
+ "detected_via": dep.source,
278
+ "fixed_version": fixed_version,
279
+ "references": osv_finding_dict.get("references", []),
280
+ "ecosystem": "Maven",
281
+ },
282
+ }
283
+
284
+
285
+ def _rule_def_from_finding(finding: Dict[str, Any]) -> Dict[str, Any]:
286
+ return {
287
+ "id": finding["rule_id"],
288
+ "name": finding["name"],
289
+ "severity": finding["severity"],
290
+ "masvs": finding["details"]["masvs"],
291
+ "details": dict(finding["details"]),
292
+ }
293
+
294
+
295
+ def scan(apk_path: str, osv_client: OSVClient = None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]:
296
+ """Scan an APK/AAB for vulnerable third-party dependencies: (findings, rule_defs, stats)."""
297
+ client = osv_client or get_default_client()
298
+
299
+ all_deps = extract_dependencies(apk_path)
300
+ deduped = _dedupe_by_coordinate(all_deps)
301
+
302
+ # "_detected" is the pre-cap count; "_checked" is what was actually queried.
303
+ stats: Dict[str, Any] = {
304
+ "total_dependencies_detected": len(all_deps),
305
+ "unique_dependencies_detected": len(deduped),
306
+ "unique_dependencies_checked": len(deduped),
307
+ "dependencies_capped": False,
308
+ "cves_found": 0,
309
+ "vulnerable_dependencies": 0,
310
+ }
311
+
312
+ if not deduped:
313
+ return [], [], stats
314
+
315
+ if len(deduped) > _MAX_DETAIL_QUERIES:
316
+ logger.warning(
317
+ f"[SCA] {len(deduped)} unique dependencies detected - capping OSV "
318
+ f"queries at {_MAX_DETAIL_QUERIES} to stay considerate of the free "
319
+ f"public API."
320
+ )
321
+ stats["dependencies_capped"] = True
322
+ deduped = deduped[:_MAX_DETAIL_QUERIES]
323
+ stats["unique_dependencies_checked"] = len(deduped)
324
+
325
+ # Batch existence pre-filter: spend a full detail query only on packages with a vuln.
326
+ pkg_version_pairs = [(d.maven_name, d.version) for d in deduped]
327
+ has_vuln = client.has_any_vuln_batch(pkg_version_pairs, ecosystem="Maven")
328
+
329
+ findings: List[Dict[str, Any]] = []
330
+ rule_defs: List[Dict[str, Any]] = []
331
+ vulnerable_coords: Set[str] = set()
332
+
333
+ for dep in deduped:
334
+ if not dep.version or dep.version == "unknown":
335
+ continue
336
+ key = (dep.maven_name, dep.version)
337
+ if not has_vuln.get(key, False):
338
+ continue
339
+
340
+ osv_findings = client.query_dict(dep.maven_name, dep.version, ecosystem="Maven")
341
+ for osv_finding in osv_findings:
342
+ finding = _finding_from_osv(dep, osv_finding)
343
+ findings.append(finding)
344
+ rule_defs.append(_rule_def_from_finding(finding))
345
+ stats["cves_found"] += 1
346
+ vulnerable_coords.add(dep.maven_name)
347
+
348
+ stats["vulnerable_dependencies"] = len(vulnerable_coords)
349
+ return findings, rule_defs, stats