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,789 @@
1
+ """Host exposure checks run alongside the Lynis baseline: reachable unauthenticated
2
+ data services, cloud metadata, container posture, and secret/VCS disclosure under a
3
+ web root. Each fires only on a confirmed exposure; checks take a ``run`` probe callable."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import re
9
+ from dataclasses import dataclass
10
+ from typing import Callable, Dict, List, Optional
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ NARVY_ENGINE = "Narvy"
15
+
16
+
17
+ @dataclass
18
+ class Probe:
19
+ rc: int
20
+ out: str
21
+
22
+
23
+ # None means the transport failed; a check treats it as no evidence, never a finding.
24
+ RunFn = Callable[[str], Optional[Probe]]
25
+
26
+
27
+ _LOOPBACK_HOSTS = {"127.0.0.1", "::1", "[::1]", "localhost"}
28
+ _ALL_INTERFACES = {"0.0.0.0", "::", "*", "[::]", ""}
29
+
30
+
31
+ def _stdout(run: RunFn, cmd: str) -> str:
32
+ p = run(cmd)
33
+ if p is None:
34
+ return ""
35
+ return p.out or ""
36
+
37
+
38
+ def _host_port(local: str) -> Optional[tuple]:
39
+ """Split an ``ss``/``netstat`` local-address token into (host, port)."""
40
+ a = (local or "").strip()
41
+ if not a:
42
+ return None
43
+ if a.startswith("["):
44
+ end = a.find("]")
45
+ if end == -1:
46
+ return None
47
+ host = a[: end + 1]
48
+ rest = a[end + 1:]
49
+ port = rest.rsplit(":", 1)[-1] if ":" in rest else ""
50
+ else:
51
+ if ":" not in a:
52
+ return None
53
+ host, port = a.rsplit(":", 1)
54
+ if not port.isdigit():
55
+ return None
56
+ return host, int(port)
57
+
58
+
59
+ def _is_loopback(host: str) -> bool:
60
+ h = (host or "").strip().rstrip("%")
61
+ if h in _LOOPBACK_HOSTS:
62
+ return True
63
+ if h.startswith("127."):
64
+ return True
65
+ if h in ("[::1]", "::1"):
66
+ return True
67
+ return False
68
+
69
+
70
+ def _is_exposed(host: str) -> bool:
71
+ """True when a listener binds anything other than pure loopback."""
72
+ h = (host or "").strip()
73
+ if _is_loopback(h):
74
+ return False
75
+ return True
76
+
77
+
78
+ def parse_listeners(text: str) -> List[Dict[str, object]]:
79
+ """Parse ``ss -H -tlnp`` or ``netstat -tlnp`` into {proto,host,port,process,exposed} rows."""
80
+ rows: List[Dict[str, object]] = []
81
+ for raw in (text or "").splitlines():
82
+ line = raw.strip()
83
+ if not line:
84
+ continue
85
+ low = line.lower()
86
+ if low.startswith("state") or low.startswith("active") or low.startswith("proto"):
87
+ continue
88
+ parts = line.split()
89
+ local = None
90
+ proc = ""
91
+ proto = "tcp"
92
+ if low.startswith("tcp") or low.startswith("udp"):
93
+ # netstat: proto recvq sendq local foreign state pid/name
94
+ proto = parts[0]
95
+ if len(parts) >= 4:
96
+ local = parts[3]
97
+ if len(parts) >= 7:
98
+ proc = parts[6]
99
+ else:
100
+ # ss -H: State Recv-Q Send-Q Local Peer [Process]
101
+ if len(parts) >= 4:
102
+ local = parts[3]
103
+ if len(parts) >= 6:
104
+ proc = " ".join(parts[5:])
105
+ hp = _host_port(local) if local else None
106
+ if not hp:
107
+ continue
108
+ host, port = hp
109
+ m = re.search(r'"([^"]+)"', proc) or re.search(r"/([A-Za-z0-9_.-]+)", proc)
110
+ pname = (m.group(1) if m else "").strip()
111
+ rows.append({
112
+ "proto": proto,
113
+ "host": host,
114
+ "port": port,
115
+ "process": pname,
116
+ "exposed": _is_exposed(host),
117
+ })
118
+ return rows
119
+
120
+
121
+ def _f(control_id: str, title: str, severity: str, category: str,
122
+ description: str, remediation: str, *, cwe: str = "", cis: str = "",
123
+ location: str = "", discriminator: str = "",
124
+ confidence: str = "HIGH") -> Dict[str, object]:
125
+ """Build a finding in the shape report.normalize expects."""
126
+ return {
127
+ "engine": NARVY_ENGINE,
128
+ "test_id": control_id,
129
+ "control_id": control_id,
130
+ "title": title,
131
+ "severity": severity,
132
+ "category": category,
133
+ "description": description,
134
+ "details": "",
135
+ "remediation": remediation,
136
+ "cwe": cwe or None,
137
+ "cis": cis or None,
138
+ "location": location,
139
+ "confidence": confidence,
140
+ "discriminator": discriminator or f"{control_id}:{location or title[:60]}",
141
+ }
142
+
143
+
144
+ # Names that look secret but are shipped as templates -> never a real secret.
145
+ _SECRET_NAME_ALLOW = re.compile(
146
+ r"\.(example|sample|template|dist|def|md)$|(^|/)\.env\.(example|sample|template|dist)$",
147
+ re.I,
148
+ )
149
+
150
+ _SECRET_ROOTS = "/home /root /var/www /srv /opt /etc/secrets /etc/nginx /run/secrets"
151
+
152
+
153
+ def check_secrets_on_disk(run: RunFn) -> List[Dict[str, object]]:
154
+ """Flag world-readable secret material on disk (.env, credentials, private keys, DB dumps)."""
155
+ names = (
156
+ r"-name '.env' -o -name '.env.*' -o -name 'credentials' "
157
+ r"-o -name 'id_rsa' -o -name 'id_dsa' -o -name 'id_ecdsa' "
158
+ r"-o -name 'id_ed25519' -o -name '*.pem' -o -name '*.key' "
159
+ r"-o -name '*.p12' -o -name '*.pfx' -o -name '*.sql' -o -name '*.sql.gz' "
160
+ r"-o -name '*.dump' -o -name '*.bak'"
161
+ )
162
+ # No outer `sh -c "..."` wrapper: ssh_exec already hands this to one shell; wrapping would expand $f early.
163
+ cmd = (
164
+ "find " + _SECRET_ROOTS + " -maxdepth 5 -type f -perm /004 "
165
+ "\\( " + names + " \\) 2>/dev/null | while IFS= read -r f; do "
166
+ "case \"$f\" in "
167
+ "*.pem|*.key|*.p12|*.pfx|*id_rsa|*id_dsa|*id_ecdsa|*id_ed25519) "
168
+ "if grep -qi 'PRIVATE KEY' \"$f\" 2>/dev/null; then echo \"KEY $f\"; fi;; "
169
+ "*) echo \"CFG $f\";; esac; done"
170
+ )
171
+ out = _stdout(run, cmd)
172
+ findings: List[Dict[str, object]] = []
173
+ seen = set()
174
+ for line in out.splitlines():
175
+ line = line.strip()
176
+ if not line or " " not in line:
177
+ continue
178
+ kind, path = line.split(" ", 1)
179
+ path = path.strip()
180
+ if not path or path in seen:
181
+ continue
182
+ if _SECRET_NAME_ALLOW.search(path):
183
+ continue
184
+ seen.add(path)
185
+ if kind == "KEY":
186
+ title = "Private key is world-readable on disk"
187
+ desc = (
188
+ f"The private key at {path} is readable by every local account "
189
+ "on this host (other-readable permission bits set) and its "
190
+ "contents were confirmed to contain a PRIVATE KEY block. Any "
191
+ "user on the box, or any process running as an unrelated "
192
+ "service account, can copy this key and impersonate whatever it "
193
+ "authenticates (SSH access, TLS server identity, signing)."
194
+ )
195
+ rem = (
196
+ f"Restrict the key to its owner: `chmod 600 {path}` (or 400) and "
197
+ "confirm ownership with `chown`. Store service private keys "
198
+ "outside world-readable trees, and rotate this key -- treat it "
199
+ "as compromised, since you cannot prove no other local user "
200
+ "already read it."
201
+ )
202
+ cwe = "CWE-522"
203
+ else:
204
+ title = "Secret or credential file is world-readable on disk"
205
+ desc = (
206
+ f"The file {path} is readable by every local account on this "
207
+ "host (other-readable). Files with these names routinely hold "
208
+ "plaintext credentials -- API tokens, database passwords, cloud "
209
+ "access keys, or a raw database dump. Any local user or "
210
+ "compromised service on this host can read them directly, with "
211
+ "no further escalation."
212
+ )
213
+ rem = (
214
+ f"Tighten permissions to the owning user only: `chmod 600 {path}` "
215
+ "(directories 700), and verify the owner. Keep application "
216
+ "secrets out of web-served and shared directories, prefer a "
217
+ "secrets manager or environment injection over on-disk files, "
218
+ "and rotate any credential that was exposed."
219
+ )
220
+ cwe = "CWE-732"
221
+ findings.append(_f(
222
+ "narvy.host.secrets_on_disk", title, "high",
223
+ "Secrets Exposure", desc, rem, cwe=cwe,
224
+ location=path, discriminator=f"narvy.host.secrets_on_disk:{path}",
225
+ ))
226
+ return findings
227
+
228
+
229
+ @dataclass
230
+ class _Svc:
231
+ port: int
232
+ name: str
233
+
234
+
235
+ _DATA_SERVICES = {
236
+ 6379: _Svc(6379, "Redis"),
237
+ 27017: _Svc(27017, "MongoDB"),
238
+ 9200: _Svc(9200, "Elasticsearch"),
239
+ 5432: _Svc(5432, "PostgreSQL"),
240
+ 11211: _Svc(11211, "Memcached"),
241
+ 5984: _Svc(5984, "CouchDB"),
242
+ }
243
+
244
+
245
+ def _listener_text(run: RunFn) -> str:
246
+ out = _stdout(run, "ss -H -tlnp 2>/dev/null")
247
+ if out.strip():
248
+ return out
249
+ return _stdout(run, "netstat -tlnp 2>/dev/null")
250
+
251
+
252
+ def _redis_noauth(run: RunFn, port: int) -> Optional[bool]:
253
+ """True if redis answers PING without auth, False if auth required, None if unknown."""
254
+ resp = _tcp_probe(run, port, "PING\\r\\n")
255
+ if resp is None:
256
+ return None
257
+ low = resp.lower()
258
+ if "noauth" in low or "authentication" in low:
259
+ return False
260
+ if "pong" in low:
261
+ return True
262
+ return None
263
+
264
+
265
+ def _memcached_noauth(run: RunFn, port: int) -> Optional[bool]:
266
+ resp = _tcp_probe(run, port, "version\\r\\n")
267
+ if resp is None:
268
+ return None
269
+ low = resp.lower()
270
+ if low.startswith("version") or "version " in low:
271
+ return True
272
+ if "error" in low or "auth" in low:
273
+ return False
274
+ return None
275
+
276
+
277
+ def _http_json_noauth(run: RunFn, port: int, path: str) -> Optional[bool]:
278
+ """GET path on 127.0.0.1:port; True=200 (open), False=401/403 (secured)."""
279
+ code = _stdout(
280
+ run,
281
+ f"curl -s -m 3 -o /dev/null -w '%{{http_code}}' "
282
+ f"http://127.0.0.1:{port}{path} 2>/dev/null",
283
+ ).strip()
284
+ if not code or not code.isdigit():
285
+ return None
286
+ c = int(code)
287
+ if c == 200:
288
+ return True
289
+ if c in (401, 403):
290
+ return False
291
+ return None
292
+
293
+
294
+ def _tcp_probe(run: RunFn, port: int, payload: str) -> Optional[str]:
295
+ """Send payload to 127.0.0.1:port over bash /dev/tcp; None when /dev/tcp is unavailable."""
296
+ cmd = (
297
+ "bash -c 'exec 3<>/dev/tcp/127.0.0.1/%d 2>/dev/null || exit 7; "
298
+ "printf \"%s\" >&3; head -c 256 <&3; exec 3>&-' 2>/dev/null"
299
+ % (port, payload)
300
+ )
301
+ p = run(cmd)
302
+ if p is None:
303
+ return None
304
+ if p.rc == 7:
305
+ return None
306
+ return p.out or ""
307
+
308
+
309
+ def _mongo_noauth(run: RunFn) -> Optional[bool]:
310
+ """False if mongod.conf enables authorization, True if it is present and disabled."""
311
+ out = _stdout(run, "cat /etc/mongod.conf /etc/mongodb.conf 2>/dev/null")
312
+ if not out.strip():
313
+ return None
314
+ if re.search(r"authorization\s*:\s*enabled", out, re.I):
315
+ return False
316
+ if re.search(r"security\s*:", out, re.I) or "net:" in out:
317
+ return True
318
+ return None
319
+
320
+
321
+ def _postgres_trust(run: RunFn) -> List[str]:
322
+ """Return non-local pg_hba.conf entries using 'trust' (password-less) auth."""
323
+ out = _stdout(
324
+ run,
325
+ "cat /etc/postgresql/*/main/pg_hba.conf "
326
+ "/var/lib/pgsql/data/pg_hba.conf "
327
+ "/var/lib/postgresql/data/pg_hba.conf 2>/dev/null",
328
+ )
329
+ bad = []
330
+ for raw in out.splitlines():
331
+ line = raw.strip()
332
+ if not line or line.startswith("#"):
333
+ continue
334
+ parts = line.split()
335
+ if len(parts) < 5:
336
+ continue
337
+ conn_type, _db, _user, addr, method = parts[0], parts[1], parts[2], parts[3], parts[4]
338
+ if conn_type not in ("host", "hostssl", "hostnossl"):
339
+ continue
340
+ if method.lower() != "trust":
341
+ continue
342
+ if addr in ("127.0.0.1/32", "::1/128", "localhost"):
343
+ continue
344
+ bad.append(line)
345
+ return bad
346
+
347
+
348
+ def check_unauth_data_services(run: RunFn) -> List[Dict[str, object]]:
349
+ """Flag data stores reachable off loopback AND accepting no-auth access; unknown or secured produces nothing."""
350
+ rows = parse_listeners(_listener_text(run))
351
+ findings: List[Dict[str, object]] = []
352
+ flagged_ports = set()
353
+ for row in rows:
354
+ port = int(row["port"])
355
+ if port not in _DATA_SERVICES or not row["exposed"]:
356
+ continue
357
+ if port in flagged_ports:
358
+ continue
359
+ svc = _DATA_SERVICES[port]
360
+ host = str(row["host"])
361
+ noauth: Optional[bool] = None
362
+ evidence = ""
363
+ if port == 6379:
364
+ noauth = _redis_noauth(run, port)
365
+ evidence = "Redis answered PING with no AUTH"
366
+ elif port == 11211:
367
+ noauth = _memcached_noauth(run, port)
368
+ evidence = "Memcached answered a command with no authentication"
369
+ elif port == 9200:
370
+ noauth = _http_json_noauth(run, port, "/_cluster/health")
371
+ evidence = "Elasticsearch returned cluster data with no credentials"
372
+ elif port == 5984:
373
+ noauth = _http_json_noauth(run, port, "/_all_dbs")
374
+ evidence = "CouchDB returned databases with no credentials (admin party)"
375
+ elif port == 27017:
376
+ noauth = _mongo_noauth(run)
377
+ evidence = "mongod configuration does not enable authorization"
378
+ elif port == 5432:
379
+ trust = _postgres_trust(run)
380
+ if trust:
381
+ noauth = True
382
+ evidence = "pg_hba.conf grants non-local 'trust' (password-less) access"
383
+ if noauth is not True:
384
+ continue
385
+ flagged_ports.add(port)
386
+ bind = "all interfaces" if host in _ALL_INTERFACES else host
387
+ desc = (
388
+ f"{svc.name} is listening on {bind} (port {port}), reachable beyond "
389
+ f"loopback, and accepts access without authentication ({evidence}). "
390
+ "Anyone who can route a packet to this port -- another host on the "
391
+ "network, or an attacker pivoting through any service on this box -- "
392
+ "reads and modifies the entire dataset with no credentials. "
393
+ "Internet-exposed instances of these stores are found and ransomed "
394
+ "by automated scanners within hours."
395
+ )
396
+ rem = (
397
+ f"1. Bind {svc.name} to 127.0.0.1 (or a private, firewalled "
398
+ "interface) instead of a public one.\n"
399
+ "2. Enable authentication and create least-privilege accounts "
400
+ f"(for {svc.name}: a password / auth backend, per-app users).\n"
401
+ "3. Put the port behind a host firewall that permits only the "
402
+ "application hosts.\n"
403
+ "4. Assume the current data is already compromised if this was "
404
+ "reachable from an untrusted network, and rotate anything sensitive."
405
+ )
406
+ findings.append(_f(
407
+ "narvy.host.unauth_data_service",
408
+ f"{svc.name} reachable off loopback with no authentication",
409
+ "critical", "Unauthenticated Service", desc, rem,
410
+ cwe="CWE-306", location=f"{host}:{port}",
411
+ discriminator=f"narvy.host.unauth_data_service:{port}",
412
+ ))
413
+ return findings
414
+
415
+
416
+ def _http_code(run: RunFn, cmd_url: str, headers: str = "") -> Optional[int]:
417
+ code = _stdout(
418
+ run,
419
+ f"curl -s -m 2 -o /dev/null -w '%{{http_code}}' {headers} {cmd_url} 2>/dev/null",
420
+ ).strip()
421
+ if code.isdigit():
422
+ return int(code)
423
+ return None
424
+
425
+
426
+ def check_imds_exposure(run: RunFn) -> List[Dict[str, object]]:
427
+ """Flag a reachable cloud metadata endpoint (AWS IMDSv1 / GCP) serving data without a token; IMDSv2 stays quiet."""
428
+ findings: List[Dict[str, object]] = []
429
+ aws_base = "http://169.254.169.254/latest/meta-data/"
430
+ # Token-less GET: 200 => IMDSv1 open; 401 => IMDSv2 enforced; else absent.
431
+ v1 = _http_code(run, aws_base)
432
+ if v1 == 200:
433
+ role = _stdout(
434
+ run,
435
+ "curl -s -m 2 http://169.254.169.254/latest/meta-data/iam/"
436
+ "security-credentials/ 2>/dev/null",
437
+ ).strip().splitlines()
438
+ role_name = (role[0].strip() if role else "")
439
+ role_line = (
440
+ f" The instance exposes IAM role '{role_name}', so its temporary AWS "
441
+ "credentials are readable token-lessly."
442
+ if role_name and "<" not in role_name and role_name.isprintable()
443
+ and len(role_name) < 128 else ""
444
+ )
445
+ desc = (
446
+ "The AWS instance metadata service answered a request with NO session "
447
+ "token (IMDSv1 is enabled). Any code running on this instance, and "
448
+ "crucially any server-side request forgery in an application hosted "
449
+ "here, can read instance metadata and the attached IAM role's "
450
+ "temporary credentials directly from " + aws_base + "." + role_line
451
+ )
452
+ rem = (
453
+ "Require IMDSv2 (token-bound) and disable IMDSv1. On the instance "
454
+ "metadata options set HttpTokens=required (e.g. "
455
+ "`aws ec2 modify-instance-metadata-options --instance-id <id> "
456
+ "--http-tokens required --http-endpoint enabled`), and lower the "
457
+ "hop limit to 1 so containers cannot reach it. Scope the instance "
458
+ "role to least privilege, and add an egress/host rule blocking "
459
+ "169.254.169.254 for application users that never need it."
460
+ )
461
+ findings.append(_f(
462
+ "narvy.host.imds_exposure",
463
+ "Cloud metadata service (IMDSv1) is reachable without a token",
464
+ "high", "Cloud Metadata Exposure", desc, rem,
465
+ cwe="CWE-441", location="169.254.169.254",
466
+ discriminator="narvy.host.imds_exposure:aws-imdsv1",
467
+ ))
468
+ return findings
469
+
470
+ gcp = _http_code(
471
+ run,
472
+ "http://metadata.google.internal/computeMetadata/v1/instance/",
473
+ headers="-H 'Metadata-Flavor: Google'",
474
+ )
475
+ if gcp == 200:
476
+ desc = (
477
+ "The GCP instance metadata server responded. Metadata (and, via "
478
+ "`/service-accounts/`, OAuth tokens for the attached service "
479
+ "account) is reachable from any process on this instance, including "
480
+ "through an application-layer SSRF."
481
+ )
482
+ rem = (
483
+ "GCP requires the `Metadata-Flavor: Google` header, which blocks the "
484
+ "simplest SSRF, but you should still: scope the instance service "
485
+ "account to least privilege, prefer workload identity over instance "
486
+ "credentials, and restrict egress to the metadata IP for app users. "
487
+ "Confirm no application proxies arbitrary user-supplied URLs."
488
+ )
489
+ findings.append(_f(
490
+ "narvy.host.imds_exposure",
491
+ "GCP metadata server reachable from the instance",
492
+ "low", "Cloud Metadata Exposure", desc, rem,
493
+ cwe="CWE-441", location="metadata.google.internal",
494
+ discriminator="narvy.host.imds_exposure:gcp",
495
+ confidence="MEDIUM",
496
+ ))
497
+ return findings
498
+
499
+
500
+ def check_container_posture(run: RunFn) -> List[Dict[str, object]]:
501
+ """Flag Docker exposure: world-accessible socket, TCP daemon off loopback, privileged/host-net containers."""
502
+ findings: List[Dict[str, object]] = []
503
+
504
+ # Socket permissions.
505
+ sock = _stdout(
506
+ run,
507
+ "stat -c '%a %U %G' /var/run/docker.sock /run/docker.sock 2>/dev/null",
508
+ ).strip().splitlines()
509
+ for line in sock:
510
+ parts = line.split()
511
+ if not parts or not parts[0].isdigit():
512
+ continue
513
+ mode = parts[0]
514
+ other = int(mode[-1])
515
+ if other & 0o6:
516
+ desc = (
517
+ "The Docker daemon socket /var/run/docker.sock is accessible to "
518
+ f"every local user (permission mode {mode}). The Docker API is "
519
+ "root-equivalent by design: anyone who can talk to this socket "
520
+ "can start a container that mounts the host filesystem and "
521
+ "obtain root on the host. World access to the socket is a direct "
522
+ "local privilege-escalation path."
523
+ )
524
+ rem = (
525
+ "Restore restrictive ownership and permissions: "
526
+ "`chown root:docker /var/run/docker.sock && chmod 660 "
527
+ "/var/run/docker.sock`. Grant Docker access only by adding "
528
+ "trusted operators to the `docker` group (itself root-equivalent, "
529
+ "so keep it small), and never widen the socket to `other`."
530
+ )
531
+ findings.append(_f(
532
+ "narvy.host.container_posture",
533
+ "Docker socket is world-accessible", "high",
534
+ "Container Posture", desc, rem, cwe="CWE-732",
535
+ location="/var/run/docker.sock",
536
+ discriminator="narvy.host.container_posture:socket",
537
+ ))
538
+ break
539
+
540
+ # dockerd on TCP off loopback.
541
+ rows = parse_listeners(_listener_text(run))
542
+ for row in rows:
543
+ port = int(row["port"])
544
+ if port not in (2375, 2376) or not row["exposed"]:
545
+ continue
546
+ plaintext = port == 2375
547
+ sev = "critical" if plaintext else "high"
548
+ desc = (
549
+ f"The Docker daemon is listening on TCP port {port} on a "
550
+ "non-loopback interface. The Docker API is root-equivalent; port "
551
+ "2375 is unencrypted and unauthenticated, so a remote attacker who "
552
+ "reaches it gets root on this host with no credentials"
553
+ + (" -- this is the plaintext, unauthenticated Docker port."
554
+ if plaintext else
555
+ " (2376 is TLS but still must be mutually authenticated).")
556
+ )
557
+ rem = (
558
+ "Do not expose the Docker daemon over TCP. Remove the `-H tcp://` "
559
+ "flag / `hosts` entry so dockerd listens only on the local unix "
560
+ "socket. If remote access is genuinely required, use 2376 with "
561
+ "verified mutual TLS (`--tlsverify`), never 2375, and firewall it to "
562
+ "known clients."
563
+ )
564
+ findings.append(_f(
565
+ "narvy.host.container_posture",
566
+ f"Docker daemon exposed over TCP ({port})", sev,
567
+ "Container Posture", desc, rem, cwe="CWE-306",
568
+ location=f"{row['host']}:{port}",
569
+ discriminator=f"narvy.host.container_posture:tcp{port}",
570
+ ))
571
+
572
+ # Privileged / host-network containers.
573
+ ids = _stdout(run, "docker ps -q 2>/dev/null").strip().split()
574
+ if ids:
575
+ insp = _stdout(
576
+ run,
577
+ "docker inspect --format "
578
+ "'{{.Name}} priv={{.HostConfig.Privileged}} "
579
+ "net={{.HostConfig.NetworkMode}}' " + " ".join(ids[:50]) +
580
+ " 2>/dev/null",
581
+ )
582
+ privileged = []
583
+ hostnet = []
584
+ for line in insp.splitlines():
585
+ line = line.strip()
586
+ if "priv=true" in line:
587
+ privileged.append(line.split()[0].lstrip("/"))
588
+ if "net=host" in line:
589
+ hostnet.append(line.split()[0].lstrip("/"))
590
+ if privileged:
591
+ names = ", ".join(sorted(set(privileged))[:10])
592
+ desc = (
593
+ f"Running container(s) started with --privileged: {names}. A "
594
+ "privileged container disables the normal container isolation "
595
+ "(all capabilities, access to host devices), so a compromise of "
596
+ "the process inside is effectively a compromise of the host."
597
+ )
598
+ rem = (
599
+ "Remove `--privileged`. Grant only the specific Linux "
600
+ "capabilities the workload needs (`--cap-add`), mount only the "
601
+ "specific devices required, and run as a non-root user inside "
602
+ "the container. Re-create the affected containers without the "
603
+ "privileged flag."
604
+ )
605
+ findings.append(_f(
606
+ "narvy.host.container_posture",
607
+ "Privileged container(s) running", "high",
608
+ "Container Posture", desc, rem, cwe="CWE-250",
609
+ location=names,
610
+ discriminator="narvy.host.container_posture:privileged",
611
+ ))
612
+ if hostnet:
613
+ names = ", ".join(sorted(set(hostnet))[:10])
614
+ desc = (
615
+ f"Container(s) running with host networking (--net=host): "
616
+ f"{names}. The container shares the host network namespace, so "
617
+ "it can bind host ports, sniff host traffic, and reach services "
618
+ "the host reaches on loopback, erasing network isolation."
619
+ )
620
+ rem = (
621
+ "Use a bridged or user-defined network and publish only the "
622
+ "ports the service needs (`-p`). Reserve `--net=host` for "
623
+ "workloads that genuinely require it and treat those containers "
624
+ "as part of the host's trust boundary."
625
+ )
626
+ findings.append(_f(
627
+ "narvy.host.container_posture",
628
+ "Container(s) using host network namespace", "medium",
629
+ "Container Posture", desc, rem, cwe="CWE-668",
630
+ location=names,
631
+ discriminator="narvy.host.container_posture:hostnet",
632
+ ))
633
+ return findings
634
+
635
+
636
+ _WEB_ROOT_CANDIDATES = (
637
+ "/var/www", "/var/www/html", "/srv/www", "/srv/http",
638
+ "/usr/share/nginx/html", "/usr/local/apache2/htdocs", "/var/www/public",
639
+ )
640
+
641
+
642
+ def _discover_web_roots(run: RunFn) -> List[str]:
643
+ roots = set()
644
+ # nginx `root` directives.
645
+ ng = _stdout(
646
+ run,
647
+ "grep -rhoE '^[[:space:]]*root[[:space:]]+[^;]+;' /etc/nginx 2>/dev/null",
648
+ )
649
+ for line in ng.splitlines():
650
+ m = re.search(r"root\s+([^;]+);", line)
651
+ if m:
652
+ roots.add(m.group(1).strip().strip('"').strip("'"))
653
+ # apache DocumentRoot.
654
+ ap = _stdout(
655
+ run,
656
+ "grep -rhoiE 'DocumentRoot[[:space:]]+\"?[^\"]+\"?' "
657
+ "/etc/apache2 /etc/httpd 2>/dev/null",
658
+ )
659
+ for line in ap.splitlines():
660
+ m = re.search(r'DocumentRoot\s+"?([^"\s]+)"?', line, re.I)
661
+ if m:
662
+ roots.add(m.group(1).strip())
663
+ for c in _WEB_ROOT_CANDIDATES:
664
+ roots.add(c)
665
+ return [r for r in roots if r.startswith("/") and len(r) > 1]
666
+
667
+
668
+ def check_exposed_vcs_web_root(run: RunFn) -> List[Dict[str, object]]:
669
+ """Flag a ``.git/`` repo (needs ``.git/HEAD``), ``.env``, swap files, ``*.bak`` or SQL dumps under a detected web root."""
670
+ findings: List[Dict[str, object]] = []
671
+ roots = _discover_web_roots(run)
672
+ if not roots:
673
+ return findings
674
+ existing = _stdout(
675
+ run,
676
+ "sh -c 'for d in " + " ".join(_shq(r) for r in roots) +
677
+ "; do [ -d \"$d\" ] && echo \"$d\"; done'",
678
+ ).split()
679
+ seen_git = set()
680
+ seen_file = set()
681
+ for root in existing:
682
+ head = _stdout(
683
+ run, f"sh -c '[ -f {_shq(root)}/.git/HEAD ] && echo YES'",
684
+ ).strip()
685
+ if head == "YES" and root not in seen_git:
686
+ seen_git.add(root)
687
+ desc = (
688
+ f"A Git repository is present in the web root {root} "
689
+ "(.git/HEAD exists). If the web server serves this directory, "
690
+ "the entire source history -- including deleted secrets, "
691
+ "configuration and internal endpoints -- is downloadable over "
692
+ "HTTP by walking /.git/, even when directory listing is off."
693
+ )
694
+ rem = (
695
+ f"Remove the repository from the served tree (deploy build "
696
+ f"artifacts, not the working copy) or move {root}/.git outside "
697
+ "the web root. If it must stay, block it at the web server "
698
+ "(`location ~ /\\.git { deny all; }` / Apache `Require all "
699
+ "denied`), and rotate any secret that was ever committed."
700
+ )
701
+ findings.append(_f(
702
+ "narvy.host.exposed_vcs_web_root",
703
+ "Git repository exposed under a web root", "high",
704
+ "Source Disclosure", desc, rem, cwe="CWE-527",
705
+ location=f"{root}/.git",
706
+ discriminator=f"narvy.host.exposed_vcs_web_root:git:{root}",
707
+ ))
708
+ arts = _stdout(
709
+ run,
710
+ "find " + _shq(root) + " -maxdepth 2 -type f \\( "
711
+ "-name '.env' -o -name '.env.*' -o -name '*.bak' -o -name '*.old' "
712
+ "-o -name '*.sql' -o -name '*.sql.gz' -o -name '*~' "
713
+ "-o -name '.*.swp' -o -name '*.orig' \\) 2>/dev/null",
714
+ )
715
+ for path in arts.splitlines():
716
+ path = path.strip()
717
+ if not path or path in seen_file:
718
+ continue
719
+ if _SECRET_NAME_ALLOW.search(path):
720
+ continue
721
+ seen_file.add(path)
722
+ desc = (
723
+ f"A sensitive or backup file is present under the web root: "
724
+ f"{path}. Environment files, editor swap files (.swp), and "
725
+ "`.bak`/`.old`/`.sql` copies are frequently left in the served "
726
+ "tree and are downloadable directly by URL, disclosing "
727
+ "credentials or source that the running application hides."
728
+ )
729
+ rem = (
730
+ f"Delete {path} from the web root (keep backups and .env files "
731
+ "outside any served directory). Add a web-server rule denying "
732
+ "dotfiles and backup extensions, and rotate any credential that "
733
+ "the file may have exposed."
734
+ )
735
+ findings.append(_f(
736
+ "narvy.host.exposed_vcs_web_root",
737
+ "Secret or backup file exposed under a web root", "high",
738
+ "Source Disclosure", desc, rem, cwe="CWE-538",
739
+ location=path,
740
+ discriminator=f"narvy.host.exposed_vcs_web_root:file:{path}",
741
+ ))
742
+ return findings
743
+
744
+
745
+ def _shq(s: str) -> str:
746
+ """Minimal single-quote shell-quoting for a discovered path."""
747
+ return "'" + str(s).replace("'", "'\\''") + "'"
748
+
749
+
750
+ ALL_CHECKS = (
751
+ check_secrets_on_disk,
752
+ check_unauth_data_services,
753
+ check_imds_exposure,
754
+ check_container_posture,
755
+ check_exposed_vcs_web_root,
756
+ )
757
+
758
+ NARVY_HOST_CHECK_COUNT = len(ALL_CHECKS)
759
+
760
+
761
+ def run_checks(run: RunFn) -> List[Dict[str, object]]:
762
+ """Run every host check; one failing check never sinks the others."""
763
+ findings: List[Dict[str, object]] = []
764
+ for check in ALL_CHECKS:
765
+ try:
766
+ findings.extend(check(run) or [])
767
+ except Exception: # noqa: BLE001
768
+ logger.warning("narvy host check %s failed", getattr(check, "__name__", "?"),
769
+ exc_info=True)
770
+ return findings
771
+
772
+
773
+ def build_ssh_runner(conn, prefix: str) -> RunFn:
774
+ """Build a probe runner executing commands on `conn` over SSH with `prefix` (e.g. ``sudo -n ``)."""
775
+ from .ssh_exec import ssh_exec, SSHExecError
776
+
777
+ def run(cmd: str) -> Optional[Probe]:
778
+ try:
779
+ r = ssh_exec(conn, f"{prefix}{cmd}" if prefix else cmd, timeout=45)
780
+ except SSHExecError:
781
+ return None
782
+ return Probe(r.returncode, r.stdout or "")
783
+
784
+ return run
785
+
786
+
787
+ def run_narvy_host_checks(conn, prefix: str) -> List[Dict[str, object]]:
788
+ """Run the host checks against a live host."""
789
+ return run_checks(build_ssh_runner(conn, prefix))