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.
- narvy/__init__.py +3 -0
- narvy/android/__init__.py +0 -0
- narvy/android/source_analyzer.py +268 -0
- narvy/android/split_bundle.py +475 -0
- narvy/android_rule_context.py +196 -0
- narvy/apk_memory_preflight.py +248 -0
- narvy/auth.py +40 -0
- narvy/ci_templates/bitbucket.yml +25 -0
- narvy/ci_templates/github.yml +60 -0
- narvy/ci_templates/gitlab.yml +32 -0
- narvy/cloud/__init__.py +0 -0
- narvy/cloud/aws_scan.py +1188 -0
- narvy/comment_filter.py +134 -0
- narvy/crypto_taint_lite.py +82 -0
- narvy/decompiler.py +337 -0
- narvy/doctor.py +244 -0
- narvy/host/__init__.py +0 -0
- narvy/host/audit.py +157 -0
- narvy/host/host_knowledge.py +982 -0
- narvy/host/lynis_bootstrap.py +400 -0
- narvy/host/lynis_parser.py +358 -0
- narvy/host/narvy_checks.py +789 -0
- narvy/host/report.py +69 -0
- narvy/host/ssh_exec.py +219 -0
- narvy/ios/__init__.py +0 -0
- narvy/ios/binary_analyzer.py +1201 -0
- narvy/ios/plist_checks.py +249 -0
- narvy/ios/source_analyzer.py +301 -0
- narvy/ios/third_party_filter.py +242 -0
- narvy/ios/trust_all_context.py +66 -0
- narvy/main.py +1983 -0
- narvy/native_hardening.py +503 -0
- narvy/reporter.py +151 -0
- narvy/rule_engine.py +57 -0
- narvy/rules/android/config.yml +77 -0
- narvy/rules/android/crypto.yml +34 -0
- narvy/rules/android/secrets.yml +161 -0
- narvy/rules/android/storage.yml +24 -0
- narvy/rules/android/webview.yml +23 -0
- narvy/rules/android.yml +219 -0
- narvy/rules/ios/objc/crypto.yml +56 -0
- narvy/rules/ios/objc/network.yml +67 -0
- narvy/rules/ios/objc/secrets.yml +79 -0
- narvy/rules/ios/objc/storage.yml +45 -0
- narvy/rules/ios/objc/webview.yml +45 -0
- narvy/rules/ios_swift.yml +327 -0
- narvy/rules/web/go.yml +2837 -0
- narvy/rules/web/java.yml +1576 -0
- narvy/rules/web/javascript.yml +3683 -0
- narvy/rules/web/kotlin.yml +413 -0
- narvy/rules/web/local/csharp_narvy/config.yml +61 -0
- narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
- narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
- narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
- narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
- narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
- narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
- narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
- narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
- narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
- narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
- narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
- narvy/rules/web/local/java_narvy/sqli.yml +235 -0
- narvy/rules/web/local/java_narvy/xxe.yml +212 -0
- narvy/rules/web/php.yml +1644 -0
- narvy/rules/web/python.yml +3967 -0
- narvy/rules/web/ruby.yml +703 -0
- narvy/rules/web/rust.yml +258 -0
- narvy/rules/web/secrets.yml +1420 -0
- narvy/rules/web/secrets_supplement.yml +383 -0
- narvy/sca/__init__.py +1 -0
- narvy/sca/android_deps.py +349 -0
- narvy/sca/ios_deps.py +578 -0
- narvy/sca/osv_client.py +617 -0
- narvy/sca/web_deps.py +955 -0
- narvy/scope_config.py +195 -0
- narvy/semgrep_engine.py +219 -0
- narvy/stack_protector_evidence.py +96 -0
- narvy/third_party_filter.py +211 -0
- narvy/uploader.py +92 -0
- narvy/weak_prng_context.py +270 -0
- narvy/web/__init__.py +0 -0
- narvy/web/nuclei_binary.py +105 -0
- narvy/web/scan_blocklist.py +96 -0
- narvy/web/scanner.py +842 -0
- narvy/web/source_analyzer.py +714 -0
- narvy/web/ssrf_guard.py +374 -0
- narvy_cli-1.0.0.dist-info/METADATA +165 -0
- narvy_cli-1.0.0.dist-info/RECORD +93 -0
- narvy_cli-1.0.0.dist-info/WHEEL +5 -0
- narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- 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))
|