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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
qualys_cli/__main__.py
ADDED
qualys_cli/audit.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Append-only JSONL audit log for every CLI invocation and HTTP call.
|
|
3
|
+
|
|
4
|
+
Design goals
|
|
5
|
+
------------
|
|
6
|
+
- Always-on by default. Operators expect an audit trail for compliance review.
|
|
7
|
+
- Cheap on the hot path: single-process append, line-buffered, ~µs per record.
|
|
8
|
+
- Never logs secrets. Passwords, tokens, Authorization headers, and any
|
|
9
|
+
param/body field whose key matches the redaction list are replaced with
|
|
10
|
+
the literal string ``"***REDACTED***"``. JWT tokens are summarised as a
|
|
11
|
+
``jwt:<sha256-prefix>`` fingerprint so the same token can be correlated
|
|
12
|
+
across log lines without exposing it.
|
|
13
|
+
- Tolerant of failures. If the log file can't be opened (read-only filesystem,
|
|
14
|
+
permissions, etc.) the audit machinery silently degrades to a no-op rather
|
|
15
|
+
than breaking the user's command.
|
|
16
|
+
- Size-based rotation. When the active file grows past ``ROTATE_AT_BYTES``
|
|
17
|
+
it is renamed to ``audit.log.1`` (older ``.1`` is dropped) and a fresh
|
|
18
|
+
file is opened. One generation of history is kept; that is enough for the
|
|
19
|
+
short-window tail-and-investigate workflow we expect, without unbounded disk
|
|
20
|
+
growth on long-running CI hosts.
|
|
21
|
+
|
|
22
|
+
Configuration
|
|
23
|
+
-------------
|
|
24
|
+
``QUALYS_AUDIT_LOG`` env var:
|
|
25
|
+
- unset / empty / ``default`` → ``~/.config/qualys-cli/audit.log``
|
|
26
|
+
- ``off`` / ``none`` / ``disabled`` → audit is disabled
|
|
27
|
+
- ``-`` or ``stderr`` → write to stderr (one JSON object per line)
|
|
28
|
+
- any other value → treated as a path
|
|
29
|
+
|
|
30
|
+
``QUALYS_AUDIT_MAX_BYTES`` env var (optional):
|
|
31
|
+
Override the rotation threshold (default 10 MB).
|
|
32
|
+
|
|
33
|
+
Record shape
|
|
34
|
+
------------
|
|
35
|
+
Each record is a single-line JSON object with these keys (some are optional):
|
|
36
|
+
|
|
37
|
+
ts ISO-8601 UTC timestamp with millisecond precision
|
|
38
|
+
event one of "command", "http", "error"
|
|
39
|
+
correlation per-invocation correlation ID (also sent as request header)
|
|
40
|
+
profile active credential profile name
|
|
41
|
+
user Qualys username for the active profile (no password)
|
|
42
|
+
pid OS pid of the CLI process (helps de-interleave concurrent runs)
|
|
43
|
+
version qualys-cli version string
|
|
44
|
+
|
|
45
|
+
Per ``event``:
|
|
46
|
+
|
|
47
|
+
command:
|
|
48
|
+
argv full command line with secret arguments redacted
|
|
49
|
+
duration_ms wall time of the entire invocation (only on the closing record)
|
|
50
|
+
exit_code process exit code (only on the closing record)
|
|
51
|
+
phase "start" or "end"
|
|
52
|
+
|
|
53
|
+
http:
|
|
54
|
+
method HTTP method
|
|
55
|
+
path path (no host); query string preserved with secrets redacted
|
|
56
|
+
status response status code (omitted on transport failure)
|
|
57
|
+
duration_ms wall time of the call including any retries
|
|
58
|
+
attempts number of attempts made (1 = success first try)
|
|
59
|
+
bytes size of the response body in bytes
|
|
60
|
+
error transport-level error message (omitted on success)
|
|
61
|
+
|
|
62
|
+
error:
|
|
63
|
+
type short error class name
|
|
64
|
+
message redacted error message
|
|
65
|
+
status HTTP status if applicable
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import contextlib
|
|
71
|
+
import hashlib
|
|
72
|
+
import hmac
|
|
73
|
+
import json
|
|
74
|
+
import os
|
|
75
|
+
import secrets
|
|
76
|
+
import sys
|
|
77
|
+
import threading
|
|
78
|
+
import time
|
|
79
|
+
import uuid
|
|
80
|
+
from datetime import UTC, datetime
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
from typing import Any, TextIO
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Constants
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
_DEFAULT_PATH = Path.home() / ".config" / "qualys-cli" / "audit.log"
|
|
89
|
+
_DEFAULT_ROTATE_AT_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
90
|
+
_OFF_SENTINELS = {"off", "none", "false", "0", "disabled", "no"}
|
|
91
|
+
_HMAC_KEY_PATH = Path.home() / ".config" / "qualys-cli" / ".audit-hmac-key"
|
|
92
|
+
|
|
93
|
+
# Keys whose values must be redacted before the record is written. Match is
|
|
94
|
+
# case-insensitive on the bare key name.
|
|
95
|
+
_SECRET_KEYS = {
|
|
96
|
+
"password", "token", "secret", "secret_id", "api_token", "api_key",
|
|
97
|
+
"apikey", "client_secret", "private_key", "credential", "credentials",
|
|
98
|
+
"auth", "authorization", "x-api-key", "passwd", "access_token",
|
|
99
|
+
"refresh_token", "bearer", "session_token", "session_id", "cookie",
|
|
100
|
+
"x-auth-token", "x-access-token", "jwt", "api_secret", "secret_key",
|
|
101
|
+
"signing_key", "encryption_key", "ssh_key", "passphrase",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_REDACTED = "***REDACTED***"
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Public mutable state — set by the CLI on startup
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
CORRELATION_ID: str = ""
|
|
111
|
+
"""Per-invocation correlation ID. Same value is sent as `X-Correlation-ID`
|
|
112
|
+
header and recorded against every audit line for the run. Set by cli.main()."""
|
|
113
|
+
|
|
114
|
+
PROFILE_NAME: str = ""
|
|
115
|
+
USERNAME: str = ""
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Internal: lazy file handle
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
_lock = threading.Lock()
|
|
122
|
+
_handle: TextIO | None = None
|
|
123
|
+
_handle_path: Path | None = None
|
|
124
|
+
_handle_is_stderr = False
|
|
125
|
+
_disabled = False
|
|
126
|
+
_init_done = False
|
|
127
|
+
_prev_hmac: str = "" # tamper-evident chain — sha of last line's signature
|
|
128
|
+
_hmac_key: bytes | None = None
|
|
129
|
+
_fsync_each_line = False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve_target() -> tuple[Path | None, bool, bool]:
|
|
133
|
+
"""Return (path, is_stderr, disabled) based on QUALYS_AUDIT_LOG."""
|
|
134
|
+
raw = os.environ.get("QUALYS_AUDIT_LOG", "").strip()
|
|
135
|
+
if not raw or raw.lower() == "default":
|
|
136
|
+
return _DEFAULT_PATH, False, False
|
|
137
|
+
low = raw.lower()
|
|
138
|
+
if low in _OFF_SENTINELS:
|
|
139
|
+
return None, False, True
|
|
140
|
+
if raw == "-" or low == "stderr":
|
|
141
|
+
return None, True, False
|
|
142
|
+
return Path(raw).expanduser(), False, False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _rotate_at_bytes() -> int:
|
|
146
|
+
raw = os.environ.get("QUALYS_AUDIT_MAX_BYTES", "").strip()
|
|
147
|
+
if raw.isdigit():
|
|
148
|
+
n = int(raw)
|
|
149
|
+
if n > 0:
|
|
150
|
+
return n
|
|
151
|
+
return _DEFAULT_ROTATE_AT_BYTES
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _load_hmac_key() -> bytes | None:
|
|
155
|
+
"""Load (or create) the per-host HMAC key used for tamper-evident chaining.
|
|
156
|
+
|
|
157
|
+
The key lives at ``~/.config/qualys-cli/.audit-hmac-key`` (mode 0600).
|
|
158
|
+
Failures degrade gracefully: chaining becomes a no-op rather than crashing.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
if _HMAC_KEY_PATH.exists():
|
|
162
|
+
return _HMAC_KEY_PATH.read_bytes()
|
|
163
|
+
_HMAC_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
key = secrets.token_bytes(32)
|
|
165
|
+
_HMAC_KEY_PATH.write_bytes(key)
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
_HMAC_KEY_PATH.chmod(0o600)
|
|
168
|
+
return key
|
|
169
|
+
except Exception:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _current_handle() -> TextIO | None:
|
|
174
|
+
"""Return the already-initialised handle (or stderr) — no I/O, no init."""
|
|
175
|
+
if _disabled:
|
|
176
|
+
return None
|
|
177
|
+
if _handle_is_stderr:
|
|
178
|
+
return sys.stderr
|
|
179
|
+
return _handle
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _ensure_handle() -> TextIO | None:
|
|
183
|
+
"""Open the log file once per process. Returns the open handle, or None
|
|
184
|
+
if audit is disabled or the file cannot be opened.
|
|
185
|
+
|
|
186
|
+
Thread-safety: the entire init must run under `_lock` — two threads racing
|
|
187
|
+
here previously could both pass the `_init_done` check, both open `_handle`
|
|
188
|
+
in append mode (leaking the loser's fd), and both read `_prev_hmac` as the
|
|
189
|
+
empty seed, producing two records with the same `prev` and breaking the
|
|
190
|
+
HMAC chain that `verify_chain` walks.
|
|
191
|
+
"""
|
|
192
|
+
global _handle, _handle_path, _handle_is_stderr, _disabled, _init_done
|
|
193
|
+
global _hmac_key, _fsync_each_line
|
|
194
|
+
|
|
195
|
+
# Fast path: already initialised. Reads of plain assignments under CPython's
|
|
196
|
+
# GIL are atomic, so checking `_init_done` without the lock is safe; the
|
|
197
|
+
# lock only protects the one-shot init below.
|
|
198
|
+
if _init_done:
|
|
199
|
+
return _current_handle()
|
|
200
|
+
|
|
201
|
+
with _lock:
|
|
202
|
+
if _init_done:
|
|
203
|
+
return _current_handle()
|
|
204
|
+
|
|
205
|
+
_fsync_each_line = os.environ.get("QUALYS_AUDIT_FSYNC", "").strip().lower() in {
|
|
206
|
+
"1", "true", "yes", "on",
|
|
207
|
+
}
|
|
208
|
+
if os.environ.get("QUALYS_AUDIT_HMAC", "1").strip().lower() not in {
|
|
209
|
+
"0", "false", "no", "off", "disabled",
|
|
210
|
+
}:
|
|
211
|
+
_hmac_key = _load_hmac_key()
|
|
212
|
+
|
|
213
|
+
target, is_stderr, disabled = _resolve_target()
|
|
214
|
+
if disabled:
|
|
215
|
+
_disabled = True
|
|
216
|
+
elif is_stderr:
|
|
217
|
+
_handle_is_stderr = True
|
|
218
|
+
else:
|
|
219
|
+
assert target is not None
|
|
220
|
+
try:
|
|
221
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
# Append, line-buffered. Best-effort tighten perms; not fatal.
|
|
223
|
+
_handle = target.open("a", buffering=1, encoding="utf-8")
|
|
224
|
+
_handle_path = target
|
|
225
|
+
with contextlib.suppress(Exception):
|
|
226
|
+
target.chmod(0o600)
|
|
227
|
+
except Exception:
|
|
228
|
+
# If we can't write, silently disable rather than break the CLI.
|
|
229
|
+
_disabled = True
|
|
230
|
+
_handle = None
|
|
231
|
+
_handle_path = None
|
|
232
|
+
|
|
233
|
+
# Publish init flag last so any concurrent reader that observes
|
|
234
|
+
# `_init_done == True` also observes a consistent `_handle` /
|
|
235
|
+
# `_handle_is_stderr` / `_disabled` triple.
|
|
236
|
+
_init_done = True
|
|
237
|
+
return _current_handle()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _maybe_rotate() -> None:
|
|
241
|
+
"""Rotate the active file if it has grown past the threshold.
|
|
242
|
+
|
|
243
|
+
Only applies to file-backed handles. Single generation of history kept
|
|
244
|
+
(``audit.log`` → ``audit.log.1``).
|
|
245
|
+
"""
|
|
246
|
+
if _handle is None or _handle_path is None or _handle_is_stderr:
|
|
247
|
+
return
|
|
248
|
+
threshold = _rotate_at_bytes()
|
|
249
|
+
try:
|
|
250
|
+
size = _handle_path.stat().st_size
|
|
251
|
+
except OSError:
|
|
252
|
+
return
|
|
253
|
+
if size < threshold:
|
|
254
|
+
return
|
|
255
|
+
backup = _handle_path.with_name(_handle_path.name + ".1")
|
|
256
|
+
try:
|
|
257
|
+
if _handle is not None:
|
|
258
|
+
_handle.flush()
|
|
259
|
+
_handle.close()
|
|
260
|
+
if backup.exists():
|
|
261
|
+
backup.unlink()
|
|
262
|
+
_handle_path.rename(backup)
|
|
263
|
+
except OSError:
|
|
264
|
+
# If rotation fails we just leave the existing file growing — better
|
|
265
|
+
# than silently dropping audit lines.
|
|
266
|
+
pass
|
|
267
|
+
try:
|
|
268
|
+
new_handle = _handle_path.open("a", buffering=1, encoding="utf-8")
|
|
269
|
+
with contextlib.suppress(Exception):
|
|
270
|
+
_handle_path.chmod(0o600)
|
|
271
|
+
globals()["_handle"] = new_handle
|
|
272
|
+
except OSError:
|
|
273
|
+
# Couldn't reopen — disable audit for the remainder of the run.
|
|
274
|
+
globals()["_handle"] = None
|
|
275
|
+
globals()["_disabled"] = True
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Redaction helpers
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def _is_secret_key(name: str) -> bool:
|
|
283
|
+
return name.lower() in _SECRET_KEYS
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _redact_value(_v: Any) -> str:
|
|
287
|
+
return _REDACTED
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def fingerprint_token(token: str) -> str:
|
|
291
|
+
"""Return a stable short fingerprint of a JWT/API token without leaking it.
|
|
292
|
+
|
|
293
|
+
Useful for correlating which token was used across log lines without
|
|
294
|
+
exposing the raw value.
|
|
295
|
+
"""
|
|
296
|
+
if not token:
|
|
297
|
+
return ""
|
|
298
|
+
h = hashlib.sha256(token.encode("utf-8", "ignore")).hexdigest()
|
|
299
|
+
return f"jwt:{h[:12]}"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def redact_mapping(d: Any) -> Any:
|
|
303
|
+
"""Return a deep copy of *d* with secret values replaced.
|
|
304
|
+
|
|
305
|
+
Works for dicts (recursing into nested dicts/lists), lists, and scalars.
|
|
306
|
+
Non-mapping inputs are returned unchanged.
|
|
307
|
+
"""
|
|
308
|
+
if isinstance(d, dict):
|
|
309
|
+
out: dict[str, Any] = {}
|
|
310
|
+
for k, v in d.items():
|
|
311
|
+
if isinstance(k, str) and _is_secret_key(k):
|
|
312
|
+
out[k] = _REDACTED
|
|
313
|
+
else:
|
|
314
|
+
out[k] = redact_mapping(v)
|
|
315
|
+
return out
|
|
316
|
+
if isinstance(d, list):
|
|
317
|
+
return [redact_mapping(x) for x in d]
|
|
318
|
+
return d
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def redact_query_string(qs: str) -> str:
|
|
322
|
+
"""Redact secret values in a URL query string (key=value&...)."""
|
|
323
|
+
if not qs or "=" not in qs:
|
|
324
|
+
return qs
|
|
325
|
+
parts: list[str] = []
|
|
326
|
+
for chunk in qs.split("&"):
|
|
327
|
+
if "=" not in chunk:
|
|
328
|
+
parts.append(chunk)
|
|
329
|
+
continue
|
|
330
|
+
k, _, v = chunk.partition("=")
|
|
331
|
+
if _is_secret_key(k) and v:
|
|
332
|
+
parts.append(f"{k}={_REDACTED}")
|
|
333
|
+
else:
|
|
334
|
+
parts.append(chunk)
|
|
335
|
+
return "&".join(parts)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def redact_argv(argv: list[str]) -> list[str]:
|
|
339
|
+
"""Redact secret-bearing flag values in an argv list.
|
|
340
|
+
|
|
341
|
+
Recognises both ``--flag value`` and ``--flag=value`` forms. The flag
|
|
342
|
+
*name* itself is not redacted (so reviewers can still see which option
|
|
343
|
+
was used); only the value is replaced.
|
|
344
|
+
"""
|
|
345
|
+
out: list[str] = []
|
|
346
|
+
i = 0
|
|
347
|
+
while i < len(argv):
|
|
348
|
+
tok = argv[i]
|
|
349
|
+
if tok.startswith("--") and "=" in tok:
|
|
350
|
+
name, _, _val = tok.partition("=")
|
|
351
|
+
bare = name.lstrip("-").replace("-", "_")
|
|
352
|
+
if _is_secret_key(bare):
|
|
353
|
+
out.append(f"{name}={_REDACTED}")
|
|
354
|
+
else:
|
|
355
|
+
out.append(tok)
|
|
356
|
+
i += 1
|
|
357
|
+
continue
|
|
358
|
+
if tok.startswith("--"):
|
|
359
|
+
bare = tok.lstrip("-").replace("-", "_")
|
|
360
|
+
if _is_secret_key(bare) and i + 1 < len(argv):
|
|
361
|
+
out.append(tok)
|
|
362
|
+
out.append(_REDACTED)
|
|
363
|
+
i += 2
|
|
364
|
+
continue
|
|
365
|
+
out.append(tok)
|
|
366
|
+
i += 1
|
|
367
|
+
return out
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# Write helpers
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def _now_iso() -> str:
|
|
375
|
+
# Millisecond precision keeps lines short while staying ordered.
|
|
376
|
+
now = datetime.now(UTC)
|
|
377
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _emit(record: dict[str, Any]) -> None:
|
|
381
|
+
"""Serialise and write a single record. Swallows all I/O errors.
|
|
382
|
+
|
|
383
|
+
When HMAC chaining is enabled the record gains a ``sig`` field that depends
|
|
384
|
+
on the previous line's signature, so any tampering or truncation is
|
|
385
|
+
detectable by :func:`verify_chain`.
|
|
386
|
+
|
|
387
|
+
Concurrency note
|
|
388
|
+
----------------
|
|
389
|
+
The handle is resolved *inside* :data:`_lock` so a concurrent rotation
|
|
390
|
+
from another thread cannot leave us writing to a closed file. The HMAC
|
|
391
|
+
chain mutation of ``_prev_hmac`` also happens under the lock — without
|
|
392
|
+
this both writers would compute their signature against the same prior
|
|
393
|
+
state, breaking the chain on the second write.
|
|
394
|
+
"""
|
|
395
|
+
global _prev_hmac
|
|
396
|
+
|
|
397
|
+
# Cheap pre-flight outside the lock — avoids paying the lock overhead
|
|
398
|
+
# for every call when audit is disabled.
|
|
399
|
+
if _disabled:
|
|
400
|
+
return
|
|
401
|
+
if _ensure_handle() is None:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
with _lock:
|
|
406
|
+
# Re-resolve under the lock — if another thread rotated us, the
|
|
407
|
+
# module-level _handle now points at the new file; the previously
|
|
408
|
+
# cached `handle` may be closed.
|
|
409
|
+
handle = sys.stderr if _handle_is_stderr else _handle
|
|
410
|
+
if _disabled or handle is None:
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
if _hmac_key is not None:
|
|
414
|
+
try:
|
|
415
|
+
base = json.dumps(
|
|
416
|
+
record, default=str, separators=(",", ":"), sort_keys=True,
|
|
417
|
+
)
|
|
418
|
+
mac = hmac.new(
|
|
419
|
+
_hmac_key,
|
|
420
|
+
(_prev_hmac + base).encode("utf-8", "ignore"),
|
|
421
|
+
hashlib.sha256,
|
|
422
|
+
).hexdigest()[:32]
|
|
423
|
+
record = {**record, "sig": mac}
|
|
424
|
+
_prev_hmac = mac
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
line = json.dumps(record, default=str, separators=(",", ":"))
|
|
430
|
+
except (TypeError, ValueError):
|
|
431
|
+
line = json.dumps(
|
|
432
|
+
{"ts": record.get("ts"), "event": "audit_serialize_error"},
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
handle.write(line)
|
|
436
|
+
handle.write("\n")
|
|
437
|
+
if _fsync_each_line and not _handle_is_stderr:
|
|
438
|
+
with contextlib.suppress(Exception):
|
|
439
|
+
handle.flush()
|
|
440
|
+
if hasattr(handle, "fileno"):
|
|
441
|
+
os.fsync(handle.fileno())
|
|
442
|
+
_maybe_rotate()
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def verify_chain(path: str | Path) -> tuple[int, int, list[int]]:
|
|
448
|
+
"""Verify the HMAC chain of an audit log. Returns ``(ok, total, broken)``.
|
|
449
|
+
|
|
450
|
+
*ok* is the count of records whose signature matches the chain; *total* is
|
|
451
|
+
the total number of records read; *broken* is a list of 0-indexed record
|
|
452
|
+
positions where the chain breaks. A chain is intact iff ``broken == []``.
|
|
453
|
+
|
|
454
|
+
Records without a ``sig`` field are skipped (they predate HMAC chaining
|
|
455
|
+
or were written with the chain disabled).
|
|
456
|
+
"""
|
|
457
|
+
p = Path(path)
|
|
458
|
+
if not p.exists():
|
|
459
|
+
return (0, 0, [])
|
|
460
|
+
key = _load_hmac_key()
|
|
461
|
+
if key is None:
|
|
462
|
+
return (0, 0, [])
|
|
463
|
+
ok = 0
|
|
464
|
+
total = 0
|
|
465
|
+
broken: list[int] = []
|
|
466
|
+
prev = ""
|
|
467
|
+
for i, raw in enumerate(p.read_text().splitlines()):
|
|
468
|
+
if not raw.strip():
|
|
469
|
+
continue
|
|
470
|
+
total += 1
|
|
471
|
+
try:
|
|
472
|
+
rec = json.loads(raw)
|
|
473
|
+
except json.JSONDecodeError:
|
|
474
|
+
broken.append(i)
|
|
475
|
+
continue
|
|
476
|
+
sig = rec.pop("sig", None)
|
|
477
|
+
if sig is None:
|
|
478
|
+
# Pre-chain line — accept silently
|
|
479
|
+
continue
|
|
480
|
+
base = json.dumps(rec, default=str, separators=(",", ":"), sort_keys=True)
|
|
481
|
+
expected = hmac.new(
|
|
482
|
+
key, (prev + base).encode("utf-8", "ignore"), hashlib.sha256,
|
|
483
|
+
).hexdigest()[:32]
|
|
484
|
+
if hmac.compare_digest(expected, sig):
|
|
485
|
+
ok += 1
|
|
486
|
+
prev = sig
|
|
487
|
+
else:
|
|
488
|
+
broken.append(i)
|
|
489
|
+
prev = sig # advance anyway to detect a single edit, not a cascade
|
|
490
|
+
return (ok, total, broken)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _base_record(event: str) -> dict[str, Any]:
|
|
494
|
+
rec: dict[str, Any] = {
|
|
495
|
+
"ts": _now_iso(),
|
|
496
|
+
"event": event,
|
|
497
|
+
"pid": os.getpid(),
|
|
498
|
+
}
|
|
499
|
+
if CORRELATION_ID:
|
|
500
|
+
rec["correlation"] = CORRELATION_ID
|
|
501
|
+
if PROFILE_NAME:
|
|
502
|
+
rec["profile"] = PROFILE_NAME
|
|
503
|
+
if USERNAME:
|
|
504
|
+
rec["user"] = USERNAME
|
|
505
|
+
return rec
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
# Public API
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def new_correlation_id() -> str:
|
|
513
|
+
"""Generate a fresh correlation ID (hex, 16 chars). Cheap and unique enough
|
|
514
|
+
for human-readable cross-referencing."""
|
|
515
|
+
return uuid.uuid4().hex[:16]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def init(profile: str = "", username: str = "") -> str:
|
|
519
|
+
"""Initialise the audit subsystem for the current invocation.
|
|
520
|
+
|
|
521
|
+
Sets the module-level correlation ID, profile name, and username, and
|
|
522
|
+
returns the correlation ID. Safe to call multiple times — only the
|
|
523
|
+
first call sets the correlation ID.
|
|
524
|
+
"""
|
|
525
|
+
global CORRELATION_ID, PROFILE_NAME, USERNAME
|
|
526
|
+
if not CORRELATION_ID:
|
|
527
|
+
CORRELATION_ID = new_correlation_id()
|
|
528
|
+
if profile:
|
|
529
|
+
PROFILE_NAME = profile
|
|
530
|
+
if username:
|
|
531
|
+
USERNAME = username
|
|
532
|
+
return CORRELATION_ID
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def reset_for_tests() -> None:
|
|
536
|
+
"""Tear down audit state. Tests use this to ensure a clean per-test slate."""
|
|
537
|
+
global CORRELATION_ID, PROFILE_NAME, USERNAME, _handle, _handle_path
|
|
538
|
+
global _handle_is_stderr, _disabled, _init_done, _prev_hmac, _hmac_key
|
|
539
|
+
global _fsync_each_line
|
|
540
|
+
with _lock:
|
|
541
|
+
if _handle is not None and not _handle_is_stderr:
|
|
542
|
+
with contextlib.suppress(Exception):
|
|
543
|
+
_handle.close()
|
|
544
|
+
CORRELATION_ID = ""
|
|
545
|
+
PROFILE_NAME = ""
|
|
546
|
+
USERNAME = ""
|
|
547
|
+
_handle = None
|
|
548
|
+
_handle_path = None
|
|
549
|
+
_handle_is_stderr = False
|
|
550
|
+
_disabled = False
|
|
551
|
+
_init_done = False
|
|
552
|
+
_prev_hmac = ""
|
|
553
|
+
_hmac_key = None
|
|
554
|
+
_fsync_each_line = False
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def log_command_start(argv: list[str], version: str) -> float:
|
|
558
|
+
"""Record the start of a CLI invocation. Returns the start time so the
|
|
559
|
+
caller can pass it back to :func:`log_command_end` for accurate duration.
|
|
560
|
+
"""
|
|
561
|
+
rec = _base_record("command")
|
|
562
|
+
rec["phase"] = "start"
|
|
563
|
+
rec["argv"] = redact_argv(argv)
|
|
564
|
+
rec["version"] = version
|
|
565
|
+
_emit(rec)
|
|
566
|
+
return time.monotonic()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def log_command_end(start: float, exit_code: int, version: str) -> None:
|
|
570
|
+
rec = _base_record("command")
|
|
571
|
+
rec["phase"] = "end"
|
|
572
|
+
rec["duration_ms"] = int((time.monotonic() - start) * 1000)
|
|
573
|
+
rec["exit_code"] = int(exit_code)
|
|
574
|
+
rec["version"] = version
|
|
575
|
+
_emit(rec)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def log_http(
|
|
579
|
+
method: str,
|
|
580
|
+
path: str,
|
|
581
|
+
status: int | None,
|
|
582
|
+
duration_ms: int,
|
|
583
|
+
attempts: int,
|
|
584
|
+
response_bytes: int,
|
|
585
|
+
error: str | None = None,
|
|
586
|
+
timing: dict[str, int] | None = None,
|
|
587
|
+
) -> None:
|
|
588
|
+
"""Record one HTTP call. ``path`` should already have its query string
|
|
589
|
+
redacted (use :func:`redact_query_string`).
|
|
590
|
+
|
|
591
|
+
``timing`` (optional) is a dict of fine-grained phase durations
|
|
592
|
+
(e.g. ``{"connect": 12, "tls": 87, "ttfb": 220}``) merged into the record
|
|
593
|
+
as a nested object so SIEM tooling can analyse latency components.
|
|
594
|
+
"""
|
|
595
|
+
rec = _base_record("http")
|
|
596
|
+
rec["method"] = method
|
|
597
|
+
rec["path"] = path
|
|
598
|
+
if status is not None:
|
|
599
|
+
rec["status"] = int(status)
|
|
600
|
+
rec["duration_ms"] = int(duration_ms)
|
|
601
|
+
rec["attempts"] = int(attempts)
|
|
602
|
+
rec["bytes"] = int(response_bytes)
|
|
603
|
+
if error:
|
|
604
|
+
rec["error"] = error[:500]
|
|
605
|
+
if timing:
|
|
606
|
+
rec["timing"] = timing
|
|
607
|
+
_emit(rec)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def log_error(error_type: str, message: str, status: int | None = None) -> None:
|
|
611
|
+
rec = _base_record("error")
|
|
612
|
+
rec["type"] = error_type
|
|
613
|
+
rec["message"] = message[:500]
|
|
614
|
+
if status is not None:
|
|
615
|
+
rec["status"] = int(status)
|
|
616
|
+
_emit(rec)
|