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/metrics.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local audit-log analytics — feeds ``qualys-cli stats``.
|
|
3
|
+
|
|
4
|
+
Pure local reader: scans ``~/.config/qualys-cli/audit.log`` (and its rotated
|
|
5
|
+
``.1`` companion) and computes:
|
|
6
|
+
|
|
7
|
+
- Total commands and HTTP calls
|
|
8
|
+
- Top endpoints by call count
|
|
9
|
+
- Latency percentiles (p50 / p90 / p99)
|
|
10
|
+
- Retry rate
|
|
11
|
+
- Per-profile error breakdown
|
|
12
|
+
|
|
13
|
+
No backend required. Failures degrade to "no data" rather than crashing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import statistics
|
|
20
|
+
from collections import Counter, defaultdict
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _audit_paths() -> list[Path]:
|
|
26
|
+
base = Path.home() / ".config" / "qualys-cli" / "audit.log"
|
|
27
|
+
paths: list[Path] = []
|
|
28
|
+
if base.exists():
|
|
29
|
+
paths.append(base)
|
|
30
|
+
backup = base.with_name(base.name + ".1")
|
|
31
|
+
if backup.exists():
|
|
32
|
+
paths.append(backup)
|
|
33
|
+
return paths
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _records(paths: list[Path]) -> list[dict[str, Any]]:
|
|
37
|
+
out: list[dict[str, Any]] = []
|
|
38
|
+
for p in paths:
|
|
39
|
+
try:
|
|
40
|
+
for raw in p.read_text().splitlines():
|
|
41
|
+
if not raw.strip():
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
out.append(json.loads(raw))
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
continue
|
|
47
|
+
except OSError:
|
|
48
|
+
continue
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def compute() -> dict[str, Any]:
|
|
53
|
+
"""Return a serialisable summary dict of the local audit log."""
|
|
54
|
+
recs = _records(_audit_paths())
|
|
55
|
+
if not recs:
|
|
56
|
+
return {"total": 0}
|
|
57
|
+
|
|
58
|
+
cmd_recs = [r for r in recs if r.get("event") == "command"]
|
|
59
|
+
http_recs = [r for r in recs if r.get("event") == "http"]
|
|
60
|
+
err_recs = [r for r in recs if r.get("event") == "error"]
|
|
61
|
+
|
|
62
|
+
starts = [r for r in cmd_recs if r.get("phase") == "start"]
|
|
63
|
+
ends = [r for r in cmd_recs if r.get("phase") == "end"]
|
|
64
|
+
cmd_durations = [int(r.get("duration_ms", 0)) for r in ends if r.get("duration_ms")]
|
|
65
|
+
exit_codes = Counter(int(r.get("exit_code", 0)) for r in ends)
|
|
66
|
+
|
|
67
|
+
durations = [int(r.get("duration_ms", 0)) for r in http_recs if r.get("duration_ms")]
|
|
68
|
+
retries = sum(1 for r in http_recs if int(r.get("attempts", 1)) > 1)
|
|
69
|
+
|
|
70
|
+
top_endpoints = Counter(
|
|
71
|
+
(r.get("method", "?"), r.get("path", "?").split("?")[0])
|
|
72
|
+
for r in http_recs
|
|
73
|
+
).most_common(15)
|
|
74
|
+
|
|
75
|
+
by_profile_errors: dict[str, Counter[str]] = defaultdict(Counter)
|
|
76
|
+
for r in err_recs:
|
|
77
|
+
prof = r.get("profile", "default")
|
|
78
|
+
by_profile_errors[prof][r.get("type", "Error")] += 1
|
|
79
|
+
|
|
80
|
+
def pct(values: list[int], p: float) -> int:
|
|
81
|
+
if not values:
|
|
82
|
+
return 0
|
|
83
|
+
srt = sorted(values)
|
|
84
|
+
idx = max(0, min(len(srt) - 1, int(round((p / 100) * (len(srt) - 1)))))
|
|
85
|
+
return srt[idx]
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"total_commands": len(starts),
|
|
89
|
+
"total_http_calls": len(http_recs),
|
|
90
|
+
"total_errors": len(err_recs),
|
|
91
|
+
"command_duration_ms_p50": int(statistics.median(cmd_durations)) if cmd_durations else 0,
|
|
92
|
+
"http_duration_ms": {
|
|
93
|
+
"p50": pct(durations, 50),
|
|
94
|
+
"p90": pct(durations, 90),
|
|
95
|
+
"p99": pct(durations, 99),
|
|
96
|
+
"max": max(durations) if durations else 0,
|
|
97
|
+
},
|
|
98
|
+
"retry_rate_pct": round(100 * retries / max(1, len(http_recs)), 2),
|
|
99
|
+
"exit_codes": dict(exit_codes),
|
|
100
|
+
"top_endpoints": [
|
|
101
|
+
{"method": m, "path": p, "count": n} for (m, p), n in top_endpoints
|
|
102
|
+
],
|
|
103
|
+
"errors_by_profile": {
|
|
104
|
+
prof: dict(types) for prof, types in by_profile_errors.items()
|
|
105
|
+
},
|
|
106
|
+
}
|
qualys_cli/platforms.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qualys platform identification from username.
|
|
3
|
+
|
|
4
|
+
Source: https://www.qualys.com/platform-identification
|
|
5
|
+
|
|
6
|
+
Username format: quays[IDENTIFIER][rest]
|
|
7
|
+
The platform identifier is the 5th character (index 4) of the username.
|
|
8
|
+
|
|
9
|
+
Gateway URLs are required for: Asset Inventory, Container Security, EDR, FIM.
|
|
10
|
+
API Server URLs are used for all other apps.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Platform:
|
|
20
|
+
name: str
|
|
21
|
+
api_url: str # API Server URL — used by most apps
|
|
22
|
+
gateway_url: str # API Gateway URL — used by CS, CSAM, EDR, FIM
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_PLATFORMS: dict[str, Platform] = {
|
|
26
|
+
"_": Platform("US1", "https://qualysapi.qualys.com", "https://gateway.qg1.apps.qualys.com"),
|
|
27
|
+
"2": Platform("US2", "https://qualysapi.qg2.apps.qualys.com", "https://gateway.qg2.apps.qualys.com"),
|
|
28
|
+
"3": Platform("US3", "https://qualysapi.qg3.apps.qualys.com", "https://gateway.qg3.apps.qualys.com"),
|
|
29
|
+
"6": Platform("US4", "https://qualysapi.qg4.apps.qualys.com", "https://gateway.qg4.apps.qualys.com"),
|
|
30
|
+
"C": Platform("GOV1", "https://qualysapi.gov1.qualys.us", "https://gateway.gov1.qualys.us"),
|
|
31
|
+
"-": Platform("EU1", "https://qualysapi.qualys.eu", "https://gateway.qg1.apps.qualys.eu"),
|
|
32
|
+
"5": Platform("EU2", "https://qualysapi.qg2.apps.qualys.eu", "https://gateway.qg2.apps.qualys.eu"),
|
|
33
|
+
"!": Platform("EU2", "https://qualysapi.qg2.apps.qualys.eu", "https://gateway.qg2.apps.qualys.eu"),
|
|
34
|
+
"B": Platform("EU3", "https://qualysapi.qg3.apps.qualys.it", "https://gateway.qg3.apps.qualys.it"),
|
|
35
|
+
"8": Platform("IN1", "https://qualysapi.qg1.apps.qualys.in", "https://gateway.qg1.apps.qualys.in"),
|
|
36
|
+
"9": Platform("CA1", "https://qualysapi.qg1.apps.qualys.ca", "https://gateway.qg1.apps.qualys.ca"),
|
|
37
|
+
"7": Platform("AE1", "https://qualysapi.qg1.apps.qualys.ae", "https://gateway.qg1.apps.qualys.ae"),
|
|
38
|
+
"1": Platform("UK1", "https://qualysapi.qg1.apps.qualys.co.uk", "https://gateway.qg1.apps.qualys.co.uk"),
|
|
39
|
+
"4": Platform("AU1", "https://qualysapi.qg1.apps.qualys.com.au","https://gateway.qg1.apps.qualys.com.au"),
|
|
40
|
+
"A": Platform("KSA1", "https://qualysapi.qg1.apps.qualysksa.com","https://gateway.qg1.apps.qualysksa.com"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_PREFIX = "quays"
|
|
44
|
+
_ID_INDEX = len(_PREFIX) # character at index 4
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect(username: str) -> Platform:
|
|
48
|
+
"""Return the Platform for the given Qualys username.
|
|
49
|
+
|
|
50
|
+
Raises ValueError if the username is too short or the identifier is unknown.
|
|
51
|
+
"""
|
|
52
|
+
if len(username) <= _ID_INDEX:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Cannot identify platform from username '{username}'. "
|
|
55
|
+
"Expected Qualys username format: quays[identifier]… "
|
|
56
|
+
"(see https://www.qualys.com/platform-identification)"
|
|
57
|
+
)
|
|
58
|
+
identifier = username[_ID_INDEX]
|
|
59
|
+
platform = _PLATFORMS.get(identifier)
|
|
60
|
+
if platform is None:
|
|
61
|
+
valid = ", ".join(repr(k) for k in _PLATFORMS)
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Unknown platform identifier '{identifier}' in username '{username}'. "
|
|
64
|
+
f"Valid identifiers: {valid}. "
|
|
65
|
+
"See https://www.qualys.com/platform-identification"
|
|
66
|
+
)
|
|
67
|
+
return platform
|
qualys_cli/queries.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Saved-query library.
|
|
3
|
+
|
|
4
|
+
Each TOML file under ``~/.config/qualys-cli/queries/<name>.toml`` describes one
|
|
5
|
+
named, shareable invocation. The schema is intentionally minimal:
|
|
6
|
+
|
|
7
|
+
name = "prod-criticals"
|
|
8
|
+
description = "Internet-facing prod hosts with severity-5 vulnerabilities"
|
|
9
|
+
command = ["vm", "host", "list"]
|
|
10
|
+
args = ["--ips", "10.0.0.0/8", "--severities", "5"]
|
|
11
|
+
|
|
12
|
+
The CLI's top-level ``qualys-cli query run prod-criticals`` resolves the file,
|
|
13
|
+
splices ``args`` into argv after the resolved command, and dispatches as if the
|
|
14
|
+
user typed it manually. ``qualys-cli query list`` and ``query show`` provide
|
|
15
|
+
discoverability; ``query save`` writes a query from the current invocation.
|
|
16
|
+
|
|
17
|
+
Security notes
|
|
18
|
+
--------------
|
|
19
|
+
- Query names are restricted to ``[A-Za-z0-9_-]+`` so they cannot escape the
|
|
20
|
+
queries directory via ``..`` or absolute-path traversal. Every load / save
|
|
21
|
+
/ delete additionally checks that the resolved path is contained within
|
|
22
|
+
``QUERIES_DIR.resolve()`` — defence in depth against future regex slippage.
|
|
23
|
+
- All user-supplied strings (name, description, command and args entries)
|
|
24
|
+
are serialised through :func:`_toml_str`, which delegates to
|
|
25
|
+
``json.dumps``. JSON's basic-string grammar is a strict subset of TOML's,
|
|
26
|
+
so this gives us correct escaping for backslashes, quotes, control chars,
|
|
27
|
+
and non-ASCII without re-implementing the TOML spec by hand.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import tomllib
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from .config import QUERIES_DIR
|
|
39
|
+
|
|
40
|
+
# `_NAME_RE` deliberately excludes "." — that class allowed `..` traversal in
|
|
41
|
+
# earlier builds. Names today must be filename-safe across platforms.
|
|
42
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SavedQuery:
|
|
47
|
+
name: str
|
|
48
|
+
command: list[str]
|
|
49
|
+
args: list[str] = field(default_factory=list)
|
|
50
|
+
description: str = ""
|
|
51
|
+
|
|
52
|
+
def to_toml(self) -> str:
|
|
53
|
+
lines: list[str] = [f"name = {_toml_str(self.name)}"]
|
|
54
|
+
if self.description:
|
|
55
|
+
lines.append(f"description = {_toml_str(self.description)}")
|
|
56
|
+
lines.append("command = [" + ", ".join(_toml_str(c) for c in self.command) + "]")
|
|
57
|
+
if self.args:
|
|
58
|
+
lines.append("args = [" + ", ".join(_toml_str(a) for a in self.args) + "]")
|
|
59
|
+
return "\n".join(lines) + "\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _toml_str(s: str) -> str:
|
|
63
|
+
"""Serialise *s* as a TOML basic string.
|
|
64
|
+
|
|
65
|
+
JSON's basic-string grammar (RFC 8259 §7) is a strict subset of TOML's
|
|
66
|
+
basic-string grammar (TOML v1.0 §5), so ``json.dumps(s)`` produces output
|
|
67
|
+
that's always valid TOML. ``ensure_ascii=False`` keeps Unicode literal
|
|
68
|
+
rather than \\u-escaping it — TOML accepts both, but the literal form is
|
|
69
|
+
more readable in saved-query files reviewers will want to skim.
|
|
70
|
+
"""
|
|
71
|
+
return json.dumps(s, ensure_ascii=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _path(name: str) -> Path:
|
|
75
|
+
if not _NAME_RE.match(name):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Invalid query name: {name!r} — must match [A-Za-z0-9_-]+ "
|
|
78
|
+
"(no dots, slashes, or path separators)"
|
|
79
|
+
)
|
|
80
|
+
p = QUERIES_DIR / f"{name}.toml"
|
|
81
|
+
# Defence in depth: even if the regex is ever loosened, refuse to operate
|
|
82
|
+
# on anything that resolves outside QUERIES_DIR.
|
|
83
|
+
try:
|
|
84
|
+
p.resolve().relative_to(QUERIES_DIR.resolve())
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Refusing to operate on {p} — path escapes the queries directory"
|
|
88
|
+
) from exc
|
|
89
|
+
return p
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def list_queries() -> list[SavedQuery]:
|
|
93
|
+
QUERIES_DIR.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
out: list[SavedQuery] = []
|
|
95
|
+
for f in sorted(QUERIES_DIR.glob("*.toml")):
|
|
96
|
+
try:
|
|
97
|
+
data = tomllib.loads(f.read_text())
|
|
98
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
99
|
+
continue
|
|
100
|
+
out.append(SavedQuery(
|
|
101
|
+
name=str(data.get("name", f.stem)),
|
|
102
|
+
command=list(data.get("command", [])),
|
|
103
|
+
args=list(data.get("args", [])),
|
|
104
|
+
description=str(data.get("description", "")),
|
|
105
|
+
))
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load(name: str) -> SavedQuery:
|
|
110
|
+
p = _path(name)
|
|
111
|
+
if not p.exists():
|
|
112
|
+
raise FileNotFoundError(f"No saved query named {name!r} at {p}")
|
|
113
|
+
data = tomllib.loads(p.read_text())
|
|
114
|
+
return SavedQuery(
|
|
115
|
+
name=str(data.get("name", name)),
|
|
116
|
+
command=list(data.get("command", [])),
|
|
117
|
+
args=list(data.get("args", [])),
|
|
118
|
+
description=str(data.get("description", "")),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save(q: SavedQuery, *, overwrite: bool = False) -> Path:
|
|
123
|
+
p = _path(q.name)
|
|
124
|
+
if p.exists() and not overwrite:
|
|
125
|
+
raise FileExistsError(f"Query {q.name!r} already exists at {p}")
|
|
126
|
+
QUERIES_DIR.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
p.write_text(q.to_toml())
|
|
128
|
+
p.chmod(0o600)
|
|
129
|
+
return p
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def delete(name: str) -> bool:
|
|
133
|
+
p = _path(name)
|
|
134
|
+
if p.exists():
|
|
135
|
+
p.unlink()
|
|
136
|
+
return True
|
|
137
|
+
return False
|