@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.
- package/LICENSE +6 -0
- package/README.md +185 -0
- package/opencode.js +115 -0
- package/package.json +30 -0
- package/scripts/init.py +89 -0
- package/scripts/remote_mcp_config.py +48 -0
- package/scripts/system_mcp.py +801 -0
|
@@ -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("&", "&").replace("<", "<").replace(">", ">").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())
|