@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 ADDED
@@ -0,0 +1,6 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ This plugin is distributed under the Apache License 2.0. The complete license
6
+ is available at https://www.apache.org/licenses/LICENSE-2.0.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # Mnogovid System Scanner
2
+
3
+ Mnogovid System Scanner is a consent-gated Linux host assessment plugin. It
4
+ orchestrates installed local tools and records redacted evidence; it never
5
+ installs software, applies a fix, deletes/quarantines a file, saves PCAPs, or
6
+ runs shell text.
7
+
8
+ It is intentionally layered. No tool can prove a Linux host is clean: a
9
+ rootkit can hide from user-space checks, package CVE state can differ because
10
+ of distribution backports, and encrypted or unobserved traffic cannot be fully
11
+ inspected.
12
+
13
+ ## Coverage
14
+
15
+ | Area | Allowlisted adapters |
16
+ | --- | --- |
17
+ | Hardening | Lynis |
18
+ | Malware / rootkits | ClamAV, rkhunter, chkrootkit |
19
+ | File/package integrity | AIDE, `rpm -Va` |
20
+ | Installed-package CVEs | debsecan |
21
+ | Runtime inventory | osquery |
22
+ | Listener and firewall exposure | `ss`, nftables |
23
+ | Persistence | enabled systemd units and timers |
24
+ | Audit, kernel, and logs | audit rules, loaded kernel modules, warning journal entries |
25
+ | Containers | Docker and Podman inventory when their CLI is available |
26
+ | Docker hardening and image CVEs | Docker security options/container posture, Trivy, Grype, Dockle |
27
+ | Service posture | Nginx syntax; bounded local MySQL, PostgreSQL, Redis, MongoDB, and ClickHouse probes |
28
+ | Active ports (explicit target only) | Nmap, top 100 ports with light version detection |
29
+ | Live traffic metadata (bounded) | TShark, 5–300 seconds, no packet file |
30
+
31
+ Missing executables are recorded as coverage gaps. The plugin does not attempt
32
+ to install them or elevate privilege. Some checks need root access and will
33
+ report failure rather than invoke `sudo`.
34
+
35
+ ## Run
36
+
37
+ Install it from the Mnogovid marketplace, then use one of the native Codex
38
+ commands:
39
+
40
+ ```text
41
+ @mnogovid-system-scanner
42
+ /mnogovid-system-scanner:system-scan
43
+ ```
44
+
45
+ The `@` mention invokes the plugin onboarding prompt. The unified command first
46
+ asks whether to assess the local host or a configured remote MCP host, checks
47
+ the profile and toolchain, and creates a missing profile only after consent.
48
+ On later runs it validates the existing profile and available adapters before
49
+ asking which mode to use: adapters only, adapters plus AI triage, or adapters
50
+ plus AI triage and independent review. It collects all scanner permissions
51
+ separately.
52
+
53
+ ### Remote server over SSH-stdio MCP
54
+
55
+ Install the same plugin on the remote host, preferably at
56
+ `/opt/mnogovid-system-scanner`, and define an SSH alias for a dedicated audit
57
+ account. Generate a static local Codex configuration stanza:
58
+
59
+ ```bash
60
+ python3 /path/to/mnogovid-system-scanner/scripts/remote_mcp_config.py prod-audit
61
+ ```
62
+
63
+ Copy its output into local `~/.codex/config.toml`, restart Codex, then use:
64
+
65
+ ```text
66
+ /mnogovid-system-scanner:system-scan
67
+ ```
68
+
69
+ The MCP protocol stays inside SSH stdio: no remote TCP listener or secret is
70
+ required in the generated configuration. It explicitly disables SSH agent and
71
+ other forwarding, enables batch mode, and requires a known host key. The utility
72
+ only renders TOML; it does not edit the local configuration or contact the
73
+ host. See [`remote-mcp.toml.example`](remote-mcp.toml.example).
74
+
75
+ The unified command asks in chat which configured remote MCP connection to use
76
+ when there is more than one, then asks a separate first consent before it
77
+ connects or starts discovery. It defaults reports to the remote MCP process's
78
+ current working directory and shows its resolved path before scanner planning.
79
+
80
+ ### Claude Code
81
+
82
+ Install the plugin from the configured marketplace, start Claude in the
83
+ selected report directory, then invoke:
84
+
85
+ ```text
86
+ /system-scan
87
+ ```
88
+
89
+ ### OpenCode
90
+
91
+ Add the package name to the project or global `opencode.json` and restart
92
+ OpenCode:
93
+
94
+ ```json
95
+ {
96
+ "$schema": "https://opencode.ai/config.json",
97
+ "plugin": ["@bergabruh/system-scanner"]
98
+ }
99
+ ```
100
+
101
+ OpenCode installs the package through Bun. The package starts its bundled,
102
+ dependency-free Python scanner bridge itself; no `cwd`, MCP block, or copied
103
+ `.opencode` assets are needed. Ask OpenCode to assess the local Linux host; it
104
+ receives the `mnogovid_system_scanner` tool and must still request every
105
+ recorded consent. `python3` and individual scanner executables remain system
106
+ prerequisites and are never installed by the package.
107
+
108
+ First inspect the host/tool availability without starting a scanner:
109
+
110
+ ```bash
111
+ python3 /path/to/mnogovid-system-scanner/scripts/init.py /safe/report-directory --json
112
+ ```
113
+
114
+ This manual initializer is optional; the unified workflow uses `system_bootstrap`
115
+ first. A profile records discovery only and never grants scanner permission:
116
+
117
+ ```bash
118
+ python3 /path/to/mnogovid-system-scanner/scripts/init.py /safe/report-directory --json --write
119
+ ```
120
+
121
+ For every adapter the agent previews its exact argv and asks for approval. Two
122
+ additional controls are deliberately separate:
123
+
124
+ - Image scanners with external vulnerability databases require `network`
125
+ consent, an image reference, and per-scanner approval.
126
+ - `nmap-local` needs recorded lifecycle active-network consent,
127
+ `authorizedTarget=true`, and one explicitly authorized literal IP.
128
+ - Database clients require `serviceProbe` consent and use only fixed local,
129
+ read-only status commands. Their raw output is withheld after normalization.
130
+ - `tshark-summary` needs recorded lifecycle traffic-capture consent, a named
131
+ interface, and a 5–300 second capture interval. It emits metadata to the
132
+ scanner result only, not a PCAP file; packet metadata can still be sensitive.
133
+
134
+ The database adapters use fixed local read-only status/version commands. They
135
+ can be unavailable when a service is not local, its socket is inaccessible, or
136
+ it requires credentials; such a result is a coverage gap, not a clean bill of
137
+ health.
138
+
139
+ Reports are written only after `system_finalize_run`:
140
+
141
+ ```text
142
+ <report-directory>/.mnogovid/system-scanner/<timestamp>/result.md
143
+ ```
144
+
145
+ The report is reader-first: verdict, actionable findings, coverage gaps with
146
+ recovery guidance, scanner coverage, security-relevant observations, recorded
147
+ consent, and distinct AI/independent-review sections. A failed, missing, or
148
+ declined adapter is a coverage gap—not a clean result.
149
+
150
+ ## AI and independent review
151
+
152
+ The AI and independent-review modes create bounded, secret-redacted payloads
153
+ only after their own separate approvals. Both assessments are advisory and
154
+ remain distinct from the scanner evidence.
155
+
156
+ Use `system_ingest` to normalize a pre-existing private local JSON or SARIF
157
+ report inside the selected report directory without starting a process.
158
+ `system_advisory_lookup` queries OSV for one
159
+ package version only after a separate `allowNetwork=true` approval; it is
160
+ advisory evidence and does not account for distribution backports by itself.
161
+
162
+ ## Development
163
+
164
+ Validate the manifest and exercise the JSON-RPC server without starting a
165
+ scanner:
166
+
167
+ ```bash
168
+ python3 /path/to/plugin-creator/scripts/validate_plugin.py /path/to/mnogovid-system-scanner
169
+ python3 -m unittest discover -s /path/to/mnogovid-system-scanner/tests -v
170
+ ```
171
+
172
+ Licensed under the Apache License 2.0.
173
+
174
+ ## Troubleshooting
175
+
176
+ - **Profile missing:** rerun the unified workflow and approve profile creation.
177
+ - **Profile invalid:** stop and repair it explicitly; the workflow will not
178
+ scan through an invalid profile.
179
+ - **Adapter missing or permission denied:** install or grant the required
180
+ read-only access through normal system administration, then rerun bootstrap.
181
+ - **Remote MCP unavailable:** verify the SSH alias, known host key, remote
182
+ Python path, and remote plugin path; the generator does not contact the
183
+ server or modify `~/.codex/config.toml`.
184
+ - **No findings:** a completed set of checks cannot prove the absence of
185
+ compromise, unseen traffic, or kernel-level stealth. Review coverage gaps.
package/opencode.js ADDED
@@ -0,0 +1,115 @@
1
+ import { spawn } from "node:child_process"
2
+ import { fileURLToPath } from "node:url"
3
+ import { tool } from "@opencode-ai/plugin"
4
+
5
+ const METHODS = new Set([
6
+ "system_catalog",
7
+ "system_doctor",
8
+ "system_bootstrap",
9
+ "system_plan",
10
+ "system_virtual_run",
11
+ "system_run",
12
+ "system_ingest",
13
+ "system_start_run",
14
+ "system_record_run",
15
+ "system_finalize_run",
16
+ "system_ai_triage_payload",
17
+ "system_advisory_lookup",
18
+ ])
19
+
20
+ class PythonMcpBridge {
21
+ constructor(script) {
22
+ this.nextId = 1
23
+ this.pending = new Map()
24
+ this.buffer = ""
25
+ this.queue = Promise.resolve()
26
+ this.stderr = ""
27
+ this.child = spawn("python3", [script], { stdio: ["pipe", "pipe", "pipe"] })
28
+ this.child.stdout.setEncoding("utf8")
29
+ this.child.stdout.on("data", (chunk) => this.receive(chunk))
30
+ this.child.stderr.setEncoding("utf8")
31
+ this.child.stderr.on("data", (chunk) => {
32
+ this.stderr = (this.stderr + chunk).slice(-4096)
33
+ })
34
+ this.child.on("error", (error) => this.failAll(error))
35
+ this.child.on("exit", (code, signal) => {
36
+ this.failAll(new Error(`Python scanner exited (${signal ?? code ?? "unknown"})${this.stderr ? `: ${this.stderr}` : ""}`))
37
+ })
38
+ process.once("exit", () => this.child.kill())
39
+ }
40
+
41
+ receive(chunk) {
42
+ this.buffer += chunk
43
+ let newline
44
+ while ((newline = this.buffer.indexOf("\n")) >= 0) {
45
+ const line = this.buffer.slice(0, newline)
46
+ this.buffer = this.buffer.slice(newline + 1)
47
+ if (!line.trim()) continue
48
+ try {
49
+ const message = JSON.parse(line)
50
+ const pending = this.pending.get(message.id)
51
+ if (!pending) continue
52
+ this.pending.delete(message.id)
53
+ if (message.error) pending.reject(new Error(message.error.message ?? "Python scanner protocol error"))
54
+ else pending.resolve(message.result)
55
+ } catch (error) {
56
+ this.failAll(new Error(`Invalid response from Python scanner: ${error.message}`))
57
+ }
58
+ }
59
+ }
60
+
61
+ failAll(error) {
62
+ for (const { reject } of this.pending.values()) reject(error)
63
+ this.pending.clear()
64
+ }
65
+
66
+ call(method, args) {
67
+ const request = () => new Promise((resolve, reject) => {
68
+ if (this.child.exitCode !== null || this.child.killed) {
69
+ reject(new Error("Python scanner is unavailable; verify that python3 is installed."))
70
+ return
71
+ }
72
+ const id = this.nextId++
73
+ this.pending.set(id, { resolve, reject })
74
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name: method, arguments: args } })}\n`)
75
+ })
76
+ const result = this.queue.then(request, request)
77
+ this.queue = result.catch(() => undefined)
78
+ return result
79
+ }
80
+ }
81
+
82
+ function parseArguments(raw) {
83
+ try {
84
+ const value = JSON.parse(raw)
85
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("must be a JSON object")
86
+ return value
87
+ } catch (error) {
88
+ throw new Error(`argumentsJson ${error.message}`)
89
+ }
90
+ }
91
+
92
+ export const MnogovidSystemScanner = async () => {
93
+ const script = fileURLToPath(new URL("./scripts/system_mcp.py", import.meta.url))
94
+ const bridge = new PythonMcpBridge(script)
95
+
96
+ return {
97
+ tool: {
98
+ mnogovid_system_scanner: tool({
99
+ description: "Call one consent-gated Mnogovid System Scanner operation. Valid operations: system_catalog, system_doctor, system_bootstrap, system_plan, system_virtual_run, system_run, system_ingest, system_start_run, system_record_run, system_finalize_run, system_ai_triage_payload, system_advisory_lookup. Pass argumentsJson as a JSON object. The Python core validates report directories, recorded lifecycle and consent before any scanner starts.",
100
+ args: {
101
+ operation: tool.schema.string(),
102
+ argumentsJson: tool.schema.string(),
103
+ },
104
+ async execute({ operation, argumentsJson }) {
105
+ if (!METHODS.has(operation)) return `Unknown Mnogovid System Scanner operation: ${operation}`
106
+ try {
107
+ return JSON.stringify(await bridge.call(operation, parseArguments(argumentsJson)))
108
+ } catch (error) {
109
+ return JSON.stringify({ error: error.message })
110
+ }
111
+ },
112
+ }),
113
+ },
114
+ }
115
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@bergabruh/system-scanner",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin for consent-gated Linux host security assessment.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./opencode.js",
8
+ "module": "./opencode.js",
9
+ "exports": "./opencode.js",
10
+ "files": [
11
+ "opencode.js",
12
+ "scripts/*.py",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "security",
20
+ "linux"
21
+ ],
22
+ "devDependencies": {
23
+ "@opencode-ai/plugin": "^1.18.11"
24
+ },
25
+ "scripts": {
26
+ "check": "node --check opencode.js"
27
+ },
28
+ "homepage": "https://github.com/BergaBruh/mnogovid",
29
+ "repository": { "type": "git", "url": "git+https://github.com/BergaBruh/mnogovid.git" }
30
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """Initialize an optional, non-destructive Mnogovid System Scanner profile."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import platform
8
+ import shutil
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from system_mcp import atomic_write, plan, report_directory
13
+
14
+ PROFILE_NAME = ".mnogovid-system-scanner.json"
15
+ PACKAGE_MANAGER_HINTS = (
16
+ ("apt", "apt-get", "sudo apt-get install <package>"),
17
+ ("dnf", "dnf", "sudo dnf install <package>"),
18
+ ("yum", "yum", "sudo yum install <package>"),
19
+ ("pacman", "pacman", "sudo pacman -S <package>"),
20
+ ("zypper", "zypper", "sudo zypper install <package>"),
21
+ ("apk", "apk", "sudo apk add <package>"),
22
+ )
23
+
24
+
25
+ def installation_guide(missing: list[str]) -> dict[str, object]:
26
+ return {
27
+ "message": "Install missing scanners only through your normal system administration process; this plugin never installs tools.",
28
+ "platform": platform.platform(),
29
+ "missingExecutables": missing,
30
+ "packageManagers": [{"name": name, "executable": executable, "commandTemplate": command} for name, executable, command in PACKAGE_MANAGER_HINTS if shutil.which(executable)],
31
+ }
32
+
33
+
34
+ def main() -> int:
35
+ parser = argparse.ArgumentParser(description="Inspect available Linux host-security scanners without running them.")
36
+ parser.add_argument("report_directory", nargs="?", default=".", help="existing directory where optional profile and reports live")
37
+ parser.add_argument("--write", action="store_true", help=f"create {PROFILE_NAME} if absent")
38
+ parser.add_argument("--force", action="store_true", help="replace an existing profile; requires --write")
39
+ parser.add_argument("--allow-active-network", action="store_true", help="record a preference for later explicitly approved Nmap probes")
40
+ parser.add_argument("--allow-network", action="store_true", help="record a preference for later explicitly approved vulnerability-database use")
41
+ parser.add_argument("--allow-service-probe", action="store_true", help="record a preference for later explicitly approved local service status probes")
42
+ parser.add_argument("--allow-traffic-capture", action="store_true", help="record a preference for later explicitly approved bounded packet summaries")
43
+ parser.add_argument("--json", action="store_true", help="print JSON")
44
+ args = parser.parse_args()
45
+ if args.force and not args.write:
46
+ parser.error("--force requires --write")
47
+ root = report_directory(args.report_directory)
48
+ result = plan(root)
49
+ result["missingExecutables"] = [item["executable"] for item in result["runs"] if not item["available"]]
50
+ result["installationGuide"] = installation_guide(result["missingExecutables"])
51
+ profile_path = root / PROFILE_NAME
52
+ exists = profile_path.exists() or profile_path.is_symlink()
53
+ if exists and profile_path.is_symlink():
54
+ parser.error(f"refusing to write through symlinked profile: {profile_path}")
55
+ if args.write and (not exists or args.force):
56
+ profile = {
57
+ "schemaVersion": 1,
58
+ "generatedBy": "mnogovid-system-scanner init",
59
+ "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
60
+ "allowActiveNetwork": args.allow_active_network,
61
+ "allowNetwork": args.allow_network,
62
+ "allowTrafficCapture": args.allow_traffic_capture,
63
+ "allowServiceProbe": args.allow_service_probe,
64
+ "availableAdapters": [item["adapter"] for item in result["runs"] if item["available"]],
65
+ "recommendedAdapters": result["recommendedAdapters"],
66
+ "installationGuide": result["installationGuide"],
67
+ "notes": ["This file does not run scanners or grant per-command consent.", "Active probing and traffic capture still require an explicit tool call and user approval."],
68
+ }
69
+ atomic_write(profile_path, json.dumps(profile, ensure_ascii=False, indent=2) + "\n", replace=args.force)
70
+ result["profile"] = {"path": str(profile_path), "action": "replaced" if exists else "created"}
71
+ elif args.write:
72
+ result["profile"] = {"path": str(profile_path), "action": "unchanged", "reason": "already exists"}
73
+ else:
74
+ result["profile"] = {"path": str(profile_path), "action": "not_written"}
75
+ if args.json:
76
+ print(json.dumps(result, ensure_ascii=False, indent=2))
77
+ else:
78
+ host_os = result["host"].get("os", {})
79
+ print(f"Host: {host_os.get('pretty_name', host_os.get('system', 'unknown'))}")
80
+ for item in result["runs"]:
81
+ print(f"- {item['adapter']}: {item['executable']} ({'available' if item['available'] else 'missing'})")
82
+ for manager in result["installationGuide"]["packageManagers"]:
83
+ print(f"- Installation template ({manager['name']}): {manager['commandTemplate']}")
84
+ print(f"Profile: {result['profile']['action']} ({profile_path})")
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ raise SystemExit(main())
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env python3
2
+ """Render a safe static Codex MCP stanza for one SSH alias.
3
+
4
+ The script prints TOML; it deliberately never edits ~/.codex/config.toml and
5
+ never contacts the remote host. The SSH alias must already exist locally.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import re
12
+ import sys
13
+
14
+
15
+ def validate_alias(value: str) -> str:
16
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", value):
17
+ raise ValueError("SSH alias must contain only letters, digits, dot, underscore, or hyphen")
18
+ return value
19
+
20
+
21
+ def validate_remote_script(value: str) -> str:
22
+ if not value.startswith("/") or ".." in value.split("/") or not re.fullmatch(r"/[A-Za-z0-9_./-]+", value):
23
+ raise ValueError("remote script must be an absolute path without '..' or whitespace")
24
+ return value
25
+
26
+
27
+ def render(alias: str, remote_script: str) -> str:
28
+ alias = validate_alias(alias)
29
+ remote_script = validate_remote_script(remote_script)
30
+ server_name = "mnogovid_system_" + re.sub(r"[^A-Za-z0-9_]", "_", alias)
31
+ arguments = ["-T", "-o", "BatchMode=yes", "-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "StrictHostKeyChecking=yes", alias, "/usr/bin/python3", remote_script]
32
+ return "\n".join([f"[mcp_servers.{server_name}]", 'command = "ssh"', "args = [" + ", ".join(json.dumps(item) for item in arguments) + "]", ""])
33
+
34
+
35
+ def main() -> int:
36
+ parser = argparse.ArgumentParser(description="Render a static SSH-stdio MCP config stanza.")
37
+ parser.add_argument("ssh_alias", help="existing safe SSH alias from ~/.ssh/config")
38
+ parser.add_argument("--remote-script", default="/opt/mnogovid-system-scanner/scripts/system_mcp.py")
39
+ args = parser.parse_args()
40
+ try:
41
+ sys.stdout.write(render(args.ssh_alias, args.remote_script))
42
+ except ValueError as exc:
43
+ parser.error(str(exc))
44
+ return 0
45
+
46
+
47
+ if __name__ == "__main__":
48
+ raise SystemExit(main())