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,358 @@
1
+ """Parse a Lynis `lynis-report.dat` into Narvy host-audit findings.
2
+
3
+ The `.dat` is a flat key=value file; repeated keys use a `key[]=` suffix.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from .host_knowledge import lookup
12
+
13
+ ENGINE_NAME = "Lynis (GPL-3.0), orchestrated by Narvy"
14
+
15
+ # Canonical upstream reference for a Lynis control id.
16
+ LYNIS_REF_BASE = "https://cisofy.com/lynis/controls/"
17
+
18
+ _WARNING_DOWNGRADE = {
19
+ "KRNL-5830": "low",
20
+ }
21
+
22
+ # "LYNIS" is Lynis's own outdated-version notice, not a host control.
23
+ _SKIP_TEST_IDS = {"LYNIS"}
24
+
25
+ _TESTID_GROUP_CATEGORY = {
26
+ "ACCT": "Accounting & Auditing",
27
+ "AUTH": "Authentication",
28
+ "BANN": "Banners & Policy",
29
+ "BOOT": "Boot & Services",
30
+ "CONT": "Containers",
31
+ "CRYP": "Cryptography",
32
+ "DBS": "Databases",
33
+ "DEB": "Package Management",
34
+ "FILE": "Filesystem",
35
+ "FINT": "File Integrity",
36
+ "FIRE": "Firewall",
37
+ "HRDN": "Hardening",
38
+ "HTTP": "Web Server",
39
+ "INSE": "Insecure Services",
40
+ "KRNL": "Kernel",
41
+ "LOGG": "Logging",
42
+ "MACF": "Mandatory Access Control",
43
+ "MAIL": "Mail",
44
+ "MALW": "Malware Protection",
45
+ "NAME": "DNS & Naming",
46
+ "NETW": "Network",
47
+ "PHP": "PHP",
48
+ "PKGS": "Packages & Patching",
49
+ "PRNT": "Printing",
50
+ "SCHD": "Scheduled Tasks",
51
+ "SHLL": "Shell",
52
+ "SQD": "Squid Proxy",
53
+ "SSH": "SSH",
54
+ "STRG": "Storage",
55
+ "TIME": "Time Sync",
56
+ "TOOL": "Security Tooling",
57
+ "USB": "USB Devices",
58
+ "VIRT": "Virtualization",
59
+ }
60
+
61
+
62
+ def _category_for(test_id: str) -> str:
63
+ group = (test_id or "").split("-", 1)[0].upper()
64
+ return _TESTID_GROUP_CATEGORY.get(group, "Host Hardening")
65
+
66
+ # TEST-ID -> (CWE, CIS control label). Unmapped ids still produce a finding.
67
+ _TESTID_MAP = {
68
+ "DBS-1820": ("CWE-306", "CIS 5.x Database Authentication"),
69
+ "PKGS-7392": ("CWE-1104", "CIS 1.9 Patch Management"),
70
+ "SSH-7408": ("CWE-16", "CIS 5.2 SSH Server Configuration"),
71
+ "AUTH-9328": ("CWE-521", "CIS 5.4 Password Policy"),
72
+ "FIRE-4513": ("CWE-1327", "CIS 3.5 Firewall Configuration"),
73
+ "BOOT-5122": ("CWE-284", "CIS 1.4 Bootloader Password"),
74
+ "KRNL-5820": ("CWE-284", "CIS 1.5 Kernel Hardening"),
75
+ }
76
+
77
+ _SENSITIVE_LISTEN = {
78
+ "mongod": "MongoDB", "mysqld": "MySQL", "postgres": "PostgreSQL",
79
+ "redis-server": "Redis", "memcached": "Memcached",
80
+ "elasticsearch": "Elasticsearch", "mongos": "MongoDB",
81
+ }
82
+
83
+ # Loopback / link-local / RFC1918 = not exposed. 0.0.0.0 and :: (all interfaces) are handled separately.
84
+ _PRIVATE_ADDR_RE = re.compile(
85
+ r"^(127\.|::1$|\[::1\]|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|fe80:)"
86
+ )
87
+ _ALL_INTERFACES = {"0.0.0.0", "::", "*", "[::]"}
88
+
89
+
90
+ def parse_lynis_report(dat_text: str,
91
+ absent_modules: Optional[set] = None) -> Dict[str, Any]:
92
+ """Return {meta, findings, hardening_index} from the raw report text."""
93
+ meta: Dict[str, str] = {}
94
+ warnings: List[str] = []
95
+ suggestions: List[str] = []
96
+ listens: List[str] = []
97
+
98
+ for raw in dat_text.splitlines():
99
+ line = raw.strip()
100
+ if not line or line.startswith("#") or "=" not in line:
101
+ continue
102
+ key, _, val = line.partition("=")
103
+ if key == "warning[]":
104
+ warnings.append(val)
105
+ elif key == "suggestion[]":
106
+ suggestions.append(val)
107
+ elif key == "network_listen[]":
108
+ listens.append(val)
109
+ elif key in ("hardening_index", "lynis_version", "os_fullname",
110
+ "hostname", "os_version"):
111
+ meta.setdefault(key, val)
112
+
113
+ exposed_services = _exposed_service_names(listens)
114
+
115
+ findings: List[Dict[str, Any]] = []
116
+ for w in warnings:
117
+ findings.append(_finding_from_pipe(w, kind="warning",
118
+ exposed_services=exposed_services))
119
+ for s in suggestions:
120
+ findings.append(_finding_from_pipe(s, kind="suggestion",
121
+ exposed_services=exposed_services))
122
+ findings.extend(_exposure_findings(listens))
123
+
124
+ # The EOL-OS control's text matches no _variant_of() pattern; attach the distro from meta.
125
+ os_evidence = meta.get("os_fullname") or meta.get("os_version") or ""
126
+ if os_evidence:
127
+ for f in findings:
128
+ if f and f.get("control_id") == "NARVY-HOST-OS-001":
129
+ f["title"] = f"{f['title']} ({os_evidence})"
130
+ f["discriminator"] = f"{f['control_id']}:{os_evidence}"
131
+ f["description"] = f"{f['description']}\n\nDetected: {os_evidence}"
132
+
133
+ seen = set()
134
+ clean: List[Dict[str, Any]] = []
135
+ for f in findings:
136
+ if not f:
137
+ continue
138
+ # Drop findings about kernel modules the host confirmed it lacks.
139
+ variant = (f.get("_variant") or "").strip()
140
+ if variant and variant in (absent_modules or set()):
141
+ continue
142
+ k = f.get("discriminator") or (f.get("test_id"), f.get("title"))
143
+ if k in seen:
144
+ continue
145
+ seen.add(k)
146
+ clean.append(f)
147
+
148
+ try:
149
+ hi = int(meta.get("hardening_index", "")) if meta.get("hardening_index") else None
150
+ except ValueError:
151
+ hi = None
152
+
153
+ return {"meta": meta, "hardening_index": hi, "findings": clean}
154
+
155
+
156
+ # TEST-ID -> the service token to look for in the listener list.
157
+ _EXPOSURE_SENSITIVE_CONTROLS = {
158
+ "DBS-1820": "mongod",
159
+ }
160
+
161
+
162
+ def _exposed_service_names(listens: List[str]) -> set:
163
+ """Service names bound to a routable / all-interfaces address."""
164
+ exposed = set()
165
+ for l in listens:
166
+ parts = l.split("|")
167
+ if len(parts) < 4:
168
+ continue
169
+ addr, service = parts[2].strip(), parts[3].strip().lower()
170
+ host = _host_of(addr)
171
+ if host in _ALL_INTERFACES or not _PRIVATE_ADDR_RE.match(host):
172
+ exposed.add(service)
173
+ return exposed
174
+
175
+
176
+ def _adjust_for_exposure(test_id: str, severity: str,
177
+ exposed_services: set) -> tuple:
178
+ """Downgrade a 'no authentication' finding when the service is loopback-only."""
179
+ svc = _EXPOSURE_SENSITIVE_CONTROLS.get(test_id)
180
+ if not svc or severity != "high":
181
+ return severity, ""
182
+ if any(svc in s for s in exposed_services):
183
+ return severity, ""
184
+ return "medium", (
185
+ "\n\nScope: this service is currently bound to loopback only, so it is "
186
+ "not reachable from the network. Severity is reduced accordingly. It "
187
+ "still matters: any local account, or a compromise of any application "
188
+ "on this host, reaches the data with no further authentication."
189
+ )
190
+
191
+
192
+ def _variant_of(text: str) -> str:
193
+ """Pull the distinguishing token from a sibling suggestion so siblings don't collapse into one row."""
194
+ m = re.search(r"'([^']{1,40})'", text)
195
+ if m:
196
+ return m.group(1)
197
+ m = re.search(r"(/[a-z][a-z0-9_/.-]{1,30})", text)
198
+ if m:
199
+ return m.group(1)
200
+ m = re.search(r"\b(minimum|maximum)\b", text, re.I)
201
+ if m:
202
+ return m.group(1).lower()
203
+ return ""
204
+
205
+
206
+ def _finding_from_pipe(val: str, *, kind: str,
207
+ exposed_services: Optional[set] = None) -> Optional[Dict[str, Any]]:
208
+ # TESTID|description|details|solution| -- split capped at 4 so a stray '|' in solution doesn't shift fields.
209
+ if val.endswith("|"):
210
+ val = val[:-1]
211
+ parts = val.split("|", 3)
212
+ test_id = parts[0].strip() if parts else ""
213
+ if not test_id or test_id.upper() in _SKIP_TEST_IDS:
214
+ return None
215
+ desc = parts[1].strip() if len(parts) > 1 else ""
216
+ details = _clean(parts[2]) if len(parts) > 2 else ""
217
+ solution_advice, solution_url = _parse_solution(parts[3] if len(parts) > 3 else "")
218
+ reference = solution_url or _lynis_ref(test_id)
219
+
220
+ # Prefer the curated KB entry; fall back to raw fields when absent.
221
+ kb = lookup(test_id)
222
+ raw_title = desc or test_id
223
+
224
+ if kb:
225
+ variant = _variant_of(f"{desc} {details}")
226
+ title = f"{kb.title} ({variant})" if variant else kb.title
227
+ description = kb.description
228
+ if variant:
229
+ description = f"{description}\n\nAffected: {variant}"
230
+ severity, scope_note = _adjust_for_exposure(
231
+ test_id, kb.severity, exposed_services or set())
232
+ if scope_note:
233
+ description = description + scope_note
234
+ return {
235
+ "engine": ENGINE_NAME,
236
+ "test_id": test_id,
237
+ "control_id": kb.control_id,
238
+ "title": title,
239
+ "severity": severity,
240
+ "category": _category_for(test_id),
241
+ "description": description,
242
+ "details": "",
243
+ "remediation": kb.remediation,
244
+ "cwe": kb.cwe or None,
245
+ "cis": kb.cis or None,
246
+ "location": kb.location or "",
247
+ "reference": reference,
248
+ "discriminator": f"{kb.control_id}:{variant}" if variant else kb.control_id,
249
+ "_variant": variant,
250
+ }
251
+
252
+ sev = _WARNING_DOWNGRADE.get(test_id, "medium") if kind == "warning" else "low"
253
+ cwe, cis = _TESTID_MAP.get(test_id, (None, None))
254
+ # Generic control-id group from the test-id prefix.
255
+ group = test_id.split("-", 1)[0].upper()
256
+ generic_id = f"NARVY-HOST-GEN-{group}"
257
+ return {
258
+ "engine": ENGINE_NAME,
259
+ "test_id": test_id,
260
+ "control_id": generic_id,
261
+ "title": raw_title,
262
+ "severity": sev,
263
+ "category": _category_for(test_id),
264
+ "description": desc,
265
+ "details": details,
266
+ "remediation": solution_advice or (
267
+ "Review this control against your hardening baseline and apply your "
268
+ "platform's recommended configuration."
269
+ ),
270
+ "cwe": cwe,
271
+ "cis": cis,
272
+ "location": "",
273
+ "reference": reference,
274
+ "discriminator": f"{generic_id}:{raw_title[:60]}",
275
+ }
276
+
277
+
278
+ def _exposure_findings(listens: List[str]) -> List[Dict[str, Any]]:
279
+ out: List[Dict[str, Any]] = []
280
+ for l in listens:
281
+ # source|proto|address:port|service|
282
+ parts = l.split("|")
283
+ if len(parts) < 4:
284
+ continue
285
+ proto, addr, service = parts[1].strip(), parts[2].strip(), parts[3].strip()
286
+ host = _host_of(addr)
287
+ if host not in _ALL_INTERFACES and _PRIVATE_ADDR_RE.match(host):
288
+ continue
289
+ svc_key = next((k for k in _SENSITIVE_LISTEN if k in service.lower()), None)
290
+ if not svc_key:
291
+ continue
292
+ name = _SENSITIVE_LISTEN[svc_key]
293
+ out.append({
294
+ "engine": ENGINE_NAME,
295
+ "test_id": "NETW-EXPOSURE",
296
+ "control_id": "NARVY-HOST-NETW-900",
297
+ "discriminator": f"NARVY-HOST-NETW-900:{addr}",
298
+ "title": f"{name} listening on a routable address ({addr})",
299
+ "severity": "medium",
300
+ "category": "network-exposure",
301
+ "description": (
302
+ f"{name} ({service}) is bound to {addr}/{proto}, reachable "
303
+ f"beyond localhost. Confirm authentication and firewall rules."
304
+ ),
305
+ "details": l,
306
+ "remediation": (
307
+ "Bind the service to 127.0.0.1 or a private interface, require "
308
+ "authentication, and restrict access with a host firewall."
309
+ ),
310
+ "cwe": "CWE-668",
311
+ "cis": "CIS 3.x Network Configuration",
312
+ "location": f"listen:{addr}",
313
+ })
314
+ return out
315
+
316
+
317
+ def _host_of(addr: str) -> str:
318
+ """Extract the host part of a listen address, IPv6-safe."""
319
+ a = addr.strip()
320
+ if a.startswith("["):
321
+ return a[: a.find("]") + 1] if "]" in a else a
322
+ if a.count(":") > 1:
323
+ return a
324
+ host = a.rsplit(":", 1)[0] if ":" in a else a
325
+ return host.split("%", 1)[0]
326
+
327
+
328
+ def _clean(s: str) -> str:
329
+ s = (s or "").strip()
330
+ s = s.rstrip("|").strip()
331
+ # Fields are directive-tokened: `text:` is advice, `url:` a link. Strip the prefix, keep the value.
332
+ low = s.lower()
333
+ if low.startswith("text:"):
334
+ s = s[5:].strip()
335
+ elif low.startswith("url:"):
336
+ s = s[4:].strip()
337
+ return "" if s in ("-", "") else s
338
+
339
+
340
+ def _parse_solution(raw: str) -> tuple:
341
+ """Split a Lynis solution field (directive-tokened `text:`/`url:`) into (advice, reference url)."""
342
+ s = (raw or "").strip().rstrip("|").strip()
343
+ if not s or s == "-":
344
+ return "", ""
345
+ low = s.lower()
346
+ if low.startswith("url:"):
347
+ return "", s[4:].strip()
348
+ if low.startswith("text:"):
349
+ return s[5:].strip(), ""
350
+ return s, ""
351
+
352
+
353
+ def _lynis_ref(test_id: str) -> str:
354
+ """Canonical upstream reference URL for a Lynis control id."""
355
+ tid = (test_id or "").strip()
356
+ if not tid:
357
+ return ""
358
+ return f"{LYNIS_REF_BASE}{tid}/"