qualys-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qualys_cli/__init__.py +1 -0
- qualys_cli/__main__.py +8 -0
- qualys_cli/audit.py +616 -0
- qualys_cli/auth.py +187 -0
- qualys_cli/cache.py +123 -0
- qualys_cli/cli.py +1168 -0
- qualys_cli/client.py +1043 -0
- qualys_cli/commands/__init__.py +0 -0
- qualys_cli/commands/asset.py +183 -0
- qualys_cli/commands/ca.py +403 -0
- qualys_cli/commands/cs.py +410 -0
- qualys_cli/commands/csam.py +752 -0
- qualys_cli/commands/etm.py +170 -0
- qualys_cli/commands/pc.py +255 -0
- qualys_cli/commands/pm.py +412 -0
- qualys_cli/commands/scanauth.py +291 -0
- qualys_cli/commands/sub.py +163 -0
- qualys_cli/commands/tc.py +539 -0
- qualys_cli/commands/user.py +104 -0
- qualys_cli/commands/vm.py +562 -0
- qualys_cli/commands/was.py +1278 -0
- qualys_cli/config.py +331 -0
- qualys_cli/extras.py +702 -0
- qualys_cli/flair.py +202 -0
- qualys_cli/formatters.py +896 -0
- qualys_cli/history.py +133 -0
- qualys_cli/http_server.py +126 -0
- qualys_cli/mcp_server.py +341 -0
- qualys_cli/metrics.py +106 -0
- qualys_cli/platforms.py +67 -0
- qualys_cli/queries.py +137 -0
- qualys_cli-0.1.1.data/data/share/qualys-cli/docs/usage.html +1230 -0
- qualys_cli-0.1.1.dist-info/METADATA +319 -0
- qualys_cli-0.1.1.dist-info/RECORD +37 -0
- qualys_cli-0.1.1.dist-info/WHEEL +4 -0
- qualys_cli-0.1.1.dist-info/entry_points.txt +2 -0
- qualys_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
qualys_cli/history.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-profile invocation history — feeds ``qualys-cli last`` and ``replay``.
|
|
3
|
+
|
|
4
|
+
Each successful invocation that produced output writes a small JSON record at
|
|
5
|
+
``~/.config/qualys-cli/history/<profile>/<correlation>.json``. We keep the
|
|
6
|
+
last 200 invocations per profile (oldest pruned on write); enough for "I just
|
|
7
|
+
ran this with --format table, give me the same data as JSON" without unbounded
|
|
8
|
+
disk growth.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .config import HISTORY_DIR
|
|
20
|
+
|
|
21
|
+
_MAX_PER_PROFILE = 200
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe_component(value: str, *, what: str) -> str:
|
|
25
|
+
"""Reject any path-separator or traversal sequence in a filename component.
|
|
26
|
+
|
|
27
|
+
``profile`` names and correlation IDs both end up interpolated directly
|
|
28
|
+
into a filesystem path. Without this guard a crafted ``--profile`` or
|
|
29
|
+
``qualys-cli replay <id>`` argument (e.g. ``../../../../etc/passwd``)
|
|
30
|
+
could escape :data:`HISTORY_DIR` and read/write arbitrary files.
|
|
31
|
+
"""
|
|
32
|
+
v = value or ""
|
|
33
|
+
if not v or ".." in v or "/" in v or "\\" in v or "\x00" in v:
|
|
34
|
+
raise ValueError(f"Invalid {what}: {value!r}")
|
|
35
|
+
return v
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _dir(profile: str) -> Path:
|
|
39
|
+
name = _safe_component(profile or "default", what="profile name")
|
|
40
|
+
d = HISTORY_DIR / name
|
|
41
|
+
# Defence in depth — even if the check above is ever loosened, refuse to
|
|
42
|
+
# operate on anything that resolves outside HISTORY_DIR.
|
|
43
|
+
try:
|
|
44
|
+
d.resolve().relative_to(HISTORY_DIR.resolve())
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Refusing to operate on {d} — path escapes the history directory"
|
|
48
|
+
) from exc
|
|
49
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
return d
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def record(
|
|
54
|
+
profile: str,
|
|
55
|
+
correlation: str,
|
|
56
|
+
argv: list[str],
|
|
57
|
+
response: Any,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Persist one invocation result for later replay."""
|
|
60
|
+
if not correlation:
|
|
61
|
+
return
|
|
62
|
+
try:
|
|
63
|
+
corr = _safe_component(correlation, what="correlation ID")
|
|
64
|
+
d = _dir(profile)
|
|
65
|
+
d.parent.chmod(0o700)
|
|
66
|
+
with contextlib.suppress(Exception):
|
|
67
|
+
d.chmod(0o700)
|
|
68
|
+
rec = {
|
|
69
|
+
"ts": int(time.time()),
|
|
70
|
+
"argv": argv,
|
|
71
|
+
"correlation": correlation,
|
|
72
|
+
"response": response,
|
|
73
|
+
}
|
|
74
|
+
path = d / f"{corr}.json"
|
|
75
|
+
path.write_text(json.dumps(rec, default=str))
|
|
76
|
+
with contextlib.suppress(Exception):
|
|
77
|
+
path.chmod(0o600)
|
|
78
|
+
# Prune oldest if we exceed the cap.
|
|
79
|
+
# Use a safe sort that handles concurrent deletions (file may vanish
|
|
80
|
+
# between glob and stat).
|
|
81
|
+
files_with_mtime: list[tuple[float, Path]] = []
|
|
82
|
+
for f in d.glob("*.json"):
|
|
83
|
+
try:
|
|
84
|
+
files_with_mtime.append((f.stat().st_mtime, f))
|
|
85
|
+
except (OSError, FileNotFoundError):
|
|
86
|
+
continue
|
|
87
|
+
files_with_mtime.sort(reverse=True)
|
|
88
|
+
for _, old in files_with_mtime[_MAX_PER_PROFILE:]:
|
|
89
|
+
with contextlib.suppress(OSError, FileNotFoundError):
|
|
90
|
+
old.unlink()
|
|
91
|
+
except Exception:
|
|
92
|
+
# Never let history persistence break a command.
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def latest(profile: str) -> dict[str, Any] | None:
|
|
97
|
+
"""Return the most recent invocation record for *profile*, or None."""
|
|
98
|
+
try:
|
|
99
|
+
d = _dir(profile)
|
|
100
|
+
files = sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
101
|
+
if not files:
|
|
102
|
+
return None
|
|
103
|
+
rec_obj = json.loads(files[0].read_text())
|
|
104
|
+
return rec_obj if isinstance(rec_obj, dict) else None
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def lookup(profile: str, correlation: str) -> dict[str, Any] | None:
|
|
110
|
+
try:
|
|
111
|
+
corr = _safe_component(correlation, what="correlation ID")
|
|
112
|
+
p = _dir(profile) / f"{corr}.json"
|
|
113
|
+
if not p.exists():
|
|
114
|
+
return None
|
|
115
|
+
rec: dict[str, Any] = json.loads(p.read_text())
|
|
116
|
+
return rec
|
|
117
|
+
except Exception:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def list_recent(profile: str, n: int = 25) -> list[dict[str, Any]]:
|
|
122
|
+
try:
|
|
123
|
+
d = _dir(profile)
|
|
124
|
+
except ValueError:
|
|
125
|
+
return []
|
|
126
|
+
files = sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:n]
|
|
127
|
+
out: list[dict[str, Any]] = []
|
|
128
|
+
for f in files:
|
|
129
|
+
try:
|
|
130
|
+
out.append(json.loads(f.read_text()))
|
|
131
|
+
except Exception:
|
|
132
|
+
continue
|
|
133
|
+
return out
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal local HTTP server that exposes the CLI as a JSON-in / JSON-out API.
|
|
3
|
+
|
|
4
|
+
Why minimal: stdlib ``http.server`` is enough for a localhost-only operator
|
|
5
|
+
tool — no need to pull FastAPI / uvicorn into the runtime dependency set.
|
|
6
|
+
|
|
7
|
+
Endpoints:
|
|
8
|
+
|
|
9
|
+
GET / Health probe (returns {"ok": true})
|
|
10
|
+
GET /tools List every registered command + JSONSchema
|
|
11
|
+
POST /run Run one command. Body:
|
|
12
|
+
{"argv": ["vm", "host", "list"], "profile": "prod"}
|
|
13
|
+
Returns: {"ok": ..., "exit_code": N, "stdout": ..., "stderr": ...}
|
|
14
|
+
|
|
15
|
+
Defaults bind to 127.0.0.1 — there's no auth on the wire, so do NOT expose
|
|
16
|
+
this server to a network. Operators who want network access should put it
|
|
17
|
+
behind a reverse proxy that adds AuthN/Z.
|
|
18
|
+
|
|
19
|
+
No CORS headers are sent. A wildcard ``Access-Control-Allow-Origin: *``
|
|
20
|
+
would let any website the operator's browser visits issue cross-origin
|
|
21
|
+
requests to this server (a classic "drive-by RCE against localhost tooling"
|
|
22
|
+
pattern) since ``POST /run`` executes arbitrary ``qualys-cli`` commands.
|
|
23
|
+
Browser-based clients must be served from the same origin or proxied.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import subprocess
|
|
31
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_handler(profile: str) -> type:
|
|
36
|
+
from .mcp_server import _build_catalog
|
|
37
|
+
|
|
38
|
+
catalog = _build_catalog()
|
|
39
|
+
|
|
40
|
+
class Handler(BaseHTTPRequestHandler):
|
|
41
|
+
# Quiet: the default logger spams stderr — we already audit centrally.
|
|
42
|
+
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
def _send_json(self, obj: dict[str, Any], status: int = 200) -> None:
|
|
46
|
+
body = json.dumps(obj, default=str).encode("utf-8")
|
|
47
|
+
self.send_response(status)
|
|
48
|
+
self.send_header("Content-Type", "application/json")
|
|
49
|
+
self.send_header("Content-Length", str(len(body)))
|
|
50
|
+
# No CORS headers — see module docstring. A wildcard origin here
|
|
51
|
+
# would let any web page the operator's browser visits drive
|
|
52
|
+
# POST /run (arbitrary command execution) cross-origin.
|
|
53
|
+
self.end_headers()
|
|
54
|
+
self.wfile.write(body)
|
|
55
|
+
|
|
56
|
+
def do_OPTIONS(self) -> None: # noqa: N802 — http.server contract
|
|
57
|
+
"""No CORS preflight support — cross-origin browser clients are
|
|
58
|
+
intentionally not supported. See module docstring."""
|
|
59
|
+
self._send_json({"error": "not found"}, status=404)
|
|
60
|
+
|
|
61
|
+
def do_GET(self) -> None: # noqa: N802 — http.server contract
|
|
62
|
+
if self.path == "/":
|
|
63
|
+
self._send_json({"ok": True, "service": "qualys-cli", "tools": len(catalog)})
|
|
64
|
+
elif self.path.startswith("/tools"):
|
|
65
|
+
self._send_json({"tools": catalog})
|
|
66
|
+
else:
|
|
67
|
+
self._send_json({"error": "not found"}, status=404)
|
|
68
|
+
|
|
69
|
+
def do_POST(self) -> None: # noqa: N802
|
|
70
|
+
if self.path != "/run":
|
|
71
|
+
self._send_json({"error": "not found"}, status=404)
|
|
72
|
+
return
|
|
73
|
+
length = int(self.headers.get("Content-Length", "0") or 0)
|
|
74
|
+
try:
|
|
75
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
self._send_json({"error": "invalid JSON body"}, status=400)
|
|
78
|
+
return
|
|
79
|
+
argv = list(body.get("argv") or [])
|
|
80
|
+
if not argv:
|
|
81
|
+
self._send_json({"error": "argv is required"}, status=400)
|
|
82
|
+
return
|
|
83
|
+
# Validate argv elements are strings and don't contain shell metacharacters
|
|
84
|
+
# that could be exploited if subprocess were ever changed to use shell=True
|
|
85
|
+
for arg in argv:
|
|
86
|
+
if not isinstance(arg, str):
|
|
87
|
+
self._send_json({"error": "argv elements must be strings"}, status=400)
|
|
88
|
+
return
|
|
89
|
+
# Block any attempt to inject shell commands or escape sequences
|
|
90
|
+
if any(c in arg for c in [";", "|", "&", "`", "$", "\n", "\r", "\x00"]):
|
|
91
|
+
self._send_json({"error": "argv contains invalid characters"}, status=400)
|
|
92
|
+
return
|
|
93
|
+
run_profile = str(body.get("profile") or profile)
|
|
94
|
+
# Validate profile name doesn't contain path traversal or injection
|
|
95
|
+
if any(c in run_profile for c in ["/", "\\", ";", "|", "&", "`", "$", "\n", "\r", "\x00"]):
|
|
96
|
+
self._send_json({"error": "invalid profile name"}, status=400)
|
|
97
|
+
return
|
|
98
|
+
full = ["qualys-cli", "--profile", run_profile, *argv]
|
|
99
|
+
env = os.environ | {"QUALYS_OUTPUT_MODE": "agentic"}
|
|
100
|
+
proc = subprocess.run(full, capture_output=True, text=True, env=env, check=False)
|
|
101
|
+
try:
|
|
102
|
+
stdout: Any = json.loads(proc.stdout) if proc.stdout.strip() else None
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
stdout = proc.stdout
|
|
105
|
+
self._send_json({
|
|
106
|
+
"ok": proc.returncode == 0,
|
|
107
|
+
"exit_code": proc.returncode,
|
|
108
|
+
"stdout": stdout,
|
|
109
|
+
"stderr": proc.stderr.strip()[-500:] if proc.stderr else "",
|
|
110
|
+
}, status=200 if proc.returncode == 0 else 207)
|
|
111
|
+
|
|
112
|
+
return Handler
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run(*, host: str = "127.0.0.1", port: int = 8765, profile: str = "default") -> None:
|
|
116
|
+
handler = _build_handler(profile)
|
|
117
|
+
server = HTTPServer((host, port), handler)
|
|
118
|
+
print(f"qualys-cli HTTP server listening on http://{host}:{port}")
|
|
119
|
+
print(f" GET http://{host}:{port}/ health probe")
|
|
120
|
+
print(f" GET http://{host}:{port}/tools tool catalog")
|
|
121
|
+
print(f" POST http://{host}:{port}/run run a command")
|
|
122
|
+
print("Press Ctrl-C to stop.")
|
|
123
|
+
try:
|
|
124
|
+
server.serve_forever()
|
|
125
|
+
except KeyboardInterrupt:
|
|
126
|
+
server.server_close()
|
qualys_cli/mcp_server.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal MCP (Model Context Protocol) server adapter.
|
|
3
|
+
|
|
4
|
+
Why minimal: we want this feature to ship without adding the MCP SDK as a
|
|
5
|
+
runtime dependency. The MCP wire protocol is JSON-RPC 2.0 over stdio with a
|
|
6
|
+
small set of methods (``initialize``, ``tools/list``, ``tools/call``). We
|
|
7
|
+
implement only those methods — sufficient for Claude Desktop, Cursor, and
|
|
8
|
+
the official MCP inspector to drive the CLI.
|
|
9
|
+
|
|
10
|
+
Each registered Typer command becomes one MCP tool. The tool name is the
|
|
11
|
+
dotted command path with hyphens (``vm.host.list``); the input schema is
|
|
12
|
+
generated from the command's option signature via the same introspection
|
|
13
|
+
the HTML doc generator uses. Tool execution shells out to ``qualys-cli`` in
|
|
14
|
+
agentic mode and returns the parsed JSON response as the tool result.
|
|
15
|
+
|
|
16
|
+
Run with ``qualys-cli mcp``. To wire it into Claude Desktop, add::
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"qualys-cli": {
|
|
21
|
+
"command": "qualys-cli",
|
|
22
|
+
"args": ["mcp"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
to ``~/Library/Application Support/Claude/claude_desktop_config.json``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import inspect
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import types
|
|
38
|
+
import typing
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
import typer
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_version() -> str:
|
|
45
|
+
"""Get the package version dynamically."""
|
|
46
|
+
try:
|
|
47
|
+
from importlib.metadata import version
|
|
48
|
+
return version("qualys-cli")
|
|
49
|
+
except Exception:
|
|
50
|
+
return "0.0.0"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _walk(t: typer.Typer, prefix: str = "") -> list[tuple[str, Any]]:
|
|
54
|
+
out: list[tuple[str, Any]] = []
|
|
55
|
+
for c in (t.registered_commands or []):
|
|
56
|
+
if c.callback is None:
|
|
57
|
+
continue
|
|
58
|
+
cname = c.name if isinstance(c.name, str) else c.callback.__name__.replace("_", "-")
|
|
59
|
+
full = f"{prefix} {cname}".strip()
|
|
60
|
+
out.append((full, c.callback))
|
|
61
|
+
for g in (t.registered_groups or []):
|
|
62
|
+
sub = g.typer_instance
|
|
63
|
+
if sub is None:
|
|
64
|
+
continue
|
|
65
|
+
gname = sub.info.name if sub.info and isinstance(sub.info.name, str) else g.name
|
|
66
|
+
if not isinstance(gname, str):
|
|
67
|
+
continue
|
|
68
|
+
out.extend(_walk(sub, f"{prefix} {gname}".strip()))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _type_to_jsonschema(annotation: object) -> dict[str, Any]:
|
|
73
|
+
a = annotation
|
|
74
|
+
if isinstance(a, types.UnionType) or typing.get_origin(a) is typing.Union:
|
|
75
|
+
args = [t for t in typing.get_args(a) if t is not type(None)]
|
|
76
|
+
if args:
|
|
77
|
+
a = args[0]
|
|
78
|
+
if a is bool:
|
|
79
|
+
return {"type": "boolean"}
|
|
80
|
+
if a is int:
|
|
81
|
+
return {"type": "integer"}
|
|
82
|
+
if a is float:
|
|
83
|
+
return {"type": "number"}
|
|
84
|
+
if typing.get_origin(a) is list:
|
|
85
|
+
return {"type": "array", "items": {"type": "string"}}
|
|
86
|
+
return {"type": "string"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _schema_for(callback: Any) -> dict[str, Any]:
|
|
90
|
+
sig = inspect.signature(callback)
|
|
91
|
+
try:
|
|
92
|
+
hints = typing.get_type_hints(callback)
|
|
93
|
+
except Exception:
|
|
94
|
+
hints = {}
|
|
95
|
+
props: dict[str, Any] = {}
|
|
96
|
+
required: list[str] = []
|
|
97
|
+
for pname, p in sig.parameters.items():
|
|
98
|
+
annot = hints.get(pname, p.annotation)
|
|
99
|
+
if annot is typer.Context:
|
|
100
|
+
continue
|
|
101
|
+
info = p.default
|
|
102
|
+
if not isinstance(info, (typer.models.OptionInfo, typer.models.ArgumentInfo)):
|
|
103
|
+
continue
|
|
104
|
+
is_arg = isinstance(info, typer.models.ArgumentInfo)
|
|
105
|
+
decls = info.param_decls or ((pname.upper(),) if is_arg else (f"--{pname.replace('_', '-')}",))
|
|
106
|
+
flag = decls[0]
|
|
107
|
+
# MCP tool argument names mirror the long-flag form, normalised
|
|
108
|
+
key = flag.lstrip("-").replace("-", "_")
|
|
109
|
+
sch = _type_to_jsonschema(annot)
|
|
110
|
+
if info.help:
|
|
111
|
+
sch["description"] = info.help
|
|
112
|
+
props[key] = sch
|
|
113
|
+
if info.default is ...:
|
|
114
|
+
required.append(key)
|
|
115
|
+
return {"type": "object", "properties": props, "required": required}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _normalise_tool_name(full: str) -> str:
|
|
119
|
+
"""Convert a CLI command path into an MCP tool name.
|
|
120
|
+
|
|
121
|
+
Anthropic's tool-name validator (FrontendRemoteMcpToolDefinition) requires
|
|
122
|
+
``^[a-zA-Z0-9_-]{1,64}$`` — dots are rejected. We map space-separators in
|
|
123
|
+
the command path to underscores and any leftover dots to underscores too.
|
|
124
|
+
Hyphens that occur naturally in CLI verbs (e.g. ``rotate-jwt``,
|
|
125
|
+
``list-easm``) are kept since they are inside the allowed character set.
|
|
126
|
+
The 64-character limit is plenty for every real Qualys command path.
|
|
127
|
+
"""
|
|
128
|
+
name = full.replace(" ", "_").replace(".", "_")
|
|
129
|
+
# Defensive scrub: replace anything outside the allowed alphabet with `_`
|
|
130
|
+
# so an unexpected docstring-derived character can't break the validator.
|
|
131
|
+
safe = "".join(ch if (ch.isalnum() or ch in {"_", "-"}) else "_" for ch in name)
|
|
132
|
+
return safe[:64]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _denormalise_tool_name(tool_name: str) -> list[str]:
|
|
136
|
+
"""Inverse of :func:`_normalise_tool_name` for argv reconstruction.
|
|
137
|
+
|
|
138
|
+
A tool name like ``vm_scan_launch`` maps back to the argv ``["vm", "scan",
|
|
139
|
+
"launch"]``. Verbs that contain hyphens stay intact (``rotate-jwt`` →
|
|
140
|
+
``["rotate-jwt"]``) because we only split on the underscore separator.
|
|
141
|
+
"""
|
|
142
|
+
return [seg for seg in tool_name.split("_") if seg]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _build_catalog() -> list[dict[str, Any]]:
|
|
146
|
+
"""Build the MCP tool catalog by walking the live Typer app."""
|
|
147
|
+
from .cli import app as root_app
|
|
148
|
+
tools: list[dict[str, Any]] = []
|
|
149
|
+
for grp in (root_app.registered_groups or []):
|
|
150
|
+
sub = grp.typer_instance
|
|
151
|
+
if sub is None:
|
|
152
|
+
continue
|
|
153
|
+
mod_key = sub.info.name if sub.info else grp.name
|
|
154
|
+
if not isinstance(mod_key, str):
|
|
155
|
+
continue
|
|
156
|
+
for full, cb in _walk(sub, mod_key):
|
|
157
|
+
doc = (cb.__doc__ or "").strip().splitlines()
|
|
158
|
+
summary = doc[0].strip() if doc else full
|
|
159
|
+
tools.append({
|
|
160
|
+
"name": _normalise_tool_name(full),
|
|
161
|
+
"description": summary,
|
|
162
|
+
"inputSchema": _schema_for(cb),
|
|
163
|
+
})
|
|
164
|
+
return tools
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _argv_for(tool_name: str, args: dict[str, Any]) -> list[str]:
|
|
168
|
+
parts = ["qualys-cli", *_denormalise_tool_name(tool_name)]
|
|
169
|
+
for k, v in args.items():
|
|
170
|
+
if v is None:
|
|
171
|
+
continue
|
|
172
|
+
flag = "--" + k.replace("_", "-")
|
|
173
|
+
if isinstance(v, bool):
|
|
174
|
+
if v:
|
|
175
|
+
parts.append(flag)
|
|
176
|
+
elif isinstance(v, list):
|
|
177
|
+
for item in v:
|
|
178
|
+
parts.append(flag)
|
|
179
|
+
parts.append(str(item))
|
|
180
|
+
else:
|
|
181
|
+
parts.append(flag)
|
|
182
|
+
parts.append(str(v))
|
|
183
|
+
return parts
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# subprocess.run is invoked with a list (no shell) below, so traditional
|
|
187
|
+
# shell-metachar blocking is not needed for safety. We only reject NULs
|
|
188
|
+
# (which subprocess can't carry anyway) and ASCII control characters that
|
|
189
|
+
# would corrupt the audit log or terminal output.
|
|
190
|
+
def _validate_arg(arg: str) -> bool:
|
|
191
|
+
"""Reject only characters that could corrupt downstream logging or that
|
|
192
|
+
cannot be safely transported by ``subprocess.run`` with a list argv.
|
|
193
|
+
|
|
194
|
+
Shell metacharacters (``;|&`` etc.) are *not* rejected — list-form
|
|
195
|
+
subprocess never invokes a shell, so injecting them does nothing. Blocking
|
|
196
|
+
them previously rejected legitimate inputs like single-quoted JSON bodies,
|
|
197
|
+
regex predicates, and shell-style globs.
|
|
198
|
+
"""
|
|
199
|
+
if "\x00" in arg:
|
|
200
|
+
return False
|
|
201
|
+
return all(c >= " " or c in ("\t",) for c in arg)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _invoke(tool_name: str, args: dict[str, Any], profile: str) -> dict[str, Any]:
|
|
205
|
+
if not _validate_arg(tool_name) or (profile and not _validate_arg(profile)):
|
|
206
|
+
return {
|
|
207
|
+
"ok": False,
|
|
208
|
+
"exit_code": 1,
|
|
209
|
+
"stdout": None,
|
|
210
|
+
"stderr": "Argument contains NUL or unprintable control characters",
|
|
211
|
+
}
|
|
212
|
+
for k, v in args.items():
|
|
213
|
+
if isinstance(v, str) and not _validate_arg(v):
|
|
214
|
+
return {
|
|
215
|
+
"ok": False,
|
|
216
|
+
"exit_code": 1,
|
|
217
|
+
"stdout": None,
|
|
218
|
+
"stderr": f"Argument contains NUL or unprintable control characters: {k}",
|
|
219
|
+
}
|
|
220
|
+
if isinstance(v, list):
|
|
221
|
+
for item in v:
|
|
222
|
+
if isinstance(item, str) and not _validate_arg(item):
|
|
223
|
+
return {
|
|
224
|
+
"ok": False,
|
|
225
|
+
"exit_code": 1,
|
|
226
|
+
"stdout": None,
|
|
227
|
+
"stderr": f"Argument contains NUL or unprintable control characters: {k}",
|
|
228
|
+
}
|
|
229
|
+
argv = _argv_for(tool_name, args)
|
|
230
|
+
if profile and "--profile" not in argv:
|
|
231
|
+
argv.insert(1, "--profile")
|
|
232
|
+
argv.insert(2, profile)
|
|
233
|
+
env = os.environ | {"QUALYS_OUTPUT_MODE": "agentic"}
|
|
234
|
+
proc = subprocess.run(argv, capture_output=True, text=True, env=env, check=False)
|
|
235
|
+
try:
|
|
236
|
+
data: Any = json.loads(proc.stdout) if proc.stdout.strip() else None
|
|
237
|
+
except json.JSONDecodeError:
|
|
238
|
+
data = proc.stdout
|
|
239
|
+
return {
|
|
240
|
+
"ok": proc.returncode == 0,
|
|
241
|
+
"exit_code": proc.returncode,
|
|
242
|
+
"stdout": data,
|
|
243
|
+
"stderr": proc.stderr.strip()[-500:] if proc.stderr else "",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# JSON-RPC stdio loop
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
_PROTOCOL_VERSION = "2024-11-05"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _send(obj: dict[str, Any]) -> None:
|
|
255
|
+
sys.stdout.write(json.dumps(obj, default=str) + "\n")
|
|
256
|
+
sys.stdout.flush()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _is_notification(msg: dict[str, Any]) -> bool:
|
|
260
|
+
"""JSON-RPC 2.0: a notification is a request without an ``id`` field.
|
|
261
|
+
|
|
262
|
+
The spec is explicit — servers MUST NOT respond to notifications. Sending
|
|
263
|
+
any frame back (even an error) violates strict validators (Anthropic's
|
|
264
|
+
MCP validator rejects ``id: null`` outright, and ``id`` is missing here)
|
|
265
|
+
so we silently ignore them.
|
|
266
|
+
"""
|
|
267
|
+
return "id" not in msg
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run(*, profile: str = "default") -> None:
|
|
271
|
+
"""Read JSON-RPC requests from stdin, write responses to stdout.
|
|
272
|
+
|
|
273
|
+
Implements the request/response side of MCP (initialize, tools/list,
|
|
274
|
+
tools/call, ping) and silently absorbs the standard notifications that
|
|
275
|
+
Claude Desktop / Cursor / other clients emit during their boot dance
|
|
276
|
+
(notifications/initialized, prompts/list, resources/list, …).
|
|
277
|
+
"""
|
|
278
|
+
catalog = _build_catalog()
|
|
279
|
+
|
|
280
|
+
for raw in sys.stdin:
|
|
281
|
+
raw = raw.strip()
|
|
282
|
+
if not raw:
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
msg = json.loads(raw)
|
|
286
|
+
except json.JSONDecodeError:
|
|
287
|
+
# Per JSON-RPC, a parse error gets a response with id=null. We
|
|
288
|
+
# write one only if this looks like a real attempt at JSON; bare
|
|
289
|
+
# newlines / control chars get silently dropped.
|
|
290
|
+
continue
|
|
291
|
+
if not isinstance(msg, dict):
|
|
292
|
+
continue
|
|
293
|
+
method = msg.get("method")
|
|
294
|
+
if not isinstance(method, str) or not method:
|
|
295
|
+
# Not a valid JSON-RPC request/notification — ignore.
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
# Notifications never get a response. This includes Claude Desktop's
|
|
299
|
+
# post-handshake "notifications/initialized" frame and any other
|
|
300
|
+
# unsolicited message the client sends without an id.
|
|
301
|
+
if _is_notification(msg):
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
rpc_id = msg["id"]
|
|
305
|
+
|
|
306
|
+
if method == "initialize":
|
|
307
|
+
_send({
|
|
308
|
+
"jsonrpc": "2.0", "id": rpc_id,
|
|
309
|
+
"result": {
|
|
310
|
+
"protocolVersion": _PROTOCOL_VERSION,
|
|
311
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
312
|
+
"serverInfo": {"name": "qualys-cli", "version": _get_version()},
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
elif method == "tools/list":
|
|
316
|
+
_send({"jsonrpc": "2.0", "id": rpc_id, "result": {"tools": catalog}})
|
|
317
|
+
elif method == "tools/call":
|
|
318
|
+
params = msg.get("params") or {}
|
|
319
|
+
tool_name = params.get("name", "")
|
|
320
|
+
args = params.get("arguments") or {}
|
|
321
|
+
result = _invoke(tool_name, args, profile)
|
|
322
|
+
content = [{"type": "text", "text": json.dumps(result, default=str, indent=2)}]
|
|
323
|
+
_send({
|
|
324
|
+
"jsonrpc": "2.0", "id": rpc_id,
|
|
325
|
+
"result": {"content": content, "isError": not result["ok"]},
|
|
326
|
+
})
|
|
327
|
+
elif method == "ping":
|
|
328
|
+
_send({"jsonrpc": "2.0", "id": rpc_id, "result": {}})
|
|
329
|
+
elif method in ("prompts/list", "resources/list", "resources/templates/list"):
|
|
330
|
+
# Capabilities we don't expose. Return an empty list rather than
|
|
331
|
+
# method-not-found so clients that probe these on startup get a
|
|
332
|
+
# clean answer and don't log warnings.
|
|
333
|
+
key = "prompts" if "prompts" in method else (
|
|
334
|
+
"resourceTemplates" if "templates" in method else "resources"
|
|
335
|
+
)
|
|
336
|
+
_send({"jsonrpc": "2.0", "id": rpc_id, "result": {key: []}})
|
|
337
|
+
else:
|
|
338
|
+
_send({
|
|
339
|
+
"jsonrpc": "2.0", "id": rpc_id,
|
|
340
|
+
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
|
341
|
+
})
|