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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
qualys_cli/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m qualys_cli`` and PyInstaller.
2
+
3
+ This module exists so PyInstaller (and ``python -m``) have a stable target.
4
+ """
5
+ from qualys_cli.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
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)