@bergabruh/system-scanner 0.1.0

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.
@@ -0,0 +1,801 @@
1
+ #!/usr/bin/env python3
2
+ """Consent-gated, dependency-free Linux host security scan orchestrator.
3
+
4
+ The MCP server discovers available host-security tools, previews a fixed argv
5
+ for each adapter, and runs only allowlisted commands. It deliberately has no
6
+ shell execution, package installation, remediation, packet-file output, or
7
+ automatic external port probing.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import ipaddress
12
+ import json
13
+ import os
14
+ import platform
15
+ import re
16
+ import secrets
17
+ import shutil
18
+ import stat
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import urllib.error
23
+ import urllib.request
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ MAX_OUTPUT = 256 * 1024
29
+ RUNS: dict[str, dict[str, Any]] = {}
30
+ PROFILE_NAME = ".mnogovid-system-scanner.json"
31
+
32
+
33
+ def _fixed(*argv: str) -> list[str]:
34
+ return list(argv)
35
+
36
+
37
+ ADAPTERS: dict[str, dict[str, Any]] = {
38
+ "lynis": {"category": "hardening", "exe": "lynis", "network": False, "traffic": False, "timeout": 900, "argv": lambda _: _fixed("audit", "system", "--no-colors")},
39
+ "clamav": {"category": "malware", "exe": "clamscan", "network": False, "traffic": False, "timeout": 3600, "argv": lambda _: _fixed("--recursive", "--infected", "--no-summary", "--exclude-dir=^/proc", "--exclude-dir=^/sys", "--exclude-dir=^/dev", "/")},
40
+ "rkhunter": {"category": "rootkit", "exe": "rkhunter", "network": False, "traffic": False, "timeout": 1800, "argv": lambda _: _fixed("--check", "--skip-keypress", "--report-warnings-only")},
41
+ "chkrootkit": {"category": "rootkit", "exe": "chkrootkit", "network": False, "traffic": False, "timeout": 1800, "argv": lambda _: _fixed()},
42
+ "aide": {"category": "integrity", "exe": "aide", "network": False, "traffic": False, "timeout": 1800, "argv": lambda _: _fixed("--check")},
43
+ "debsecan": {"category": "vulnerabilities", "exe": "debsecan", "network": False, "traffic": False, "timeout": 300, "argv": lambda _: _fixed("--format", "detail")},
44
+ "rpm-verify": {"category": "integrity", "exe": "rpm", "network": False, "traffic": False, "timeout": 900, "argv": lambda _: _fixed("-Va")},
45
+ "osquery": {"category": "inventory", "exe": "osqueryi", "network": False, "traffic": False, "timeout": 300, "argv": lambda _: _fixed("--json", "SELECT p.pid,p.name,p.path,p.uid FROM processes p WHERE p.on_disk = 0 OR p.path = ''; ")},
46
+ "listeners": {"category": "exposure", "exe": "ss", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("-H", "-lntup")},
47
+ "nftables": {"category": "firewall", "exe": "nft", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("list", "ruleset")},
48
+ "systemd-enabled": {"category": "persistence", "exe": "systemctl", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("list-unit-files", "--state=enabled", "--no-legend", "--no-pager")},
49
+ "systemd-timers": {"category": "persistence", "exe": "systemctl", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("list-timers", "--all", "--no-legend", "--no-pager")},
50
+ "iptables": {"category": "firewall", "exe": "iptables-save", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed()},
51
+ "ufw": {"category": "firewall", "exe": "ufw", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("status", "verbose")},
52
+ "audit-rules": {"category": "audit", "exe": "auditctl", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("-l")},
53
+ "journal-warnings": {"category": "logs", "exe": "journalctl", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("--no-pager", "--since", "24 hours ago", "--priority", "warning", "--output", "short-iso", "--lines", "1000")},
54
+ "kernel-modules": {"category": "kernel", "exe": "lsmod", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed()},
55
+ "docker-containers": {"category": "containers", "exe": "docker", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("ps", "--all", "--no-trunc", "--format", "{{json .}}")},
56
+ "docker-security-options": {"category": "container-hardening", "exe": "docker", "network": False, "traffic": False, "sensitiveOutput": True, "timeout": 60, "argv": lambda _: _fixed("info", "--format", "{{json .SecurityOptions}}")},
57
+ "docker-inspect": {"category": "container-hardening", "exe": "docker", "network": False, "traffic": False, "sensitiveOutput": True, "timeout": 60, "argv": lambda args: _docker_inspect_args(args)},
58
+ "podman-containers": {"category": "containers", "exe": "podman", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("ps", "--all", "--no-trunc", "--format", "json")},
59
+ "debsums": {"category": "integrity", "exe": "debsums", "network": False, "traffic": False, "timeout": 1800, "argv": lambda _: _fixed("--changed")},
60
+ "nginx-config": {"category": "web-hardening", "exe": "nginx", "network": False, "traffic": False, "timeout": 60, "argv": lambda _: _fixed("-t")},
61
+ "mysql-status": {"category": "database-posture", "exe": "mysqladmin", "network": False, "traffic": False, "serviceProbe": True, "sensitiveOutput": True, "timeout": 15, "argv": lambda _: _fixed("--protocol=socket", "--connect-timeout=3", "status")},
62
+ "postgres-status": {"category": "database-posture", "exe": "pg_isready", "network": False, "traffic": False, "serviceProbe": True, "sensitiveOutput": True, "timeout": 15, "argv": lambda _: _fixed("--timeout=3")},
63
+ "redis-info": {"category": "database-posture", "exe": "redis-cli", "network": False, "traffic": False, "serviceProbe": True, "sensitiveOutput": True, "timeout": 15, "argv": lambda _: _fixed("--no-auth-warning", "INFO", "server")},
64
+ "mongodb-status": {"category": "database-posture", "exe": "mongosh", "network": False, "traffic": False, "serviceProbe": True, "sensitiveOutput": True, "timeout": 15, "argv": lambda _: _fixed("--quiet", "--eval", "JSON.stringify(db.serverStatus({uptime:1,connections:1,security:1,transportSecurity:1}))")},
65
+ "clickhouse-version": {"category": "database-posture", "exe": "clickhouse-client", "network": False, "traffic": False, "serviceProbe": True, "sensitiveOutput": True, "timeout": 15, "argv": lambda _: _fixed("--query", "SELECT version()")},
66
+ "trivy-image": {"category": "container-vulnerabilities", "exe": "trivy", "network": True, "traffic": False, "sensitiveOutput": True, "timeout": 1800, "argv": lambda args: _image_args("image", ["--format", "json", "--scanners", "vuln"], args)},
67
+ "grype-image": {"category": "container-vulnerabilities", "exe": "grype", "network": True, "traffic": False, "sensitiveOutput": True, "timeout": 1800, "argv": lambda args: _image_args("", ["-o", "json"], args)},
68
+ "dockle-image": {"category": "container-hardening", "exe": "dockle", "network": False, "traffic": False, "sensitiveOutput": True, "timeout": 900, "argv": lambda args: _image_args("", ["--exit-code", "0"], args)},
69
+ "nmap-local": {"category": "exposure", "exe": "nmap", "network": True, "traffic": False, "timeout": 900, "active": True, "argv": lambda args: _nmap_args(args)},
70
+ "tshark-summary": {"category": "traffic", "exe": "tshark", "network": False, "traffic": True, "timeout": 360, "argv": lambda args: _tshark_args(args)},
71
+ }
72
+
73
+
74
+ def _nmap_args(args: dict[str, Any]) -> list[str]:
75
+ target = args.get("target")
76
+ if not isinstance(target, str):
77
+ raise ValueError("nmap-local requires target as an IP address")
78
+ try:
79
+ ipaddress.ip_address(target)
80
+ except ValueError as exc:
81
+ raise ValueError("nmap-local target must be one literal IP address") from exc
82
+ if args.get("authorizedTarget") is not True:
83
+ raise ValueError("nmap-local requires authorizedTarget=true for the named IP")
84
+ return ["-sV", "--version-light", "--top-ports", "100", "--reason", target]
85
+
86
+
87
+ def _tshark_args(args: dict[str, Any]) -> list[str]:
88
+ interface = args.get("interface")
89
+ duration = args.get("durationSeconds", 30)
90
+ capture_filter = args.get("captureFilter")
91
+ if not isinstance(interface, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,32}", interface):
92
+ raise ValueError("tshark-summary requires a valid interface name")
93
+ if not isinstance(duration, int) or not 5 <= duration <= 300:
94
+ raise ValueError("durationSeconds must be an integer from 5 through 300")
95
+ argv = ["-n", "-i", interface, "-a", f"duration:{duration}", "-c", "10000", "-T", "fields", "-e", "frame.time_epoch", "-e", "ip.src", "-e", "ip.dst", "-e", "_ws.col.Protocol", "-e", "frame.len", "-E", "separator=,", "-E", "quote=d"]
96
+ if capture_filter is not None:
97
+ if not isinstance(capture_filter, str) or not 1 <= len(capture_filter) <= 256:
98
+ raise ValueError("captureFilter must be a non-empty string at most 256 characters")
99
+ argv.extend(["-f", capture_filter])
100
+ return argv
101
+
102
+
103
+ def _docker_inspect_args(args: dict[str, Any]) -> list[str]:
104
+ container_id = args.get("containerId")
105
+ if not isinstance(container_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", container_id):
106
+ raise ValueError("docker-inspect requires one Docker container ID or name")
107
+ return ["inspect", "--type", "container", container_id]
108
+
109
+
110
+ def _image_args(subcommand: str, prefix: list[str], args: dict[str, Any]) -> list[str]:
111
+ image = args.get("imageRef")
112
+ if not isinstance(image, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/@:+-]{0,255}", image):
113
+ raise ValueError("image scanner requires one Docker image reference")
114
+ return ([subcommand] if subcommand else []) + prefix + [image]
115
+
116
+
117
+ TOOLS = [
118
+ {"name": "system_catalog", "description": "List allowlisted Linux host security, exposure, and traffic-observation adapters.", "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}},
119
+ {"name": "system_doctor", "description": "Read local OS identity and check allowlisted executable availability. It does not execute a scanner.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}}, "required": ["reportDirectory"], "additionalProperties": False}},
120
+ {"name": "system_bootstrap", "description": "Check the system-scanner profile and local toolchain before a scan. Set createProfile=true only after explicit user approval to create a missing profile; it does not run a scanner.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}, "createProfile": {"type": "boolean"}}, "required": ["reportDirectory"], "additionalProperties": False}},
121
+ {"name": "system_plan", "description": "Create a non-executing plan for all available Linux host scanners and observation tools.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}}, "required": ["reportDirectory"], "additionalProperties": False}},
122
+ {"name": "system_virtual_run", "description": "Preview one exact allowlisted command without starting it. nmap requires an explicitly authorized IP; image scanners need one image reference; docker-inspect needs one container ID or name.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}, "adapter": {"type": "string"}, "target": {"type": "string"}, "authorizedTarget": {"type": "boolean"}, "containerId": {"type": "string"}, "imageRef": {"type": "string"}, "interface": {"type": "string"}, "durationSeconds": {"type": "integer"}, "captureFilter": {"type": "string"}}, "required": ["reportDirectory", "adapter"], "additionalProperties": False}},
123
+ {"name": "system_run", "description": "Execute one allowlisted local command without a shell. It requires a started lifecycle and identical preview; networked image scanners, active ports, traffic capture, and local service probes require matching lifecycle consent.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}, "runId": {"type": "string"}, "adapter": {"type": "string"}, "target": {"type": "string"}, "authorizedTarget": {"type": "boolean"}, "containerId": {"type": "string"}, "imageRef": {"type": "string"}, "interface": {"type": "string"}, "durationSeconds": {"type": "integer"}, "captureFilter": {"type": "string"}}, "required": ["reportDirectory", "runId", "adapter"], "additionalProperties": False}},
124
+ {"name": "system_ingest", "description": "Normalize an existing private local JSON or SARIF host-security report inside the selected report directory without executing a program.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}, "report": {"type": "string"}, "format": {"enum": ["json", "sarif"]}, "adapter": {"type": "string"}}, "required": ["reportDirectory", "report", "format"], "additionalProperties": False}},
125
+ {"name": "system_start_run", "description": "Start a durable, consent-owned system assessment lifecycle. No scanner is executed and no report is written.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}, "mode": {"enum": ["scan", "scan-ai", "scan-agent"]}, "consent": {"type": "object", "properties": {"profileWrite": {"type": "boolean"}, "network": {"type": "boolean"}, "activeNetwork": {"type": "boolean"}, "trafficCapture": {"type": "boolean"}, "serviceProbe": {"type": "boolean"}, "aiTriage": {"type": "boolean"}, "agentReview": {"type": "boolean"}}, "additionalProperties": False}}, "required": ["reportDirectory", "mode", "consent"], "additionalProperties": False}},
126
+ {"name": "system_record_run", "description": "Append a preview, scanner result, skipped reason, host-AI triage, or independent review to a started system assessment.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}, "runId": {"type": "string"}, "kind": {"enum": ["scanner", "preview", "skipped", "host_ai_triage", "agent_review"]}, "entry": {"type": "object"}}, "required": ["reportDirectory", "runId", "kind", "entry"], "additionalProperties": False}},
127
+ {"name": "system_finalize_run", "description": "Write the redacted Markdown report for a completed lifecycle under <reportDirectory>/.mnogovid/system-scanner/.", "inputSchema": {"type": "object", "properties": {"reportDirectory": {"type": "string"}, "runId": {"type": "string"}, "initialization": {"type": "object"}, "doctor": {"type": "object"}, "plan": {"type": "object"}, "hostAiTriage": {"type": "object"}, "agentReview": {"type": "object"}}, "required": ["reportDirectory", "runId"], "additionalProperties": False}},
128
+ {"name": "system_ai_triage_payload", "description": "Produce a bounded redacted finding payload for host-model triage. It never contacts a model.", "inputSchema": {"type": "object", "properties": {"findings": {"type": "array"}}, "required": ["findings"], "additionalProperties": False}},
129
+ {"name": "system_advisory_lookup", "description": "Query OSV for one installed package version only after explicit network approval. It never installs or changes packages.", "inputSchema": {"type": "object", "properties": {"ecosystem": {"type": "string"}, "package": {"type": "string"}, "version": {"type": "string"}, "allowNetwork": {"type": "boolean"}}, "required": ["ecosystem", "package", "version", "allowNetwork"], "additionalProperties": False}},
130
+ ]
131
+
132
+
133
+ def report_directory(value: Any) -> Path:
134
+ if not isinstance(value, str) or not value:
135
+ raise ValueError("reportDirectory must be a non-empty directory path")
136
+ raw = Path(value).expanduser()
137
+ if raw.is_symlink():
138
+ raise ValueError("reportDirectory must not be a symlink")
139
+ path = raw.resolve()
140
+ info = os.lstat(path)
141
+ if not stat.S_ISDIR(info.st_mode):
142
+ raise ValueError(f"reportDirectory is not a directory: {path}")
143
+ if info.st_uid != os.geteuid() or info.st_mode & 0o022:
144
+ raise ValueError("reportDirectory must be owned by this user and not group/world writable")
145
+ return path
146
+
147
+
148
+ def ensure_private_directory(path: Path) -> None:
149
+ """Create or validate a user-owned, non-symlinked report component."""
150
+ try:
151
+ info = os.lstat(path)
152
+ except FileNotFoundError:
153
+ path.mkdir(mode=0o700)
154
+ info = os.lstat(path)
155
+ if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode):
156
+ raise ValueError(f"refusing non-directory or symlink report component: {path.name}")
157
+ if info.st_uid != os.geteuid() or info.st_mode & 0o022:
158
+ raise ValueError(f"report component must be private and owned by this user: {path.name}")
159
+ os.chmod(path, 0o700)
160
+
161
+
162
+ def run_directory(root: Path, run_id: str, create: bool) -> Path:
163
+ reports = root / ".mnogovid"
164
+ scanner_reports = reports / "system-scanner"
165
+ if create:
166
+ ensure_private_directory(reports)
167
+ ensure_private_directory(scanner_reports)
168
+ ensure_private_directory(scanner_reports / run_id)
169
+ else:
170
+ for item in (reports, scanner_reports, scanner_reports / run_id):
171
+ if not item.exists():
172
+ raise ValueError("unknown or expired runId")
173
+ ensure_private_directory(item)
174
+ return scanner_reports / run_id
175
+
176
+
177
+ def atomic_write(path: Path, document: str, replace: bool) -> None:
178
+ if path.exists() or path.is_symlink():
179
+ info = os.lstat(path)
180
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
181
+ raise ValueError(f"refusing non-regular or symlink output: {path.name}")
182
+ if not replace:
183
+ raise ValueError(f"refusing to overwrite existing output: {path.name}")
184
+ temp = path.parent / f".{path.name}.{secrets.token_hex(12)}.tmp"
185
+ descriptor = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
186
+ try:
187
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
188
+ handle.write(document)
189
+ handle.flush()
190
+ os.fsync(handle.fileno())
191
+ os.replace(temp, path)
192
+ os.chmod(path, 0o600)
193
+ except Exception:
194
+ try:
195
+ temp.unlink(missing_ok=True)
196
+ except OSError:
197
+ pass
198
+ raise
199
+
200
+
201
+ def read_regular_file(path: Path, limit: int | None = None) -> str:
202
+ info = os.lstat(path)
203
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
204
+ raise ValueError("refusing to read non-regular or symlink state file")
205
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
206
+ with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
207
+ content = handle.read(limit + 1) if limit is not None else handle.read()
208
+ if limit is not None and len(content.encode("utf-8")) > limit:
209
+ raise ValueError("input exceeds the bounded report size limit")
210
+ return content
211
+
212
+
213
+ def private_input_report(root: Path, value: Any) -> Path:
214
+ if not isinstance(value, str) or not value:
215
+ raise ValueError("report must be a non-empty path inside reportDirectory")
216
+ candidate = Path(value).expanduser()
217
+ path = candidate if candidate.is_absolute() else root / candidate
218
+ try:
219
+ relative = path.relative_to(root)
220
+ except ValueError as exc:
221
+ raise ValueError("report must be inside reportDirectory") from exc
222
+ if not relative.parts or any(part in (".", "..") for part in relative.parts):
223
+ raise ValueError("report must be a descendant file inside reportDirectory")
224
+ current = root
225
+ for index, part in enumerate(relative.parts):
226
+ current = current / part
227
+ info = os.lstat(current)
228
+ if stat.S_ISLNK(info.st_mode):
229
+ raise ValueError("report path must not contain a symlink")
230
+ final = index == len(relative.parts) - 1
231
+ if final and not stat.S_ISREG(info.st_mode):
232
+ raise ValueError("report must be a regular file")
233
+ if not final and not stat.S_ISDIR(info.st_mode):
234
+ raise ValueError("report path contains a non-directory component")
235
+ if info.st_uid != os.geteuid() or info.st_mode & 0o022:
236
+ raise ValueError("report and its path components must be private and owned by this user")
237
+ return current
238
+
239
+
240
+ def read_os_release() -> dict[str, str]:
241
+ data: dict[str, str] = {"kernel": platform.release(), "machine": platform.machine(), "system": platform.system()}
242
+ path = Path("/etc/os-release")
243
+ if path.is_file():
244
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
245
+ if "=" in line and not line.startswith("#"):
246
+ key, value = line.split("=", 1)
247
+ data[key.lower()] = value.strip().strip('"')
248
+ return data
249
+
250
+
251
+ def discover_host() -> dict[str, Any]:
252
+ os_release = read_os_release()
253
+ package_managers = [name for name in ("apt-get", "dnf", "yum", "pacman", "zypper", "apk", "rpm", "dpkg") if shutil.which(name)]
254
+ runtimes = [name for name in ("docker", "podman", "containerd", "kubectl") if shutil.which(name)]
255
+ surfaces = []
256
+ if Path("/run/systemd/system").exists(): surfaces.append("systemd")
257
+ cgroup_path = Path("/proc/1/cgroup")
258
+ cgroup = cgroup_path.read_text(encoding="utf-8", errors="replace").lower() if cgroup_path.is_file() else ""
259
+ if Path("/.dockerenv").exists() or "docker" in cgroup or "kubepods" in cgroup: surfaces.append("container")
260
+ if runtimes: surfaces.append("container-runtime")
261
+ if shutil.which("nft") or shutil.which("iptables-save") or shutil.which("ufw"): surfaces.append("firewall")
262
+ if Path("/var/log/journal").exists(): surfaces.append("persistent-journal")
263
+ return {"os": os_release, "packageManagers": package_managers, "containerRuntimes": runtimes, "surfaces": sorted(set(surfaces)), "kernel": {"release": platform.release(), "machine": platform.machine()}}
264
+
265
+
266
+ def recommend_host(found: dict[str, Any]) -> list[str]:
267
+ adapters = ["lynis", "clamav", "rkhunter", "chkrootkit", "aide", "debsums", "debsecan", "rpm-verify", "osquery", "listeners", "nftables", "iptables", "ufw", "audit-rules", "journal-warnings", "kernel-modules", "systemd-enabled", "systemd-timers", "nginx-config", "mysql-status", "postgres-status", "redis-info", "mongodb-status", "clickhouse-version"]
268
+ managers = set(found.get("packageManagers", []))
269
+ if "rpm" not in managers: adapters.remove("rpm-verify")
270
+ if "apt-get" not in managers and "dpkg" not in managers: adapters.remove("debsecan")
271
+ if "docker" in found.get("containerRuntimes", []): adapters += ["docker-containers", "docker-security-options", "docker-inspect", "trivy-image", "grype-image", "dockle-image"]
272
+ if "podman" in found.get("containerRuntimes", []): adapters.append("podman-containers")
273
+ adapters += ["nmap-local", "tshark-summary"]
274
+ return adapters
275
+
276
+
277
+ def plan(_: Path) -> dict[str, Any]:
278
+ found = discover_host()
279
+ runs = []
280
+ for ident in recommend_host(found):
281
+ spec = ADAPTERS[ident]
282
+ available = shutil.which(spec["exe"]) is not None
283
+ runs.append({"adapter": ident, "category": spec["category"], "executable": spec["exe"], "available": available, "requiresActiveNetwork": spec.get("network", False), "requiresTrafficCapture": spec.get("traffic", False), "execution": "not_executed"})
284
+ return {"host": found, "recommendedAdapters": recommend_host(found), "runs": runs, "processStarted": False, "networkUsed": False, "trafficCaptured": False}
285
+
286
+
287
+ def bootstrap(root: Path, create_profile: bool) -> dict[str, Any]:
288
+ if not isinstance(create_profile, bool):
289
+ raise ValueError("createProfile must be boolean when supplied")
290
+ profile_path = root / PROFILE_NAME
291
+ profile: dict[str, Any]
292
+ if profile_path.exists() or profile_path.is_symlink():
293
+ info = os.lstat(profile_path)
294
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
295
+ raise ValueError("refusing non-regular or symlinked system-scanner profile")
296
+ if info.st_uid != os.geteuid() or info.st_mode & 0o022:
297
+ raise ValueError("system-scanner profile must be private and owned by this user")
298
+ try:
299
+ saved = json.loads(read_regular_file(profile_path, MAX_OUTPUT))
300
+ except json.JSONDecodeError:
301
+ saved = None
302
+ valid = isinstance(saved, dict) and saved.get("schemaVersion") == 1 and saved.get("generatedBy") in {"mnogovid-system-scanner bootstrap", "mnogovid-system-scanner init"}
303
+ profile = {"path": str(profile_path), "action": "verified" if valid else "invalid", "valid": valid}
304
+ elif create_profile:
305
+ discovered = plan(root)
306
+ saved = {"schemaVersion": 1, "generatedBy": "mnogovid-system-scanner bootstrap", "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), "recommendedAdapters": discovered["recommendedAdapters"], "availableAdapters": [item["adapter"] for item in discovered["runs"] if item["available"]], "notes": ["This profile records discovery only and grants no scanner permission.", "Every scanner still requires an explicit lifecycle preview and approval."]}
307
+ atomic_write(profile_path, json.dumps(saved, ensure_ascii=False, indent=2) + "\n", replace=False)
308
+ profile = {"path": str(profile_path), "action": "created", "valid": True}
309
+ else:
310
+ profile = {"path": str(profile_path), "action": "missing", "valid": False}
311
+ discovered = plan(root)
312
+ return {"profile": profile, "doctor": {**discovered, "missingExecutables": [item["executable"] for item in discovered["runs"] if not item["available"]]}, "processStarted": False}
313
+
314
+
315
+ def redact(value: Any) -> Any:
316
+ if isinstance(value, dict):
317
+ return {str(k): redact("[REDACTED]" if re.search(r"(token|secret|password|api.?key|private.?key)", str(k), re.I) else v) for k, v in value.items()}
318
+ if isinstance(value, list):
319
+ return [redact(item) for item in value]
320
+ if isinstance(value, str) and re.search(r"(ghp_|sk-|AKIA|-----BEGIN|(?:token|secret|password|api[_-]?key|authorization|bearer|cookie|session)\s*[=:])", value, re.I):
321
+ return "[REDACTED]"
322
+ return value
323
+
324
+
325
+ def bounded_text(value: Any, limit: int = 500) -> str:
326
+ return str(redact(value)).replace("\r", " ").replace("\n", " ")[:limit]
327
+
328
+
329
+ def safe_text(value: Any, limit: int = 500) -> str:
330
+ text = bounded_text(value, limit)
331
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("|", "\\|").replace("[", "\\[").replace("]", "\\]").replace("(", "\\(").replace(")", "\\)")
332
+
333
+
334
+ def safe_json(value: Any) -> str:
335
+ return json.dumps(redact(value), ensure_ascii=True, indent=2).replace("`", "\\u0060").replace("<", "\\u003c").replace(">", "\\u003e")
336
+
337
+
338
+ def normalize_consent(value: Any) -> dict[str, bool]:
339
+ keys = {"profileWrite", "network", "activeNetwork", "trafficCapture", "serviceProbe", "aiTriage", "agentReview"}
340
+ if not isinstance(value, dict) or set(value) - keys:
341
+ raise ValueError("consent may contain only known boolean permission fields")
342
+ if any(not isinstance(item, bool) for item in value.values()):
343
+ raise ValueError("every consent value must be boolean")
344
+ return {key: value.get(key, False) for key in keys}
345
+
346
+
347
+ def normalize_entry(kind: str, entry: dict[str, Any], finding_count: int) -> dict[str, Any]:
348
+ if kind == "skipped":
349
+ if not isinstance(entry.get("adapter"), str) or not isinstance(entry.get("reason"), str):
350
+ raise ValueError("skipped entry requires adapter and reason strings")
351
+ return {"adapter": bounded_text(entry["adapter"], 80), "reason": bounded_text(entry["reason"])}
352
+ if kind in ("preview", "scanner"):
353
+ adapter = entry.get("adapter")
354
+ if adapter not in ADAPTERS:
355
+ raise ValueError(f"{kind} entry must name an allowlisted adapter")
356
+ command_value = entry.get("command") if isinstance(entry.get("command"), dict) else {}
357
+ argv = command_value.get("argv")
358
+ if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and len(item) <= 512 for item in argv):
359
+ raise ValueError(f"{kind} entry requires a bounded command argv")
360
+ result: dict[str, Any] = {"adapter": adapter, "category": ADAPTERS[adapter]["category"], "command": {"argv": [bounded_text(item, 512) for item in argv], "currentDir": bounded_text(command_value.get("currentDir", ""), 512)}}
361
+ if kind == "preview":
362
+ result.update({"requiresNetwork": bool(entry.get("requiresNetwork")), "requiresActiveNetwork": bool(entry.get("requiresActiveNetwork")), "requiresTrafficCapture": bool(entry.get("requiresTrafficCapture")), "requiresServiceProbe": bool(entry.get("requiresServiceProbe")), "resultStatus": "not_executed"})
363
+ return result
364
+ if entry.get("resultStatus") not in ("complete", "failed", "incomplete") or not isinstance(entry.get("exitCode"), int):
365
+ raise ValueError("scanner entry requires resultStatus and integer exitCode")
366
+ findings = entry.get("findings", [])
367
+ observations = entry.get("observations", [])
368
+ if not isinstance(findings, list) or not isinstance(observations, list):
369
+ raise ValueError("scanner findings and observations must be arrays")
370
+ normalized_findings = []
371
+ for item in findings[:200]:
372
+ if not isinstance(item, dict): continue
373
+ normalized_findings.append({"adapter": adapter, "ruleId": bounded_text(item.get("ruleId", item.get("id", "")), 160), "severity": bounded_text(item.get("severity", "review"), 40), "title": bounded_text(item.get("title", "")), "location": bounded_text(item.get("location", item.get("path", "")), 512), "line": item.get("line") if isinstance(item.get("line"), int) else None, "library": bounded_text(item.get("library", item.get("package", "")), 160), "installedVersion": bounded_text(item.get("installedVersion", item.get("version", "")), 160), "fixedVersion": bounded_text(item.get("fixedVersion", ""), 160)})
374
+ result.update({"resultStatus": entry["resultStatus"], "exitCode": entry["exitCode"], "requiresNetwork": bool(entry.get("requiresNetwork")), "requiresActiveNetwork": bool(entry.get("requiresActiveNetwork")), "requiresTrafficCapture": bool(entry.get("requiresTrafficCapture")), "requiresServiceProbe": bool(entry.get("requiresServiceProbe")), "findings": normalized_findings, "observations": [bounded_text(item) for item in observations[:200] if isinstance(item, str)]})
375
+ result["counts"] = {"findings": len(result["findings"]), "observations": len(result["observations"])}
376
+ return result
377
+ notes = entry.get("findingNotes")
378
+ if not isinstance(notes, list) or len(notes) != finding_count:
379
+ raise ValueError("triage entry requires exactly one findingNotes item for every recorded finding")
380
+ normalized = []
381
+ for expected, note in enumerate(notes):
382
+ if not isinstance(note, dict) or note.get("findingIndex") != expected or note.get("classification") not in ("true_positive", "false_positive", "needs_review"):
383
+ raise ValueError("triage notes must use ordered findingIndex and a known classification")
384
+ confidence = note.get("confidence")
385
+ if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1 or not isinstance(note.get("note"), str):
386
+ raise ValueError("triage notes require confidence from 0 through 1 and a text note")
387
+ normalized.append({"findingIndex": expected, "classification": note["classification"], "confidence": confidence, "note": bounded_text(note["note"], 2000)})
388
+ return {"findingNotes": normalized}
389
+
390
+
391
+ def command(ident: str, args: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
392
+ if ident not in ADAPTERS:
393
+ raise ValueError(f"unknown adapter: {ident}")
394
+ spec = ADAPTERS[ident]
395
+ exe = shutil.which(spec["exe"]) or spec["exe"]
396
+ return spec, [exe, *spec["argv"](args)]
397
+
398
+
399
+ def normalize_output(adapter: str, output: str) -> tuple[list[dict[str, Any]], list[str]]:
400
+ """Keep inventory separate from a security finding.
401
+
402
+ A listening socket or enabled unit is evidence to review, not proof of a
403
+ compromise. Only adapter-specific warning signatures become findings.
404
+ """
405
+ lines = [line.strip() for line in output.splitlines() if line.strip()]
406
+ patterns: dict[str, re.Pattern[str]] = {
407
+ "lynis": re.compile(r"\b(WARNING|SUGGESTION)\b", re.I),
408
+ "clamav": re.compile(r"\bFOUND$", re.I),
409
+ "rkhunter": re.compile(r"\b(Warning|Rootkit|Suspicious)\b", re.I),
410
+ "chkrootkit": re.compile(r"\b(INFECTED|Vulnerable|Warning)\b", re.I),
411
+ "aide": re.compile(r"^(?:added|removed|changed)\b|\b(?:Added|Removed|Changed)\b"),
412
+ "debsecan": re.compile(r"\b(?:CVE|DSA|USN)-", re.I),
413
+ "rpm-verify": re.compile(r"^[.A-Z?]{8,9}\s"),
414
+ "osquery": re.compile(r".+"),
415
+ "nmap-local": re.compile(r"\bopen\b", re.I),
416
+ "debsums": re.compile(r"^\S+\s+\S+"),
417
+ "journal-warnings": re.compile(r".+"),
418
+ }
419
+ matcher = patterns.get(adapter)
420
+ matched = [line for line in lines if matcher and matcher.search(line)]
421
+ findings = [{"adapter": adapter, "severity": "review", "title": line[:500]} for line in matched[:200]]
422
+ observations = [line[:500] for line in lines[:200] if line not in matched]
423
+ return findings, observations
424
+
425
+
426
+ def normalize_docker_inspect(output: str) -> tuple[list[dict[str, Any]], list[str]]:
427
+ try:
428
+ raw = json.loads(output)
429
+ except json.JSONDecodeError:
430
+ return [], ["Docker inspect did not return JSON; review the redacted command diagnostic."]
431
+ containers = raw if isinstance(raw, list) else [raw]
432
+ findings: list[dict[str, Any]] = []
433
+ observations: list[str] = []
434
+ for container in containers[:1]:
435
+ if not isinstance(container, dict): continue
436
+ host = container.get("HostConfig") if isinstance(container.get("HostConfig"), dict) else {}
437
+ config = container.get("Config") if isinstance(container.get("Config"), dict) else {}
438
+ if host.get("Privileged") is True: findings.append({"adapter": "docker-inspect", "severity": "high", "title": "Container is privileged"})
439
+ if host.get("NetworkMode") == "host": findings.append({"adapter": "docker-inspect", "severity": "high", "title": "Container uses host networking"})
440
+ if host.get("PidMode") == "host" or host.get("IpcMode") == "host": findings.append({"adapter": "docker-inspect", "severity": "high", "title": "Container shares a host namespace"})
441
+ caps = host.get("CapAdd")
442
+ if isinstance(caps, list) and caps: findings.append({"adapter": "docker-inspect", "severity": "review", "title": "Container adds Linux capabilities beyond Docker defaults"})
443
+ options = host.get("SecurityOpt")
444
+ if isinstance(options, list) and any("unconfined" in str(option).lower() for option in options): findings.append({"adapter": "docker-inspect", "severity": "high", "title": "Container disables a confinement profile"})
445
+ mounts = container.get("Mounts") if isinstance(container.get("Mounts"), list) else []
446
+ if any(isinstance(mount, dict) and str(mount.get("Source")) == "/" for mount in mounts): findings.append({"adapter": "docker-inspect", "severity": "high", "title": "Container mounts the host root filesystem"})
447
+ if any(isinstance(mount, dict) and str(mount.get("Destination")) == "/var/run/docker.sock" for mount in mounts): findings.append({"adapter": "docker-inspect", "severity": "high", "title": "Container receives the Docker socket"})
448
+ user = config.get("User")
449
+ if user in (None, "", "0", "root"): observations.append("Container process is configured to run as root or leaves the user unspecified")
450
+ return findings[:200], observations[:200]
451
+
452
+
453
+ def normalize_docker_security_options(output: str) -> tuple[list[dict[str, Any]], list[str]]:
454
+ try:
455
+ options = json.loads(output)
456
+ except json.JSONDecodeError:
457
+ return [], ["Docker security-option query did not return JSON; raw output was withheld."]
458
+ if not isinstance(options, list):
459
+ return [], ["Docker daemon returned a security-option response"]
460
+ return [], [f"Docker daemon reports {len(options)} configured security options"]
461
+
462
+
463
+ def normalize_service_probe(adapter: str, output: str) -> tuple[list[dict[str, Any]], list[str]]:
464
+ text = output.lower()
465
+ if adapter == "postgres-status" and ("rejecting" in text or "no response" in text):
466
+ return [{"adapter": adapter, "severity": "review", "title": "PostgreSQL is not accepting the local readiness probe"}], []
467
+ if adapter == "nginx-config" and "test is successful" in text:
468
+ return [], ["Nginx configuration syntax check completed successfully"]
469
+ if adapter == "clickhouse-version" and output.strip():
470
+ return [], ["ClickHouse local client returned a server version"]
471
+ if adapter in {"mysql-status", "redis-info", "mongodb-status", "postgres-status"} and output.strip():
472
+ return [], [f"{adapter} returned a local read-only status response"]
473
+ return [], []
474
+
475
+
476
+ def normalize_image_scan(adapter: str, output: str) -> tuple[list[dict[str, Any]], list[str]]:
477
+ if adapter == "dockle-image":
478
+ findings = []
479
+ for line in output.splitlines():
480
+ match = re.match(r"^(FATAL|WARN)\s*-\s*([A-Z0-9-]+):\s*(.+)$", line.strip())
481
+ if match:
482
+ findings.append({"adapter": adapter, "ruleId": match.group(2), "severity": "high" if match.group(1) == "FATAL" else "review", "title": match.group(3)[:300]})
483
+ return findings[:200], []
484
+ try:
485
+ data = json.loads(output)
486
+ except json.JSONDecodeError:
487
+ return [], ["Image scanner did not return parseable JSON; raw output was withheld."]
488
+ findings: list[dict[str, Any]] = []
489
+ if adapter == "trivy-image":
490
+ results = data.get("Results", []) if isinstance(data, dict) else []
491
+ for result in results if isinstance(results, list) else []:
492
+ target = result.get("Target") if isinstance(result, dict) else None
493
+ vulnerabilities = result.get("Vulnerabilities", []) if isinstance(result, dict) else []
494
+ for vulnerability in vulnerabilities if isinstance(vulnerabilities, list) else []:
495
+ if isinstance(vulnerability, dict):
496
+ findings.append({"adapter": adapter, "ruleId": vulnerability.get("VulnerabilityID"), "severity": vulnerability.get("Severity") or "review", "title": vulnerability.get("Title") or vulnerability.get("PkgName") or vulnerability.get("VulnerabilityID"), "library": vulnerability.get("PkgName"), "installedVersion": vulnerability.get("InstalledVersion"), "fixedVersion": vulnerability.get("FixedVersion"), "location": target})
497
+ elif adapter == "grype-image":
498
+ matches = data.get("matches", []) if isinstance(data, dict) else []
499
+ for match in matches if isinstance(matches, list) else []:
500
+ if not isinstance(match, dict): continue
501
+ vulnerability = match.get("vulnerability") if isinstance(match.get("vulnerability"), dict) else {}
502
+ artifact = match.get("artifact") if isinstance(match.get("artifact"), dict) else {}
503
+ fix = vulnerability.get("fix") if isinstance(vulnerability.get("fix"), dict) else {}
504
+ findings.append({"adapter": adapter, "ruleId": vulnerability.get("id"), "severity": vulnerability.get("severity") or "review", "title": vulnerability.get("description") or vulnerability.get("id"), "library": artifact.get("name"), "installedVersion": artifact.get("version"), "fixedVersion": ", ".join(fix.get("versions", [])) if isinstance(fix.get("versions"), list) else None})
505
+ return findings[:200], []
506
+
507
+
508
+ def parse_ingested_report(value: Any, adapter: str | None = None) -> list[dict[str, Any]]:
509
+ findings: list[dict[str, Any]] = []
510
+ if isinstance(value, dict) and isinstance(value.get("runs"), list):
511
+ for run in value["runs"]:
512
+ for result in run.get("results", []) if isinstance(run, dict) else []:
513
+ if not isinstance(result, dict): continue
514
+ location = ((result.get("locations") or [{}])[0].get("physicalLocation") or {}) if isinstance(result.get("locations"), list) else {}
515
+ region = location.get("region") if isinstance(location, dict) else {}
516
+ artifact = location.get("artifactLocation") if isinstance(location, dict) else {}
517
+ findings.append({"adapter": adapter or "sarif", "ruleId": result.get("ruleId"), "severity": str(result.get("level") or "review").upper(), "title": result.get("message", {}).get("text", "") if isinstance(result.get("message"), dict) else "", "location": artifact.get("uri") if isinstance(artifact, dict) else None, "line": region.get("startLine") if isinstance(region, dict) else None})
518
+ elif isinstance(value, dict) and isinstance(value.get("findings"), list):
519
+ findings = [item for item in value["findings"] if isinstance(item, dict)][:200]
520
+ elif isinstance(value, dict) and isinstance(value.get("matches"), list):
521
+ for match in value["matches"][:200]:
522
+ if not isinstance(match, dict): continue
523
+ vulnerability = match.get("vulnerability") if isinstance(match.get("vulnerability"), dict) else {}
524
+ artifact = match.get("artifact") if isinstance(match.get("artifact"), dict) else {}
525
+ findings.append({"adapter": adapter or "report", "ruleId": vulnerability.get("id"), "severity": vulnerability.get("severity") or "review", "title": vulnerability.get("description") or vulnerability.get("id"), "library": artifact.get("name"), "installedVersion": artifact.get("version")})
526
+ elif isinstance(value, list):
527
+ findings = [item for item in value if isinstance(item, dict)][:200]
528
+ return [{"adapter": bounded_text(item.get("adapter", adapter or "report"), 80), "ruleId": bounded_text(item.get("ruleId", item.get("id", "")), 160), "severity": bounded_text(item.get("severity", "review"), 40), "title": bounded_text(item.get("title", item.get("message", "Imported finding"))), "location": bounded_text(item.get("location", item.get("path", "")), 512), "line": item.get("line"), "library": bounded_text(item.get("library", item.get("package", "")), 160), "installedVersion": bounded_text(item.get("installedVersion", item.get("version", "")), 160), "fixedVersion": bounded_text(item.get("fixedVersion", ""), 160)} for item in findings]
529
+
530
+
531
+ def finding_value(finding: dict[str, Any], names: tuple[str, ...]) -> Any:
532
+ for name in names:
533
+ value = finding.get(name)
534
+ if value not in (None, ""): return value
535
+ return None
536
+
537
+
538
+ def finding_location_or_library(finding: dict[str, Any]) -> str:
539
+ location = finding_value(finding, ("location", "path", "file", "uri"))
540
+ line = finding_value(finding, ("line", "lineNumber", "startLine"))
541
+ library = finding_value(finding, ("library", "package", "component"))
542
+ parts = [f"{location}:{line}" if location and line else str(location) for _ in [0] if location]
543
+ if library and str(library) != str(location): parts.append(str(library))
544
+ return "; ".join(parts) if parts else "—"
545
+
546
+
547
+ def finding_title(finding: dict[str, Any]) -> str:
548
+ identifier = finding_value(finding, ("ruleId", "id", "cve", "advisory"))
549
+ title = finding_value(finding, ("title", "message"))
550
+ return f"{identifier}: {title}" if identifier and title else str(title or identifier or "System scanner finding")
551
+
552
+
553
+ def scanner_recovery(run: dict[str, Any]) -> str:
554
+ adapter = str(run.get("adapter") or "scanner")
555
+ steps = {"clamav": "Update trusted ClamAV signatures outside this workflow, then preview and rerun the scan.", "aide": "Verify that AIDE has a trusted baseline; do not initialize one on a possibly compromised host.", "lynis": "Rerun the previewed Lynis audit with required read permissions and inspect its redacted diagnostic.", "nmap-local": "Confirm target ownership and lifecycle consent, then rerun only the previously previewed literal-IP probe.", "tshark-summary": "Confirm the interface and bounded duration, then rerun without saving packet content.", "debsecan": "Refresh the local distribution advisory data through normal system administration, then rerun and validate backport status."}
556
+ diagnostic = bounded_text(run.get("stderrSnippet", ""), 500)
557
+ return steps.get(adapter, "Rerun the identical previewed command after resolving the recorded diagnostic.") + (f" Diagnostic: {diagnostic}" if diagnostic else "")
558
+
559
+
560
+ def run_one(root: Path, args: dict[str, Any], virtual: bool) -> dict[str, Any]:
561
+ ident = args.get("adapter")
562
+ if not isinstance(ident, str):
563
+ raise ValueError("adapter must be a string")
564
+ spec, argv = command(ident, args)
565
+ base = {"adapter": ident, "category": spec["category"], "host": read_os_release(), "requiresNetwork": spec.get("network", False), "requiresActiveNetwork": spec.get("active", False), "requiresTrafficCapture": spec.get("traffic", False), "requiresServiceProbe": spec.get("serviceProbe", False), "command": {"argv": argv, "currentDir": str(root)}}
566
+ if virtual:
567
+ return {**base, "execution": "virtual", "processStarted": False, "resultStatus": "not_executed", "findings": []}
568
+ if shutil.which(spec["exe"]) is None:
569
+ raise ValueError(f"scanner executable not found on PATH: {spec['exe']}")
570
+ completed = subprocess.run(argv, cwd=root, capture_output=True, text=True, timeout=spec["timeout"], check=False)
571
+ output = (completed.stdout or "")[:MAX_OUTPUT]
572
+ error = (completed.stderr or "")[:MAX_OUTPUT]
573
+ if ident == "docker-inspect":
574
+ findings, observations = normalize_docker_inspect(output)
575
+ elif ident == "docker-security-options":
576
+ findings, observations = normalize_docker_security_options(output)
577
+ elif ident in {"trivy-image", "grype-image", "dockle-image"}:
578
+ findings, observations = normalize_image_scan(ident, output)
579
+ elif spec.get("serviceProbe") or ident == "nginx-config":
580
+ findings, observations = normalize_service_probe(ident, output)
581
+ else:
582
+ findings, observations = normalize_output(ident, output)
583
+ status = "complete" if completed.returncode == 0 or completed.returncode == 1 and findings else "incomplete" if completed.returncode == 1 else "failed"
584
+ snippets = {"stdoutSnippet": output[:4000], "stderrSnippet": error[:4000]} if not spec.get("sensitiveOutput") else {"stdoutSnippet": "[WITHHELD: normalized security fields only]", "stderrSnippet": "[WITHHELD: normalized security fields only]"}
585
+ return redact({**base, "execution": "executed", "processStarted": True, "resultStatus": status, "exitCode": completed.returncode, "findings": findings, "observations": observations, "counts": {"findings": len(findings), "observations": len(observations)}, **snippets})
586
+
587
+
588
+ def state_path(root: Path, run_id: Any) -> Path:
589
+ if not isinstance(run_id, str) or not run_id.isdigit():
590
+ raise ValueError("runId must be a Unix timestamp")
591
+ return root / ".mnogovid" / "system-scanner" / run_id / "run-state.json"
592
+
593
+
594
+ def save_run(root: Path, run_id: str, run: dict[str, Any]) -> None:
595
+ directory = run_directory(root, run_id, create=True)
596
+ atomic_write(directory / "run-state.json", safe_json(run) + "\n", replace=True)
597
+
598
+
599
+ def started_run(root: Path, run_id: Any) -> dict[str, Any]:
600
+ if isinstance(run_id, str) and run_id in RUNS:
601
+ return RUNS[run_id]
602
+ path = run_directory(root, str(run_id), create=False) / "run-state.json"
603
+ run = json.loads(read_regular_file(path))
604
+ if run.get("reportDirectory") != str(root):
605
+ raise ValueError("runId belongs to a different report directory")
606
+ RUNS[str(run_id)] = run
607
+ return run
608
+
609
+
610
+ def render_report(root: Path, run: dict[str, Any], report_id: str) -> str:
611
+ scanners = run["scannerResults"]
612
+ findings = [finding for scanner in scanners for finding in scanner.get("findings", []) if isinstance(finding, dict)]
613
+ notes = {item["findingIndex"]: item for item in (run.get("hostAiTriage") or {}).get("findingNotes", []) if isinstance(item, dict) and isinstance(item.get("findingIndex"), int)}
614
+ completed = [item for item in scanners if item.get("resultStatus") == "complete"]
615
+ incomplete = [item for item in scanners if item.get("resultStatus") == "incomplete"]
616
+ failed = [item for item in scanners if item.get("resultStatus") == "failed"]
617
+ classifications = [str(item.get("classification", "")).lower() for item in notes.values()]
618
+ if any(item == "true_positive" for item in classifications):
619
+ verdict, explanation = "ACTION REQUIRED", "At least one host finding was assessed as likely real; review it before remediation."
620
+ elif findings or incomplete or failed or run["skippedScanners"]:
621
+ verdict, explanation = "REVIEW REQUIRED", "Findings or incomplete coverage require human verification; this is not proof of compromise or cleanliness."
622
+ else:
623
+ verdict, explanation = "NO FINDINGS REPORTED", "Completed checks reported no normalized findings. Unobserved traffic and kernel-level stealth remain coverage limits."
624
+ lines = ["# Mnogovid System Scanner report", "", "## Verdict", "", f"**{verdict}.** {explanation}", "", "| Report directory | Mode | Findings | Completed scanners | Incomplete / failed |", "| --- | --- | --- | --- | --- |", f"| {safe_text(root, 512)} | {safe_text(run['mode'], 40)} | {len(findings)} | {len(completed)} | {len(incomplete) + len(failed)} |", "", "## What needs attention", ""]
625
+ if not findings:
626
+ lines += ["No normalized findings were recorded. Review coverage gaps before treating the host as clean.", ""]
627
+ for index, finding in enumerate(findings):
628
+ note = notes.get(index)
629
+ lines += [f"### {index + 1}. {safe_text(finding_title(finding))}", "", f"**Scanner:** {safe_text(finding.get('adapter', 'unknown'), 80)} ", f"**Severity:** {safe_text(finding.get('severity', 'review'), 40)} ", f"**Location / package:** {safe_text(finding_location_or_library(finding), 512)} "]
630
+ if finding.get("installedVersion"):
631
+ lines.append(f"**Installed version:** {safe_text(finding.get('installedVersion'), 160)} ")
632
+ if finding.get("fixedVersion"):
633
+ lines.append(f"**Fixed version:** {safe_text(finding.get('fixedVersion'), 160)} ")
634
+ if note:
635
+ lines += [f"**AI assessment:** {safe_text(note.get('classification', 'needs_review'), 40)} (confidence: {note.get('confidence', 'not provided')}) ", f"**Why it matters:** {safe_text(note.get('note', 'No detailed note recorded.'), 2000)}", ""]
636
+ else:
637
+ lines += ["**Next step:** verify this host observation with a separate read-only check before treating it as malicious or benign.", ""]
638
+ if incomplete or failed or run["skippedScanners"]:
639
+ lines += ["## Coverage gaps", ""]
640
+ for item in incomplete + failed:
641
+ lines.append(f"- **{safe_text(item.get('adapter', 'unknown'), 80)}:** {safe_text(item.get('resultStatus', 'unknown'), 40)}; {safe_text(scanner_recovery(item), 1200)}")
642
+ for item in run["skippedScanners"]:
643
+ lines.append(f"- **{safe_text(item.get('adapter', 'unknown'), 80)}:** skipped; {safe_text(item.get('reason', 'not run'), 1200)}")
644
+ lines.append("")
645
+ lines += ["## Scan coverage", "", "| Scanner | Result | Findings | Observations | Access |", "| --- | --- | --- | --- | --- |"]
646
+ for item in scanners:
647
+ counts = item.get("counts") or {}
648
+ access = "active network" if item.get("requiresActiveNetwork") else "network database" if item.get("requiresNetwork") else "traffic capture" if item.get("requiresTrafficCapture") else "local service probe" if item.get("requiresServiceProbe") else "local"
649
+ lines.append(f"| {safe_text(item.get('adapter', 'unknown'), 80)} | {safe_text(item.get('resultStatus', 'unknown'), 40)} | {counts.get('findings', len(item.get('findings', [])))} | {counts.get('observations', len(item.get('observations', [])))} | {access} |")
650
+ if not scanners:
651
+ lines.append("| No scanner was run | not executed | 0 | 0 | local |")
652
+ observations = [observation for scanner in scanners for observation in scanner.get("observations", []) if isinstance(observation, str)]
653
+ if observations:
654
+ lines += ["## Security-relevant observations", "", "These items are inventory or telemetry, not findings by themselves.", ""]
655
+ lines += [f"- {safe_text(observation)}" for observation in observations[:200]]
656
+ lines.append("")
657
+ lines += ["", "## Scope and consent", "", "```json", safe_json(run["consent"]), "```", ""]
658
+ if run.get("hostAiTriage"):
659
+ lines += ["## Host AI triage (advisory)", "", "```json", safe_json(run["hostAiTriage"]), "```", ""]
660
+ if run.get("agentReview"):
661
+ lines += ["## Independent agent review (advisory)", "", "```json", safe_json(run["agentReview"]), "```", ""]
662
+ lines += ["## Report details", "", "| Report ID | Generated | AI analysis | Independent review |", "| --- | --- | --- | --- |", f"| {report_id} | {datetime.now(timezone.utc).replace(microsecond=0).isoformat()} | {'included' if run.get('hostAiTriage') else 'not requested'} | {'included' if run.get('agentReview') else 'not requested'} |", ""]
663
+ return "\n".join(lines)
664
+
665
+
666
+ def write_report(root: Path, run: dict[str, Any]) -> dict[str, Any]:
667
+ report_id = str(time.time_ns())
668
+ destination_root = run_directory(root, report_id, create=True)
669
+ destination = destination_root / "result.md"
670
+ document = render_report(root, run, report_id)
671
+ truncated = len(document.encode("utf-8")) > MAX_OUTPUT
672
+ if truncated:
673
+ document = document.encode("utf-8")[:MAX_OUTPUT].decode("utf-8", "ignore") + "\n\n_Report truncated at storage limit._\n"
674
+ atomic_write(destination, document, replace=False)
675
+ return {"reportId": report_id, "path": str(destination), "redacted": True, "truncated": truncated}
676
+
677
+
678
+ def content(value: Any, error: bool = False) -> dict[str, Any]:
679
+ return {"isError": error, "content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False)}]}
680
+
681
+
682
+ def call(name: str, args: dict[str, Any]) -> dict[str, Any]:
683
+ try:
684
+ if name == "system_catalog":
685
+ return content({"adapters": [{"id": key, "category": value["category"], "executable": value["exe"], "requiresNetwork": value.get("network", False), "requiresActiveNetwork": value.get("active", False), "requiresTrafficCapture": value.get("traffic", False), "requiresServiceProbe": value.get("serviceProbe", False)} for key, value in ADAPTERS.items()], "safety": "No remediation, installations, arbitrary commands, PCAP files, or unapproved network/service probes. Sensitive service and Docker output is normalized before it is returned."})
686
+ if name in ("system_doctor", "system_plan"):
687
+ root = report_directory(args.get("reportDirectory")); data = plan(root)
688
+ if name == "system_doctor":
689
+ data["missingExecutables"] = [item["executable"] for item in data["runs"] if not item["available"]]
690
+ return content(data)
691
+ if name == "system_bootstrap":
692
+ root = report_directory(args.get("reportDirectory"))
693
+ return content(bootstrap(root, args.get("createProfile", False)))
694
+ if name == "system_virtual_run":
695
+ root = report_directory(args.get("reportDirectory"))
696
+ return content(run_one(root, args, True))
697
+ if name == "system_run":
698
+ root = report_directory(args.get("reportDirectory")); run = started_run(root, args.get("runId"))
699
+ preview = run_one(root, args, True)
700
+ if preview["requiresNetwork"] and not run["consent"]["network"]:
701
+ raise ValueError("network consent was not recorded for this lifecycle")
702
+ if preview["requiresActiveNetwork"] and not run["consent"]["activeNetwork"]:
703
+ raise ValueError("active network consent was not recorded for this lifecycle")
704
+ if preview["requiresTrafficCapture"] and not run["consent"]["trafficCapture"]:
705
+ raise ValueError("traffic-capture consent was not recorded for this lifecycle")
706
+ if preview["requiresServiceProbe"] and not run["consent"]["serviceProbe"]:
707
+ raise ValueError("local service-probe consent was not recorded for this lifecycle")
708
+ if not any(item.get("adapter") == preview["adapter"] and item.get("command", {}).get("argv") == preview["command"]["argv"] for item in run["virtualCommands"]):
709
+ raise ValueError("record an identical system_virtual_run preview in this lifecycle before executing")
710
+ return content(run_one(root, args, False))
711
+ if name == "system_ingest":
712
+ root = report_directory(args.get("reportDirectory"))
713
+ report = private_input_report(root, args.get("report"))
714
+ report_format = args.get("format")
715
+ if report_format not in ("json", "sarif"):
716
+ raise ValueError("format must be json or sarif")
717
+ raw = json.loads(read_regular_file(report, MAX_OUTPUT))
718
+ findings = parse_ingested_report(raw, args.get("adapter") if isinstance(args.get("adapter"), str) else None)
719
+ return content({"sourcePath": str(report), "format": report_format, "reportOnly": True, "findings": redact(findings), "counts": {"findings": len(findings)}})
720
+ if name == "system_start_run":
721
+ root = report_directory(args.get("reportDirectory")); mode = args.get("mode"); consent = args.get("consent")
722
+ if mode not in ("scan", "scan-ai", "scan-agent"):
723
+ raise ValueError("mode and consent are required")
724
+ consent = normalize_consent(consent)
725
+ run_id = str(time.time_ns())
726
+ run = {"reportDirectory": str(root), "mode": mode, "consent": consent, "startedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), "scannerResults": [], "virtualCommands": [], "skippedScanners": [], "hostAiTriage": None, "agentReview": None}
727
+ RUNS[run_id] = run; save_run(root, run_id, run)
728
+ return content({"runId": run_id, "statePath": str(state_path(root, run_id)), "processStarted": False, "reportWritten": False})
729
+ if name == "system_record_run":
730
+ root = report_directory(args.get("reportDirectory")); run_id = args.get("runId"); run = started_run(root, run_id)
731
+ kind, entry = args.get("kind"), args.get("entry")
732
+ if kind not in ("scanner", "preview", "skipped", "host_ai_triage", "agent_review") or not isinstance(entry, dict):
733
+ raise ValueError("kind and entry are required")
734
+ finding_count = sum(len(item.get("findings", [])) for item in run["scannerResults"])
735
+ normalized = normalize_entry(kind, entry, finding_count)
736
+ if kind == "host_ai_triage": run["hostAiTriage"] = normalized
737
+ elif kind == "agent_review": run["agentReview"] = normalized
738
+ else:
739
+ key = {"scanner": "scannerResults", "preview": "virtualCommands", "skipped": "skippedScanners"}[kind]
740
+ run[key].append(normalized)
741
+ save_run(root, str(run_id), run)
742
+ return content({"runId": run_id, "recorded": kind})
743
+ if name == "system_finalize_run":
744
+ root = report_directory(args.get("reportDirectory")); run_id = args.get("runId"); run = started_run(root, run_id)
745
+ finding_count = sum(len(item.get("findings", [])) for item in run["scannerResults"])
746
+ if args.get("hostAiTriage") is not None:
747
+ if not isinstance(args["hostAiTriage"], dict): raise ValueError("hostAiTriage must be an object")
748
+ run["hostAiTriage"] = normalize_entry("host_ai_triage", args["hostAiTriage"], finding_count)
749
+ if args.get("agentReview") is not None:
750
+ if not isinstance(args["agentReview"], dict): raise ValueError("agentReview must be an object")
751
+ run["agentReview"] = normalize_entry("agent_review", args["agentReview"], finding_count)
752
+ if run["mode"] in ("scan-ai", "scan-agent") and run["consent"].get("aiTriage") is True and not run.get("hostAiTriage"):
753
+ raise ValueError("record approved host AI triage before finalizing")
754
+ if run["mode"] == "scan-agent" and run["consent"].get("agentReview") is True and not run.get("agentReview"):
755
+ raise ValueError("record approved agent review before finalizing")
756
+ result = write_report(root, run); state_path(root, run_id).unlink(missing_ok=True); RUNS.pop(str(run_id), None)
757
+ return content({**result, "runId": run_id, "finalized": True})
758
+ if name == "system_ai_triage_payload":
759
+ findings = redact(args.get("findings"))
760
+ if not isinstance(findings, list): raise ValueError("findings must be an array")
761
+ return content({"findingLimit": min(len(findings), 40), "findings": findings[:40], "instruction": "Analyze only supplied redacted evidence. Return findingNotes in zero-based findingIndex order with classification (true_positive, false_positive, needs_review), confidence, and detailed evidence note. Do not request secrets or suggest automatic remediation."})
762
+ if name == "system_advisory_lookup":
763
+ if args.get("allowNetwork") is not True:
764
+ raise ValueError("OSV advisory lookup requires allowNetwork=true")
765
+ ecosystem, package_name, version = args.get("ecosystem"), args.get("package"), args.get("version")
766
+ if not all(isinstance(value, str) and value for value in (ecosystem, package_name, version)):
767
+ raise ValueError("ecosystem, package, and version must be non-empty strings")
768
+ body = json.dumps({"package": {"ecosystem": ecosystem, "name": package_name}, "version": version}).encode()
769
+ request = urllib.request.Request("https://api.osv.dev/v1/query", data=body, headers={"Content-Type": "application/json"}, method="POST")
770
+ try:
771
+ with urllib.request.urlopen(request, timeout=15) as response:
772
+ payload = response.read(MAX_OUTPUT + 1)
773
+ if len(payload) > MAX_OUTPUT:
774
+ raise ValueError("OSV advisory response exceeds the bounded response limit")
775
+ raw = json.loads(payload.decode())
776
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
777
+ raise ValueError(f"OSV advisory lookup failed: {exc}")
778
+ vulnerabilities = raw.get("vulns", []) if isinstance(raw, dict) else []
779
+ return content({"source": "OSV", "networkUsed": True, "package": {"ecosystem": ecosystem, "name": package_name, "version": version}, "vulnerabilities": [{"id": item.get("id"), "summary": item.get("summary"), "modified": item.get("modified"), "aliases": item.get("aliases", []), "references": item.get("references", [])} for item in vulnerabilities[:50] if isinstance(item, dict)]})
780
+ raise ValueError(f"unknown tool: {name}")
781
+ except (ValueError, OSError, subprocess.TimeoutExpired) as exc:
782
+ return content({"error": str(exc)}, True)
783
+
784
+
785
+ def main() -> int:
786
+ for line in sys.stdin:
787
+ try:
788
+ request = json.loads(line); method, request_id = request.get("method"), request.get("id")
789
+ if method == "initialize": result = {"protocolVersion": "2025-03-26", "capabilities": {"tools": {}}, "serverInfo": {"name": "mnogovid-system-scanner", "version": "0.1.0"}}
790
+ elif method == "tools/list": result = {"tools": TOOLS}
791
+ elif method == "tools/call": result = call(request.get("params", {}).get("name", ""), request.get("params", {}).get("arguments", {}))
792
+ elif request_id is None: continue
793
+ else: raise ValueError(f"method not found: {method}")
794
+ print(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}, ensure_ascii=False), flush=True)
795
+ except Exception as exc:
796
+ print(json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": str(exc)}}), flush=True)
797
+ return 0
798
+
799
+
800
+ if __name__ == "__main__":
801
+ raise SystemExit(main())