coderouter-cli 2.13.0__py3-none-any.whl → 2.14.0__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.
@@ -0,0 +1,606 @@
1
+ """Secret registry + logging redaction (v2.14.0).
2
+
3
+ Before this module there was no notion of "this string is a secret"
4
+ anywhere in the codebase. API keys were read from the environment by
5
+ :func:`coderouter.config.loader.resolve_api_key`, dropped straight into
6
+ ``headers["x-api-key"]`` / ``headers["Authorization"]``, and that was the
7
+ end of it. Nothing masked them, because nothing knew which strings needed
8
+ masking.
9
+
10
+ That is not the same as "keys leak today". We found no call site that
11
+ logs a header dict. The problem is the shape of the risk: the safety of
12
+ every future log line depended on whoever wrote it remembering that the
13
+ value in their hand was a credential. One ``logger.debug("upstream
14
+ rejected: %s", exc)`` where the provider echoed the key back in its error
15
+ body, and it lands in ``requests.jsonl`` forever.
16
+
17
+ So the fix is not "audit the current call sites" — it is to make the
18
+ process know its own secrets and scrub them on the way out, once, for
19
+ every logger.
20
+
21
+ Design
22
+ ------
23
+ **Registry over regex.** The only strings we can redact with zero false
24
+ positives are the ones we know are secrets, so the primary mechanism is
25
+ an exact-match registry: :func:`register_secret` is called the moment a
26
+ credential enters the process (see ``resolve_api_key``), and
27
+ :func:`redact` replaces every occurrence of a registered value with
28
+ ``[redacted:<LABEL>]``. Pattern matching is only a backstop for
29
+ credentials that never passed through our resolver (a key pasted into a
30
+ ``base_url``, an upstream error body quoting a token) — the pattern set
31
+ is deliberately small and anchored so it cannot eat ordinary prose.
32
+
33
+ **Filter, not formatter.** Redaction is a :class:`logging.Filter` so it
34
+ applies to every handler we install — the stderr JSON-line handler, the
35
+ audit JSONL handler, the request-log JSONL handler — rather than only to
36
+ the one formatter. The filter mutates the record in place, so a handler
37
+ that runs later in the chain cannot re-expose what an earlier one
38
+ scrubbed.
39
+
40
+ **Length floor.** Values shorter than :data:`_MIN_SECRET_LEN` are refused
41
+ by the registry. A 3-character "key" (a placeholder, an empty-ish env
42
+ var, a literal ``"x"`` in a test fixture) would otherwise match inside
43
+ unrelated words and turn every log line into confetti. Refusing to
44
+ register is safer than redacting everything.
45
+
46
+ **Process-global.** The registry is module-level state, like the metrics
47
+ collector. Secrets belong to the process, not to a config object, and the
48
+ filter has to reach them from inside ``logging`` where no config is in
49
+ scope.
50
+
51
+ Non-goals
52
+ ---------
53
+ This does not encrypt anything at rest, does not touch already-written
54
+ log files (see :func:`scan_logs_for_secrets` for detection, not repair),
55
+ and is not a defence against someone who can read the process
56
+ environment. It closes exactly one hole: credentials reaching log sinks.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import logging
62
+ import os
63
+ import re
64
+ from collections.abc import Iterable, Iterator, Mapping
65
+ from dataclasses import dataclass, field
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ __all__ = [
70
+ "SecretCheck",
71
+ "SecretRedactingFilter",
72
+ "SecretReport",
73
+ "check_secret_hygiene",
74
+ "clear_secrets",
75
+ "exit_code_for_secret_report",
76
+ "format_secret_report",
77
+ "install_secret_filter",
78
+ "redact",
79
+ "register_config_secrets",
80
+ "register_secret",
81
+ "registered_labels",
82
+ "scan_logs_for_secrets",
83
+ "self_test",
84
+ ]
85
+
86
+
87
+ # A credential shorter than this is not registerable. Real API keys are far
88
+ # longer (the shortest common shape, a bare 32-hex token, is 32); anything
89
+ # under 8 characters is a placeholder, an accident, or a test stub, and
90
+ # redacting it would corrupt unrelated log text.
91
+ _MIN_SECRET_LEN = 8
92
+
93
+ # Registered secrets: exact value -> label used in the replacement text.
94
+ # The label is the env var name (``OPENAI_API_KEY``) so an operator reading
95
+ # a scrubbed log can still tell *which* credential was involved.
96
+ _secrets: dict[str, str] = {}
97
+
98
+ # Backstop patterns for credentials that never went through our resolver.
99
+ # Each entry is (compiled pattern, replacement). Every pattern is anchored
100
+ # on a distinctive prefix or on an explicit key=value / header syntax — we
101
+ # do not attempt to detect "high entropy strings", which is the classic way
102
+ # a scrubber starts eating commit hashes and base64 payloads.
103
+ _PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
104
+ # OpenAI / Anthropic style: sk-…, sk-ant-…, sk-proj-…
105
+ (re.compile(r"\bsk-(?:[A-Za-z]+-)?[A-Za-z0-9_\-]{16,}"), "[redacted:sk-key]"),
106
+ # GitHub personal access tokens and friends.
107
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}"), "[redacted:gh-token]"),
108
+ # Google API keys.
109
+ (re.compile(r"\bAIza[A-Za-z0-9_\-]{20,}"), "[redacted:google-key]"),
110
+ # A bearer token in a header dump or an error string.
111
+ (
112
+ re.compile(r"(?i)\b(bearer)\s+[A-Za-z0-9._\-]{16,}"),
113
+ r"\1 [redacted:bearer]",
114
+ ),
115
+ # ``?api_key=…`` / ``&access_token=…`` embedded in a URL.
116
+ (
117
+ re.compile(
118
+ r"(?i)([?&](?:api[-_]?key|access[-_]?token|auth[-_]?token|key)=)[^&\s\"'<>]+"
119
+ ),
120
+ r"\1[redacted:url-param]",
121
+ ),
122
+ # ``https://user:password@host`` userinfo.
123
+ (
124
+ re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://[^/\s:@]+):[^/\s@]+@"),
125
+ r"\1:[redacted:userinfo]@",
126
+ ),
127
+ )
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Registry
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def register_secret(value: str | None, label: str = "secret") -> bool:
136
+ """Register ``value`` as a secret to scrub from all log output.
137
+
138
+ Returns ``True`` when the value was accepted. Values that are empty,
139
+ non-string, or shorter than :data:`_MIN_SECRET_LEN` are rejected and
140
+ return ``False`` — see the module docstring for why a length floor
141
+ matters more than completeness here.
142
+
143
+ Idempotent: re-registering the same value keeps the first label, so a
144
+ key resolved once as ``OPENAI_API_KEY`` does not get relabelled to a
145
+ generic ``secret`` by a later anonymous call.
146
+ """
147
+ if not isinstance(value, str):
148
+ return False
149
+ stripped = value.strip()
150
+ if len(stripped) < _MIN_SECRET_LEN:
151
+ return False
152
+ _secrets.setdefault(stripped, label)
153
+ return True
154
+
155
+
156
+ def register_config_secrets(config: Any) -> list[str]:
157
+ """Register every credential reachable from a loaded config object.
158
+
159
+ Walks ``config.providers`` and resolves each ``api_key_env`` from the
160
+ environment. Called from the loader so redaction is armed before the
161
+ first request — waiting for the first :func:`resolve_api_key` call
162
+ would leave startup log lines unprotected.
163
+
164
+ Returns the list of labels registered, for the caller to log as a
165
+ count. **The values themselves are never returned or logged.**
166
+ """
167
+ labels: list[str] = []
168
+ providers = getattr(config, "providers", None) or ()
169
+ for provider in providers:
170
+ env_name = getattr(provider, "api_key_env", None)
171
+ if not env_name:
172
+ continue
173
+ value = os.environ.get(env_name, "").strip()
174
+ if register_secret(value, env_name):
175
+ labels.append(env_name)
176
+ return labels
177
+
178
+
179
+ def registered_labels() -> tuple[str, ...]:
180
+ """Labels of every registered secret, in registration order.
181
+
182
+ Safe to log — labels are env var names, not values.
183
+ """
184
+ return tuple(_secrets.values())
185
+
186
+
187
+ def clear_secrets() -> None:
188
+ """Drop every registered secret. Exists for test isolation."""
189
+ _secrets.clear()
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Redaction
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def redact(text: str) -> str:
198
+ """Return ``text`` with every known or pattern-matched secret masked.
199
+
200
+ Registered values are replaced first (exact match, no regex cost for
201
+ the common case), then the backstop patterns run over the result. A
202
+ string containing no secrets is returned unchanged — and, because the
203
+ registry is usually tiny, the fast path is a handful of ``in`` checks.
204
+ """
205
+ if not text:
206
+ return text
207
+ for value, label in _secrets.items():
208
+ if value in text:
209
+ text = text.replace(value, f"[redacted:{label}]")
210
+ for pattern, replacement in _PATTERNS:
211
+ text = pattern.sub(replacement, text)
212
+ return text
213
+
214
+
215
+ def _redact_value(value: Any) -> Any:
216
+ """Redact strings inside a log payload, recursing through containers.
217
+
218
+ ``extra={...}`` payloads in this codebase are mostly flat dicts of
219
+ scalars, but a few carry a list of provider names or a nested detail
220
+ dict, so the walk handles both. Non-string leaves pass through
221
+ untouched — we never stringify a value just to scan it, because that
222
+ would change what the formatter emits.
223
+ """
224
+ if isinstance(value, str):
225
+ return redact(value)
226
+ if isinstance(value, Mapping):
227
+ return {k: _redact_value(v) for k, v in value.items()}
228
+ if isinstance(value, (list, tuple, set)):
229
+ rebuilt = [_redact_value(v) for v in value]
230
+ if isinstance(value, tuple):
231
+ return tuple(rebuilt)
232
+ if isinstance(value, set):
233
+ return set(rebuilt)
234
+ return rebuilt
235
+ return value
236
+
237
+
238
+ # Standard LogRecord attributes the filter must not touch. Rewriting
239
+ # ``pathname`` or ``module`` would be pointless work and could corrupt the
240
+ # formatter's whitelist logic in coderouter.logging.JsonLineFormatter.
241
+ _RECORD_SKIP = frozenset(
242
+ {
243
+ "args",
244
+ "created",
245
+ "exc_info",
246
+ "filename",
247
+ "funcName",
248
+ "levelname",
249
+ "levelno",
250
+ "lineno",
251
+ "module",
252
+ "msecs",
253
+ "name",
254
+ "pathname",
255
+ "process",
256
+ "processName",
257
+ "relativeCreated",
258
+ "stack_info",
259
+ "thread",
260
+ "threadName",
261
+ "taskName",
262
+ }
263
+ )
264
+
265
+
266
+ class SecretRedactingFilter(logging.Filter):
267
+ """Scrub registered secrets from a record before any handler sees it.
268
+
269
+ Mutates the record in place and always returns ``True`` — this is a
270
+ filter in the plumbing sense, not a gate: no record is ever dropped.
271
+ Mutation is deliberate. Attaching this to the first handler would be
272
+ enough to protect the rest only by accident of ordering, so we attach
273
+ it to every handler we install and let the in-place edit make the
274
+ duplicate work a no-op.
275
+ """
276
+
277
+ def filter(self, record: logging.LogRecord) -> bool:
278
+ """Redact ``msg``, ``args``, ``exc_text`` and every ``extra`` field."""
279
+ if isinstance(record.msg, str):
280
+ record.msg = redact(record.msg)
281
+ if record.args:
282
+ record.args = _redact_value(record.args)
283
+ exc_text = getattr(record, "exc_text", None)
284
+ if isinstance(exc_text, str):
285
+ record.exc_text = redact(exc_text)
286
+ for key, value in list(record.__dict__.items()):
287
+ if key in _RECORD_SKIP or key.startswith("_"):
288
+ continue
289
+ if key in {"msg", "exc_text"}:
290
+ continue
291
+ redacted = _redact_value(value)
292
+ if redacted is not value:
293
+ record.__dict__[key] = redacted
294
+ return True
295
+
296
+
297
+ _FILTER_MARKER = "_coderouter_secret_filter"
298
+
299
+
300
+ def install_secret_filter(handler: logging.Handler) -> None:
301
+ """Attach a :class:`SecretRedactingFilter` to ``handler`` once.
302
+
303
+ Idempotent — a marker attribute keeps a re-configured handler from
304
+ stacking filters, which would double the scan cost per record for no
305
+ benefit (redaction is already fixed-point).
306
+ """
307
+ if getattr(handler, _FILTER_MARKER, False):
308
+ return
309
+ handler.addFilter(SecretRedactingFilter())
310
+ setattr(handler, _FILTER_MARKER, True)
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Diagnostics — surfaced by ``coderouter doctor --check-secrets``
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def self_test() -> bool:
319
+ """Prove the filter actually scrubs, by running a record through it.
320
+
321
+ Registers a throwaway value, pushes it through a real
322
+ :class:`SecretRedactingFilter`, and checks it does not survive. This
323
+ is what makes the doctor check evidence rather than an assertion that
324
+ the code exists — a filter that was silently detached by a third-party
325
+ logging config fails here.
326
+ """
327
+ canary = "coderouter-selftest-canary-0123456789"
328
+ had = canary in _secrets
329
+ register_secret(canary, "SELFTEST")
330
+ try:
331
+ record = logging.LogRecord(
332
+ name="coderouter.selftest",
333
+ level=logging.INFO,
334
+ pathname=__file__,
335
+ lineno=0,
336
+ msg=f"probe {canary}",
337
+ args=(),
338
+ exc_info=None,
339
+ )
340
+ # ``extra={...}`` lands as plain attributes on the record, so the
341
+ # canary has to be planted the same way to prove nested payloads
342
+ # are covered and not just the message.
343
+ record.__dict__["detail"] = {"nested": canary}
344
+ SecretRedactingFilter().filter(record)
345
+ clean_msg = canary not in str(record.msg)
346
+ clean_extra = canary not in str(record.__dict__["detail"])
347
+ return clean_msg and clean_extra
348
+ finally:
349
+ if not had:
350
+ _secrets.pop(canary, None)
351
+
352
+
353
+ def _iter_log_files(state_dir: str | os.PathLike[str] | None) -> Iterator[Path]:
354
+ """Yield the JSONL sinks CodeRouter writes under ``state_dir``.
355
+
356
+ Rotation keeps a single ``.1`` backup next to each live file (see
357
+ :mod:`coderouter.state.audit_log`), so both are scanned — a secret
358
+ that was written before the fix is just as exposed in the backup.
359
+ """
360
+ if not state_dir:
361
+ return
362
+ base = Path(state_dir).expanduser()
363
+ for name in ("requests.jsonl", "audit.jsonl"):
364
+ for candidate in (base / name, base / f"{name}.1"):
365
+ if candidate.is_file():
366
+ yield candidate
367
+
368
+
369
+ def scan_logs_for_secrets(
370
+ state_dir: str | os.PathLike[str] | None,
371
+ *,
372
+ extra_paths: Iterable[str | os.PathLike[str]] = (),
373
+ max_bytes: int = 64 * 1024 * 1024,
374
+ ) -> list[tuple[Path, str, int]]:
375
+ """Search already-written log files for registered secret values.
376
+
377
+ Returns ``(path, label, line_number)`` for each hit. This is the check
378
+ that turns "we added masking" into "and here is proof the old logs are
379
+ clean" — or, if they are not, into a rotation list.
380
+
381
+ Reads at most ``max_bytes`` per file and never rewrites anything. Files
382
+ are read with ``errors="replace"`` because a truncated rotation can
383
+ leave a partial UTF-8 sequence, and a decode error must not abort the
384
+ scan of the remaining files.
385
+ """
386
+ if not _secrets:
387
+ return []
388
+ hits: list[tuple[Path, str, int]] = []
389
+ paths = list(_iter_log_files(state_dir))
390
+ paths.extend(Path(p).expanduser() for p in extra_paths)
391
+ for path in paths:
392
+ try:
393
+ if path.stat().st_size > max_bytes:
394
+ continue
395
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
396
+ for lineno, line in enumerate(fh, start=1):
397
+ for value, label in _secrets.items():
398
+ if value in line:
399
+ hits.append((path, label, lineno))
400
+ except OSError:
401
+ # An unreadable log is not a leak finding — the caller reports
402
+ # what it could scan, and a permissions problem surfaces via
403
+ # the separate .env / state-dir checks.
404
+ continue
405
+ return hits
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # ``coderouter doctor --check-secrets`` — the report
410
+ # ---------------------------------------------------------------------------
411
+ #
412
+ # Deliberately mirrors the ``EnvSecurityCheck`` / ``EnvSecurityReport`` shape
413
+ # from :mod:`coderouter.env_security` (same verdict names, same exit-code
414
+ # contract) so ``_run_doctor`` can union the two reports without
415
+ # special-casing either. We do not import those types: env_security is a
416
+ # filesystem module and this is a logging module, and coupling them would
417
+ # make one un-importable without the other for no gain.
418
+
419
+
420
+ @dataclass(frozen=True)
421
+ class SecretCheck:
422
+ """Outcome of one check in the secret-hygiene suite.
423
+
424
+ ``fix`` is a one-line remediation the operator can act on, or ``None``
425
+ when the verdict needs no action.
426
+ """
427
+
428
+ name: str
429
+ verdict: str # "ok" | "skip" | "warn" | "error"
430
+ detail: str
431
+ fix: str | None = None
432
+
433
+
434
+ @dataclass
435
+ class SecretReport:
436
+ """Aggregate report for a single ``--check-secrets`` invocation."""
437
+
438
+ checks: list[SecretCheck] = field(default_factory=list)
439
+
440
+
441
+ def exit_code_for_secret_report(report: SecretReport) -> int:
442
+ """0 clean / 2 needs attention / 1 blocker — matches ``--check-env``."""
443
+ verdicts = {c.verdict for c in report.checks}
444
+ if "error" in verdicts:
445
+ return 1
446
+ if "warn" in verdicts:
447
+ return 2
448
+ return 0
449
+
450
+
451
+ def _check_embedded_credentials(config: Any) -> SecretCheck:
452
+ """Look for credentials pasted into ``base_url`` instead of an env var.
453
+
454
+ A key in the URL defeats the whole registry: it is never resolved by
455
+ ``resolve_api_key``, so it is never registered, so the scrubber cannot
456
+ know about it — and the URL is exactly the field most likely to end up
457
+ in an error message. The backstop patterns in :func:`redact` catch the
458
+ common shapes at log time, but the real fix is to move the credential
459
+ into ``api_key_env``, which is what this check tells the operator.
460
+ """
461
+ offenders: list[str] = []
462
+ for provider in getattr(config, "providers", None) or ():
463
+ url = str(getattr(provider, "base_url", "") or "")
464
+ if url != redact(url):
465
+ offenders.append(str(getattr(provider, "name", "?")))
466
+ if offenders:
467
+ return SecretCheck(
468
+ name="config-embedded-credentials",
469
+ verdict="error",
470
+ detail=(
471
+ "base_url appears to carry a credential for: "
472
+ + ", ".join(sorted(offenders))
473
+ ),
474
+ fix="Move the credential into an env var and set api_key_env on the provider.",
475
+ )
476
+ return SecretCheck(
477
+ name="config-embedded-credentials",
478
+ verdict="ok",
479
+ detail="No credential-looking material found in any provider base_url.",
480
+ )
481
+
482
+
483
+ def _check_registry(config: Any) -> SecretCheck:
484
+ """Report how many declared credentials are actually armed.
485
+
486
+ A provider that declares ``api_key_env`` whose variable is unset is not
487
+ a leak, but it is worth surfacing: it usually means the operator
488
+ expected a key to be in play, and it also means the scrubber has
489
+ nothing to match if that provider starts echoing a key later.
490
+ """
491
+ declared: list[str] = []
492
+ unset: list[str] = []
493
+ for provider in getattr(config, "providers", None) or ():
494
+ env_name = getattr(provider, "api_key_env", None)
495
+ if not env_name:
496
+ continue
497
+ declared.append(env_name)
498
+ if not os.environ.get(env_name, "").strip():
499
+ unset.append(env_name)
500
+ if not declared:
501
+ return SecretCheck(
502
+ name="registered-secrets",
503
+ verdict="skip",
504
+ detail="No provider declares api_key_env — nothing to register.",
505
+ )
506
+ armed = len(set(declared)) - len(set(unset))
507
+ if unset:
508
+ return SecretCheck(
509
+ name="registered-secrets",
510
+ verdict="warn",
511
+ detail=(
512
+ f"{armed}/{len(set(declared))} declared credentials are set; "
513
+ f"unset: {', '.join(sorted(set(unset)))}"
514
+ ),
515
+ fix="Export the missing variables, or drop api_key_env for keyless providers.",
516
+ )
517
+ return SecretCheck(
518
+ name="registered-secrets",
519
+ verdict="ok",
520
+ detail=f"{armed} credential(s) registered for redaction: "
521
+ + ", ".join(sorted(set(declared))),
522
+ )
523
+
524
+
525
+ def _check_filter() -> SecretCheck:
526
+ """Run the scrubber end-to-end rather than asserting it is installed."""
527
+ if self_test():
528
+ return SecretCheck(
529
+ name="redaction-filter",
530
+ verdict="ok",
531
+ detail="A canary secret was scrubbed from both the message and the extra payload.",
532
+ )
533
+ return SecretCheck(
534
+ name="redaction-filter",
535
+ verdict="error",
536
+ detail="SecretRedactingFilter did not scrub a canary value.",
537
+ fix="Check whether a third-party logging config replaced CodeRouter's handlers.",
538
+ )
539
+
540
+
541
+ def _check_written_logs(config: Any) -> SecretCheck:
542
+ """Scan the JSONL sinks already on disk for live credential values.
543
+
544
+ This is the check that produces evidence instead of reassurance. If a
545
+ key leaked before redaction existed — or through a sink we do not own —
546
+ it is sitting in ``requests.jsonl`` right now, and the only safe
547
+ remediation is rotation, not deletion.
548
+ """
549
+ state_dir = getattr(config, "state_dir", None)
550
+ if not state_dir:
551
+ return SecretCheck(
552
+ name="written-log-scan",
553
+ verdict="skip",
554
+ detail="state_dir is unset — no JSONL sinks to scan.",
555
+ )
556
+ if not _secrets:
557
+ return SecretCheck(
558
+ name="written-log-scan",
559
+ verdict="skip",
560
+ detail="No credentials registered — nothing to search for.",
561
+ )
562
+ hits = scan_logs_for_secrets(state_dir)
563
+ if not hits:
564
+ return SecretCheck(
565
+ name="written-log-scan",
566
+ verdict="ok",
567
+ detail=f"No registered credential found in the JSONL sinks under {state_dir}.",
568
+ )
569
+ where = ", ".join(f"{p.name}:{line} ({label})" for p, label, line in hits[:5])
570
+ more = "" if len(hits) <= 5 else f" (+{len(hits) - 5} more)"
571
+ return SecretCheck(
572
+ name="written-log-scan",
573
+ verdict="error",
574
+ detail=f"Credential values found in written logs: {where}{more}",
575
+ fix="Rotate the affected credentials, then delete the offending log files.",
576
+ )
577
+
578
+
579
+ def check_secret_hygiene(config: Any) -> SecretReport:
580
+ """Run the whole secret-hygiene suite against a loaded config."""
581
+ register_config_secrets(config)
582
+ return SecretReport(
583
+ checks=[
584
+ _check_filter(),
585
+ _check_registry(config),
586
+ _check_embedded_credentials(config),
587
+ _check_written_logs(config),
588
+ ]
589
+ )
590
+
591
+
592
+ _VERDICT_GLYPH = {"ok": "OK ", "skip": "SKIP", "warn": "WARN", "error": "FAIL"}
593
+
594
+
595
+ def format_secret_report(report: SecretReport) -> str:
596
+ """Render a report for the terminal, in ``--check-env``'s layout."""
597
+ lines = ["", "coderouter doctor --check-secrets", "=" * 52]
598
+ for check in report.checks:
599
+ lines.append(f"[{_VERDICT_GLYPH.get(check.verdict, '????')}] {check.name}")
600
+ lines.append(f" {check.detail}")
601
+ if check.fix:
602
+ lines.append(f" fix: {check.fix}")
603
+ code = exit_code_for_secret_report(report)
604
+ verdict = {0: "clean", 2: "needs attention", 1: "blocker"}[code]
605
+ lines.extend(["-" * 52, f"verdict: {verdict} (exit {code})", ""])
606
+ return "\n".join(lines)
@@ -49,6 +49,8 @@ import time
49
49
  from datetime import UTC, datetime
