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/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
+ }
@@ -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