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.
- coderouter/adapters/anthropic_native.py +4 -2
- coderouter/adapters/openai_compat.py +5 -2
- coderouter/cli.py +140 -2
- coderouter/config/loader.py +24 -1
- coderouter/config/schemas.py +144 -0
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/logging.py +7 -0
- coderouter/rollback.py +217 -0
- coderouter/secret_redaction.py +606 -0
- coderouter/state/audit_log.py +6 -0
- coderouter/state/request_log.py +6 -0
- coderouter/vscode_init.py +156 -20
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/RECORD +21 -17
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|
coderouter/state/audit_log.py
CHANGED
|
@@ -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)
|
coderouter/state/request_log.py
CHANGED
|
@@ -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)
|