50
50
  from pathlib import Path
51
51
 
52
+ from coderouter.secret_redaction import install_secret_filter
53
+
52
54
  # M12(2) buffering defaults. Audit lines are lower-volume than request
53
55
  # journal lines, but still fsync'd once per event pre-M12. We buffer and
54
56
  # flush on whichever comes first — a count threshold or a wall-clock
@@ -121,6 +123,10 @@ class AuditLogHandler(logging.Handler):
121
123
  flush_interval_s: float = _DEFAULT_FLUSH_INTERVAL_S,
122
124
  ) -> None:
123
125
  super().__init__(level=logging.DEBUG)
126
+ # v2.14.0: scrub registered credentials on the way to disk. This
127
+ # sink is the one that persists, so an unscrubbed key here outlives
128
+ # the process that leaked it.
129
+ install_secret_filter(self)
124
130
  self._log_path = Path(log_path)
125
131
  self._max_bytes = max_bytes
126
132
  self._flush_every_n = max(1, flush_every_n)
@@ -38,6 +38,8 @@ import time
38
38
  from datetime import UTC, datetime
39
39
  from pathlib import Path
40
40
 
41
+ from coderouter.secret_redaction import install_secret_filter
42
+
41
43
  # Events to capture for the request journal.
42
44
  _JOURNAL_EVENTS: frozenset[str] = frozenset({"cache-observed"})
43
45
 
@@ -76,6 +78,10 @@ class RequestLogHandler(logging.Handler):
76
78
  flush_interval_s: float = _DEFAULT_FLUSH_INTERVAL_S,
77
79
  ) -> None:
78
80
  super().__init__(level=logging.DEBUG)
81
+ # v2.14.0: scrub registered credentials on the way to disk. This
82
+ # sink is the one that persists, so an unscrubbed key here outlives
83
+ # the process that leaked it.
84
+ install_secret_filter(self)
79
85
  self._log_path = Path(log_path)
80
86
  self._max_bytes = max_bytes
81
87
  self._flush_every_n = max(1, flush_every_n)