switchroom 0.19.35 → 0.19.36
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.
- package/dist/cli/skill-validate-pretool.mjs +15 -2
- package/dist/cli/switchroom.js +672 -441
- package/dist/host-control/main.js +149 -3
- package/package.json +4 -2
- package/profiles/_base/start.sh.hbs +37 -12
- package/telegram-plugin/dist/gateway/gateway.js +311 -93
- package/telegram-plugin/format.ts +70 -15
- package/telegram-plugin/gateway/gateway.ts +22 -29
- package/telegram-plugin/gateway/ipc-server.ts +18 -15
- package/telegram-plugin/gateway/model-command.ts +34 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
- package/telegram-plugin/operator-events.ts +40 -16
- package/telegram-plugin/secret-detect/db-uri.ts +90 -0
- package/telegram-plugin/secret-detect/index.ts +24 -1
- package/telegram-plugin/secret-detect/inert-values.ts +147 -0
- package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
- package/telegram-plugin/secret-detect/patterns.ts +24 -4
- package/telegram-plugin/tests/format-consistency.test.ts +93 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
- package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
- package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
- package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
- package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
- package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
- package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Write-path secret redaction for the Hindsight plugin.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS
|
|
4
|
+
---------------
|
|
5
|
+
Switchroom ships a secret redactor (``telegram-plugin/secret-detect/``)
|
|
6
|
+
that masks credentials before they are persisted to the Telegram message
|
|
7
|
+
store, the issues log and hostd output. It is TypeScript, and it never ran
|
|
8
|
+
on the path that writes agent MEMORY — every retain in this plugin is
|
|
9
|
+
Python, so credentials that appeared in a turn were stored in Hindsight
|
|
10
|
+
verbatim.
|
|
11
|
+
|
|
12
|
+
This module is the Python half of that redactor, wired at the ONE function
|
|
13
|
+
every retain in this plugin goes through (``lib/client.py``:
|
|
14
|
+
``HindsightClient.retain`` before splitting, plus ``_request`` as a
|
|
15
|
+
structural backstop for any future caller that skips ``retain``).
|
|
16
|
+
|
|
17
|
+
NO FORKED PATTERN LIST
|
|
18
|
+
----------------------
|
|
19
|
+
The regex table is NOT written here. It is loaded from
|
|
20
|
+
``secret_patterns.json``, which is GENERATED from
|
|
21
|
+
``telegram-plugin/secret-detect/patterns.ts`` by
|
|
22
|
+
``scripts/gen-secret-patterns.ts`` and pinned by the ``bun lint`` guard
|
|
23
|
+
``scripts/check-secret-pattern-parity.ts``. A pattern added once therefore
|
|
24
|
+
covers the Telegram gate, the issues pipeline AND memory intake.
|
|
25
|
+
|
|
26
|
+
That byte-compare proves the SOURCES match. It does not prove the
|
|
27
|
+
BEHAVIOUR matches — identical regex source means different things to
|
|
28
|
+
``re`` and to ``RegExp`` (see the semantics bridge below), which is how
|
|
29
|
+
the ``\b``-boundary fork shipped past a green CI in the first place.
|
|
30
|
+
Behaviour parity is a separate, weaker guarantee, held up by the shared
|
|
31
|
+
vectors in ``secret_redaction_vectors.json`` and by the differential test
|
|
32
|
+
``telegram-plugin/tests/secret-detect-cross-engine.test.ts``, which runs
|
|
33
|
+
BOTH engines over the same corpus and compares their output.
|
|
34
|
+
|
|
35
|
+
The three rules that need imperative gates rather than a bare regex —
|
|
36
|
+
the KEY=VALUE entropy heuristic, connection-URI credentials and
|
|
37
|
+
human-memorable passwords — are ported by hand below and pinned against
|
|
38
|
+
their TypeScript counterparts by the shared behaviour vectors in
|
|
39
|
+
``secret_redaction_vectors.json`` (asserted by
|
|
40
|
+
``scripts/tests/test_secret_redact.py`` here and by
|
|
41
|
+
``telegram-plugin/tests/secret-detect-write-path.test.ts`` there).
|
|
42
|
+
|
|
43
|
+
DELIBERATE DIFFERENCES FROM THE TS ENGINE
|
|
44
|
+
-----------------------------------------
|
|
45
|
+
* The generic high-entropy fallback is not ported. ``redact()`` in
|
|
46
|
+
TypeScript explicitly filters ``generic_high_entropy`` out before
|
|
47
|
+
masking (it is a low-precision "ask the user" signal, not a mask
|
|
48
|
+
signal), so a redact-only engine has no use for it.
|
|
49
|
+
* No 32 KB chunking. The TS engine windows large inputs to bound ReDoS on
|
|
50
|
+
a hot Telegram path; here we scan the whole string, which is strictly
|
|
51
|
+
more coverage, and the patterns are all linear-time shapes.
|
|
52
|
+
* The suppressor (test/example/fixture demotion) is not ported: it only
|
|
53
|
+
demotes confidence, and ``redact()`` masks suppressed hits anyway.
|
|
54
|
+
|
|
55
|
+
FAIL-CLOSED
|
|
56
|
+
-----------
|
|
57
|
+
If the pattern table is missing or unparseable, ``redact()`` raises at
|
|
58
|
+
import time rather than silently becoming a no-op. A redactor that fails
|
|
59
|
+
open is worse than no redactor, because callers stop looking.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
import json
|
|
63
|
+
import math
|
|
64
|
+
import os
|
|
65
|
+
import re
|
|
66
|
+
from typing import Dict, List, Optional, Tuple
|
|
67
|
+
|
|
68
|
+
REDACTED_MARKER = "[REDACTED]"
|
|
69
|
+
|
|
70
|
+
_PATTERNS_FILE = os.path.join(os.path.dirname(__file__), "secret_patterns.json")
|
|
71
|
+
|
|
72
|
+
# ─── JS/Python regex semantics bridge ─────────────────────────────────
|
|
73
|
+
#
|
|
74
|
+
# The pattern SOURCES are byte-identical across the two engines (that is
|
|
75
|
+
# what `check-secret-pattern-parity` proves), but identical source is not
|
|
76
|
+
# identical behaviour: `re` and `RegExp` disagree on what the shorthand
|
|
77
|
+
# classes MEAN.
|
|
78
|
+
#
|
|
79
|
+
# `\b`, `\w`, `\d`, and case-folding under `re.I`
|
|
80
|
+
# Python `str` patterns are UNICODE-aware by default, JavaScript
|
|
81
|
+
# `RegExp` without the `u` flag is ASCII-only. Every anchored rule
|
|
82
|
+
# here is `\b`-delimited, so `sk-ant-...<CJK char>` was a word
|
|
83
|
+
# boundary in JS (masked) and NOT one in Python (stored verbatim).
|
|
84
|
+
# Fixed by compiling with `re.ASCII`.
|
|
85
|
+
#
|
|
86
|
+
# Note the flag arithmetic below: `re.UNICODE` is implicit on `str`
|
|
87
|
+
# patterns and `flags | re.ASCII` alone raises
|
|
88
|
+
# `ValueError: ASCII and UNICODE flags are incompatible`.
|
|
89
|
+
#
|
|
90
|
+
# `\s` / `\S`
|
|
91
|
+
# This one runs the OTHER way. JavaScript `\s` is Unicode-aware
|
|
92
|
+
# even without the `u` flag (NBSP, ideographic space, BOM all
|
|
93
|
+
# match), so plain `re.ASCII` would narrow Python's `\s` below
|
|
94
|
+
# JS's and re-open the fork on the separator instead of the
|
|
95
|
+
# boundary. `_js_compat_source` therefore rewrites `\s`/`\S` to an
|
|
96
|
+
# explicit class carrying exactly the ECMAScript WhiteSpace +
|
|
97
|
+
# LineTerminator set, so the two agree under `re.ASCII`.
|
|
98
|
+
#
|
|
99
|
+
# The rewrite happens HERE rather than in the codegen on purpose: keeping
|
|
100
|
+
# `secret_patterns.json` a faithful mirror of `patterns.ts` is what makes
|
|
101
|
+
# the byte-compare guard readable, and the semantics bridge is a property
|
|
102
|
+
# of the Python engine, not of the shared table.
|
|
103
|
+
|
|
104
|
+
# ECMAScript WhiteSpace + LineTerminator (what JS `\s` matches).
|
|
105
|
+
_JS_WS = (
|
|
106
|
+
" \\t\\n\\x0b\\x0c\\r"
|
|
107
|
+
"\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _js_compat_source(src: str) -> str:
|
|
112
|
+
"""Rewrite `\\s`/`\\S` so they mean under `re.ASCII` what they mean to
|
|
113
|
+
a JavaScript `RegExp` without the `u` flag.
|
|
114
|
+
|
|
115
|
+
`[\\s\\S]` (the "any character including newlines" idiom) is left
|
|
116
|
+
alone: `\\S` cannot be expressed inside a character class, and the
|
|
117
|
+
union is every character in either engine regardless of flags.
|
|
118
|
+
"""
|
|
119
|
+
out: List[str] = []
|
|
120
|
+
i = 0
|
|
121
|
+
n = len(src)
|
|
122
|
+
in_class = False
|
|
123
|
+
while i < n:
|
|
124
|
+
ch = src[i]
|
|
125
|
+
if ch == "\\" and i + 1 < n:
|
|
126
|
+
nxt = src[i + 1]
|
|
127
|
+
if nxt == "s":
|
|
128
|
+
out.append(_JS_WS if in_class else "[" + _JS_WS + "]")
|
|
129
|
+
i += 2
|
|
130
|
+
continue
|
|
131
|
+
if nxt == "S" and not in_class:
|
|
132
|
+
out.append("[^" + _JS_WS + "]")
|
|
133
|
+
i += 2
|
|
134
|
+
continue
|
|
135
|
+
out.append(src[i : i + 2])
|
|
136
|
+
i += 2
|
|
137
|
+
continue
|
|
138
|
+
if ch == "[" and not in_class:
|
|
139
|
+
in_class = True
|
|
140
|
+
elif ch == "]" and in_class:
|
|
141
|
+
in_class = False
|
|
142
|
+
out.append(ch)
|
|
143
|
+
i += 1
|
|
144
|
+
return "".join(out)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _compile(source: str, flags: int = 0) -> "re.Pattern[str]":
|
|
148
|
+
"""Compile with JavaScript-compatible shorthand-class semantics."""
|
|
149
|
+
return re.compile(_js_compat_source(source), flags & ~re.UNICODE | re.ASCII)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _load_patterns() -> List[dict]:
|
|
153
|
+
with open(_PATTERNS_FILE, "r", encoding="utf-8") as fh:
|
|
154
|
+
doc = json.load(fh)
|
|
155
|
+
patterns = doc.get("patterns")
|
|
156
|
+
if not isinstance(patterns, list) or not patterns:
|
|
157
|
+
raise ValueError(f"{_PATTERNS_FILE} has no patterns array")
|
|
158
|
+
compiled = []
|
|
159
|
+
for p in patterns:
|
|
160
|
+
flags = 0
|
|
161
|
+
if p.get("ignore_case"):
|
|
162
|
+
flags |= re.I
|
|
163
|
+
if p.get("multiline"):
|
|
164
|
+
flags |= re.M
|
|
165
|
+
compiled.append(
|
|
166
|
+
{
|
|
167
|
+
"rule_id": p["rule_id"],
|
|
168
|
+
"regex": _compile(p["source"], flags),
|
|
169
|
+
"capture_index": int(p.get("capture_index", 0)),
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
return compiled
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
_COMPILED_PATTERNS = _load_patterns()
|
|
176
|
+
|
|
177
|
+
# ─── Gates mirrored from the TypeScript engine ────────────────────────
|
|
178
|
+
|
|
179
|
+
# index.ts — env_key_value shape gate.
|
|
180
|
+
ENV_KV_MIN_LEN = 12
|
|
181
|
+
ENV_KV_MIN_ENTROPY = 3.5
|
|
182
|
+
# kv-scanner.ts — KV_ENTROPY_THRESHOLD.
|
|
183
|
+
KV_ENTROPY_THRESHOLD = 4.0
|
|
184
|
+
# kv-scanner.ts — MEMORABLE_PW_MIN_CLASSES.
|
|
185
|
+
MEMORABLE_PW_MIN_CLASSES = 2
|
|
186
|
+
|
|
187
|
+
_KV_RE = _compile(
|
|
188
|
+
r"\b([A-Za-z_][A-Za-z0-9_-]*(?:password|passwd|token|secret|key|api[_-]?key))"
|
|
189
|
+
r"\s*[:=]\s*[\"']?([^\s\"'\\]{8,})[\"']?",
|
|
190
|
+
re.I,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
_MEMORABLE_PW_RE = _compile(
|
|
194
|
+
r"\b([A-Za-z0-9_-]*(?:password|passwd|passphrase|pwd))\b"
|
|
195
|
+
r"\s*(?:[:=]\s*|\s+is\s+)([\"']?)([^\s\"']{8,64})\2",
|
|
196
|
+
re.I,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# `<scheme>://<user>:<password>@<host>` — see db-uri.ts for the shape
|
|
200
|
+
# rationale. The password class deliberately ADMITS `@` and stops only at
|
|
201
|
+
# an authority terminator (`/`, `?`, `#`, whitespace); greedy matching
|
|
202
|
+
# then backs off to the LAST `@`, so an unencoded `@` inside the password
|
|
203
|
+
# cannot leave the tail of the password outside the masked span.
|
|
204
|
+
_DB_URI_RE = _compile(
|
|
205
|
+
r"\b([a-zA-Z][a-zA-Z0-9+.-]+)://([^\s:/@]+):([^\s/?#]+)@([^\s/@?#]+)"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Placeholders, variable references and already-masked text. Redacting
|
|
209
|
+
# these buys nothing and destroys the meaning of the surrounding line —
|
|
210
|
+
# and for a `vault:` reference it deletes exactly the key NAME an agent
|
|
211
|
+
# is supposed to remember. Mirrors INERT_VALUE_RE in
|
|
212
|
+
# telegram-plugin/secret-detect/inert-values.ts.
|
|
213
|
+
# ``$`` is spelled ``\Z`` throughout: Python's ``$`` also matches BEFORE a
|
|
214
|
+
# final newline, JavaScript's (without ``/m``) does not. Every entry here
|
|
215
|
+
# is end-anchored, so that difference would be a live cross-engine fork.
|
|
216
|
+
_INERT_VALUE_RES = [
|
|
217
|
+
_compile(r"^\[REDACTED(?::[A-Za-z0-9_]+)?\]\Z", re.I),
|
|
218
|
+
_compile(r"^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?\Z"),
|
|
219
|
+
_compile(r"^\{\{[^\n\r\u2028\u2029]*\}\}\Z"),
|
|
220
|
+
_compile(r"^<[^<>\n\r\u2028\u2029]*>\Z"),
|
|
221
|
+
_compile(r"^%[A-Za-z_][A-Za-z0-9_]*%\Z"),
|
|
222
|
+
_compile(r"^vault:[A-Za-z0-9_./-]*\Z", re.I),
|
|
223
|
+
_compile(r"^[*x\u2022.]+\Z", re.I),
|
|
224
|
+
_compile(
|
|
225
|
+
r"^(?:process\.env|import\.meta\.env|os\.environ)"
|
|
226
|
+
r"(?:\.[A-Za-z_$][A-Za-z0-9_$]*"
|
|
227
|
+
r"|\[[\"']?[A-Za-z_$][A-Za-z0-9_$]*[\"']?\])*\Z"
|
|
228
|
+
),
|
|
229
|
+
_compile(
|
|
230
|
+
r"^(?:changeme|change-me|replaceme|replace-me|placeholder|todo|tbd"
|
|
231
|
+
r"|yourkey|your-key|yourpassword|your-password|yoursecret|your-secret"
|
|
232
|
+
r"|yourtoken|your-token)(?:[-_][A-Za-z0-9-]+)?\Z",
|
|
233
|
+
re.I,
|
|
234
|
+
),
|
|
235
|
+
_compile(r"^[a-z]+(?: [a-z]+){2,}\Z"),
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
# An unbroken run of ``[A-Za-z0-9]`` long enough to BE a credential.
|
|
239
|
+
# Mirrors CREDENTIAL_RUN_RE / hasCredentialShapedRun in inert-values.ts.
|
|
240
|
+
_CREDENTIAL_RUN_RE = _compile(r"[A-Za-z0-9]{12,}")
|
|
241
|
+
# Mixed-class floor = ENV_KV_MIN_LEN. Single-class runs (only letters or
|
|
242
|
+
# only digits) are far more likely to be English words, so they get more
|
|
243
|
+
# rope -- but not unlimited: no word in the protected corpus reaches 16.
|
|
244
|
+
_MIXED_CLASS_RUN_MIN = 12
|
|
245
|
+
_SINGLE_CLASS_RUN_MIN = 16
|
|
246
|
+
|
|
247
|
+
# Trailing punctuation an English sentence puts AFTER a value. The value
|
|
248
|
+
# classes are `[^\s"']`-shaped, so a sentence-final `.` or a list comma
|
|
249
|
+
# is captured as part of the value; stripping it before the shape gates
|
|
250
|
+
# is what keeps "The password is required." out of the redactor.
|
|
251
|
+
_TRAILING_PUNCT_RE = re.compile(r"[.,;:!?)\]}]+$")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _strip_trailing_punctuation(value: str) -> str:
|
|
255
|
+
return _TRAILING_PUNCT_RE.sub("", value)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _run_is_credential_shaped(run: str) -> bool:
|
|
259
|
+
classes = 0
|
|
260
|
+
if re.search(r"[a-z]", run):
|
|
261
|
+
classes += 1
|
|
262
|
+
if re.search(r"[A-Z]", run):
|
|
263
|
+
classes += 1
|
|
264
|
+
if re.search(r"[0-9]", run):
|
|
265
|
+
classes += 1
|
|
266
|
+
floor = _MIXED_CLASS_RUN_MIN if classes >= 2 else _SINGLE_CLASS_RUN_MIN
|
|
267
|
+
return len(run) >= floor
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def has_credential_shaped_run(value: str) -> bool:
|
|
271
|
+
"""True when ``value`` carries a credential-shaped run anywhere inside.
|
|
272
|
+
|
|
273
|
+
#3982 review, MAJOR 7. The inert SHAPES are necessary but not
|
|
274
|
+
sufficient: ``<a8f3...>`` and ``{{tok_...}}`` are legal instances of
|
|
275
|
+
the ``<...>`` / ``{{...}}`` shapes, and a wrapper left behind after
|
|
276
|
+
someone filled the value in (``vault:pg/password<secret>``,
|
|
277
|
+
``[REDACTED]<secret>``, ``changeme-<secret>``) turned a
|
|
278
|
+
false-positive fix into a bypass of already-shipped coverage.
|
|
279
|
+
"""
|
|
280
|
+
return any(
|
|
281
|
+
_run_is_credential_shaped(m.group(0))
|
|
282
|
+
for m in _CREDENTIAL_RUN_RE.finditer(value)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def is_inert_value(value: str) -> bool:
|
|
287
|
+
if not any(r.search(value) for r in _INERT_VALUE_RES):
|
|
288
|
+
return False
|
|
289
|
+
return not has_credential_shaped_run(value)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# Rules whose captured value is a LABELLED slot: an inert placeholder in
|
|
293
|
+
# that slot is documentation, not a credential. See MAJOR 5 of the #3982
|
|
294
|
+
# review — `POSTGRES_PASSWORD: vault:pg/password` was being destroyed
|
|
295
|
+
# while the lowercase spelling of the same line was left alone.
|
|
296
|
+
_INERT_GATED_RULES = frozenset({"env_key_value", "json_secret_field", "cli_flag"})
|
|
297
|
+
|
|
298
|
+
# url-redact.ts — SENSITIVE_PARAMS.
|
|
299
|
+
_SENSITIVE_PARAMS = {
|
|
300
|
+
"token",
|
|
301
|
+
"key",
|
|
302
|
+
"api_key",
|
|
303
|
+
"apikey",
|
|
304
|
+
"secret",
|
|
305
|
+
"access_token",
|
|
306
|
+
"password",
|
|
307
|
+
"pass",
|
|
308
|
+
"auth",
|
|
309
|
+
"client_secret",
|
|
310
|
+
"refresh_token",
|
|
311
|
+
"signature",
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_URL_RE = _compile(r"\b(?:https?|wss?|ftp)://[^\s<>\"']+", re.I)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def shannon_entropy(value: str) -> float:
|
|
318
|
+
"""Shannon entropy in bits/char — mirrors secret-detect/entropy.ts."""
|
|
319
|
+
if not value:
|
|
320
|
+
return 0.0
|
|
321
|
+
counts: Dict[str, int] = {}
|
|
322
|
+
for ch in value:
|
|
323
|
+
counts[ch] = counts.get(ch, 0) + 1
|
|
324
|
+
n = len(value)
|
|
325
|
+
total = 0.0
|
|
326
|
+
for c in counts.values():
|
|
327
|
+
p = c / n
|
|
328
|
+
total -= p * math.log2(p)
|
|
329
|
+
return total
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _char_class_count(value: str) -> int:
|
|
333
|
+
n = 0
|
|
334
|
+
if re.search(r"[a-z]", value):
|
|
335
|
+
n += 1
|
|
336
|
+
if re.search(r"[A-Z]", value):
|
|
337
|
+
n += 1
|
|
338
|
+
if re.search(r"[0-9]", value):
|
|
339
|
+
n += 1
|
|
340
|
+
if re.search(r"[^A-Za-z0-9]", value):
|
|
341
|
+
n += 1
|
|
342
|
+
return n
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _looks_like_memorable_password(value: str) -> bool:
|
|
346
|
+
"""Port of looksLikeMemorablePassword() in kv-scanner.ts."""
|
|
347
|
+
if is_inert_value(value):
|
|
348
|
+
return False
|
|
349
|
+
core = _strip_trailing_punctuation(value)
|
|
350
|
+
if len(core) < 8 or len(core) > 64:
|
|
351
|
+
return False
|
|
352
|
+
if is_inert_value(core):
|
|
353
|
+
return False
|
|
354
|
+
if len(set(core)) < 4:
|
|
355
|
+
return False
|
|
356
|
+
return _char_class_count(core) >= MEMORABLE_PW_MIN_CLASSES
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ─── URL credential scrub (port of url-redact.ts) ─────────────────────
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _redact_one_url(raw: str) -> Optional[str]:
|
|
363
|
+
"""Mask userinfo + sensitive query params in ONE url, or return None
|
|
364
|
+
when nothing changed. Deliberately string-level: unlike the TS side we
|
|
365
|
+
do not round-trip through a URL parser, because Python's urlsplit /
|
|
366
|
+
urlunsplit normalizes in ways WHATWG does not and the two engines must
|
|
367
|
+
agree byte-for-byte on the common shapes.
|
|
368
|
+
"""
|
|
369
|
+
changed = False
|
|
370
|
+
scheme_sep = raw.find("://")
|
|
371
|
+
if scheme_sep < 0:
|
|
372
|
+
return None
|
|
373
|
+
rest = raw[scheme_sep + 3 :]
|
|
374
|
+
# Authority runs to the first /, ?, or #.
|
|
375
|
+
auth_end = len(rest)
|
|
376
|
+
for ch in "/?#":
|
|
377
|
+
i = rest.find(ch)
|
|
378
|
+
if i >= 0:
|
|
379
|
+
auth_end = min(auth_end, i)
|
|
380
|
+
authority = rest[:auth_end]
|
|
381
|
+
tail = rest[auth_end:]
|
|
382
|
+
at = authority.rfind("@")
|
|
383
|
+
if at >= 0:
|
|
384
|
+
authority = "***@" + authority[at + 1 :]
|
|
385
|
+
changed = True
|
|
386
|
+
# Sensitive query params.
|
|
387
|
+
if "?" in tail:
|
|
388
|
+
path, _, query_and_frag = tail.partition("?")
|
|
389
|
+
query, hashsep, frag = query_and_frag.partition("#")
|
|
390
|
+
parts = []
|
|
391
|
+
for pair in query.split("&"):
|
|
392
|
+
key, eq, _val = pair.partition("=")
|
|
393
|
+
if eq and key.lower() in _SENSITIVE_PARAMS:
|
|
394
|
+
parts.append(f"{key}=***")
|
|
395
|
+
changed = True
|
|
396
|
+
else:
|
|
397
|
+
parts.append(pair)
|
|
398
|
+
tail = path + "?" + "&".join(parts) + hashsep + frag
|
|
399
|
+
if not changed:
|
|
400
|
+
return None
|
|
401
|
+
return raw[: scheme_sep + 3] + authority + tail
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def redact_urls(text: str) -> str:
|
|
405
|
+
def _sub(m: "re.Match[str]") -> str:
|
|
406
|
+
replaced = _redact_one_url(m.group(0))
|
|
407
|
+
return replaced if replaced is not None else m.group(0)
|
|
408
|
+
|
|
409
|
+
return _URL_RE.sub(_sub, text)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ─── Detection ────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class _Hit:
|
|
416
|
+
__slots__ = ("rule_id", "start", "end")
|
|
417
|
+
|
|
418
|
+
def __init__(self, rule_id: str, start: int, end: int) -> None:
|
|
419
|
+
self.rule_id = rule_id
|
|
420
|
+
self.start = start
|
|
421
|
+
self.end = end
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _pattern_hits(text: str) -> List[_Hit]:
|
|
425
|
+
hits: List[_Hit] = []
|
|
426
|
+
for p in _COMPILED_PATTERNS:
|
|
427
|
+
idx = p["capture_index"]
|
|
428
|
+
for m in p["regex"].finditer(text):
|
|
429
|
+
if not m.group(0):
|
|
430
|
+
continue
|
|
431
|
+
cap = m.group(0) if idx == 0 else m.group(idx)
|
|
432
|
+
if not cap:
|
|
433
|
+
continue
|
|
434
|
+
start = m.start(0) if idx == 0 else m.start(idx)
|
|
435
|
+
if start < 0:
|
|
436
|
+
continue
|
|
437
|
+
if p["rule_id"] in _INERT_GATED_RULES and is_inert_value(cap):
|
|
438
|
+
continue
|
|
439
|
+
if p["rule_id"] == "env_key_value":
|
|
440
|
+
if len(cap) < ENV_KV_MIN_LEN:
|
|
441
|
+
continue
|
|
442
|
+
if shannon_entropy(cap) < ENV_KV_MIN_ENTROPY:
|
|
443
|
+
continue
|
|
444
|
+
hits.append(_Hit(p["rule_id"], start, start + len(cap)))
|
|
445
|
+
return hits
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _kv_hits(text: str) -> List[_Hit]:
|
|
449
|
+
hits: List[_Hit] = []
|
|
450
|
+
for m in _KV_RE.finditer(text):
|
|
451
|
+
value = m.group(2)
|
|
452
|
+
if not value:
|
|
453
|
+
continue
|
|
454
|
+
# Mirrors scanKeyValue() in kv-scanner.ts — placeholders and code
|
|
455
|
+
# references are not credentials.
|
|
456
|
+
if is_inert_value(value):
|
|
457
|
+
continue
|
|
458
|
+
if shannon_entropy(value) < KV_ENTROPY_THRESHOLD:
|
|
459
|
+
continue
|
|
460
|
+
hits.append(_Hit("kv_entropy", m.start(2), m.end(2)))
|
|
461
|
+
return hits
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _db_uri_hits(text: str) -> List[_Hit]:
|
|
465
|
+
hits: List[_Hit] = []
|
|
466
|
+
for m in _DB_URI_RE.finditer(text):
|
|
467
|
+
password = m.group(3)
|
|
468
|
+
if password.startswith("[REDACTED") or re.fullmatch(
|
|
469
|
+
r"[*x]+", password, re.I | re.A
|
|
470
|
+
):
|
|
471
|
+
continue
|
|
472
|
+
hits.append(_Hit("db_uri_password", m.start(3), m.end(3)))
|
|
473
|
+
return hits
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _memorable_password_hits(text: str) -> List[_Hit]:
|
|
477
|
+
hits: List[_Hit] = []
|
|
478
|
+
for m in _MEMORABLE_PW_RE.finditer(text):
|
|
479
|
+
value = m.group(3)
|
|
480
|
+
if not value or not _looks_like_memorable_password(value):
|
|
481
|
+
continue
|
|
482
|
+
# The WHOLE captured value is masked, trailing punctuation
|
|
483
|
+
# included: once the gates have decided this is a credential, a
|
|
484
|
+
# trailing `!` is far more likely to be the last byte of the
|
|
485
|
+
# password than the end of a sentence, and losing a period from
|
|
486
|
+
# inside an already-redacted span costs nothing.
|
|
487
|
+
hits.append(_Hit("memorable_password", m.start(3), m.end(3)))
|
|
488
|
+
return hits
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _marker(rule_id: str) -> str:
|
|
492
|
+
"""Port of redactedMarker() in redact.ts."""
|
|
493
|
+
trimmed = re.sub(r"^(kv|env)_", "", rule_id)
|
|
494
|
+
if not trimmed or trimmed in ("key_value", "kv_entropy"):
|
|
495
|
+
return REDACTED_MARKER
|
|
496
|
+
return f"[REDACTED:{trimmed}]"
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _resolve(hits: List[_Hit]) -> List[_Hit]:
|
|
500
|
+
"""Keep a non-overlapping set, preferring the earliest start and, on a
|
|
501
|
+
tie, the widest span. Overlapping replacements would corrupt offsets;
|
|
502
|
+
the TS engine gets the same effect from dedupeRaw + dropOverlaps.
|
|
503
|
+
"""
|
|
504
|
+
ordered = sorted(hits, key=lambda h: (h.start, -(h.end - h.start)))
|
|
505
|
+
kept: List[_Hit] = []
|
|
506
|
+
last_end = -1
|
|
507
|
+
for h in ordered:
|
|
508
|
+
if h.start < last_end:
|
|
509
|
+
continue
|
|
510
|
+
kept.append(h)
|
|
511
|
+
last_end = h.end
|
|
512
|
+
return kept
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def redact(text: Optional[str]) -> Optional[str]:
|
|
516
|
+
"""Mask every detected secret in ``text``, leaving prose intact.
|
|
517
|
+
|
|
518
|
+
Returns the input unchanged when nothing matched. ``None`` and empty
|
|
519
|
+
strings pass through so callers can apply this to optional fields
|
|
520
|
+
without branching.
|
|
521
|
+
"""
|
|
522
|
+
if not text:
|
|
523
|
+
return text
|
|
524
|
+
scrubbed = redact_urls(text)
|
|
525
|
+
hits = (
|
|
526
|
+
_pattern_hits(scrubbed)
|
|
527
|
+
+ _kv_hits(scrubbed)
|
|
528
|
+
+ _db_uri_hits(scrubbed)
|
|
529
|
+
+ _memorable_password_hits(scrubbed)
|
|
530
|
+
)
|
|
531
|
+
if not hits:
|
|
532
|
+
return scrubbed
|
|
533
|
+
out = scrubbed
|
|
534
|
+
for h in sorted(_resolve(hits), key=lambda x: x.start, reverse=True):
|
|
535
|
+
out = out[: h.start] + _marker(h.rule_id) + out[h.end :]
|
|
536
|
+
return out
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def redact_metadata(value):
|
|
540
|
+
"""Recursively redact string leaves of a JSON-shaped metadata value.
|
|
541
|
+
|
|
542
|
+
Retain metadata carries free-form provenance (source paths, tool
|
|
543
|
+
names, transcript excerpts on some producers), so it is a real leak
|
|
544
|
+
surface even though the memory ROW is built from ``content``. Keys are
|
|
545
|
+
left alone — they are structural, and rewriting them would break
|
|
546
|
+
consumers that look them up by name.
|
|
547
|
+
"""
|
|
548
|
+
if isinstance(value, str):
|
|
549
|
+
return redact(value)
|
|
550
|
+
if isinstance(value, list):
|
|
551
|
+
return [redact_metadata(v) for v in value]
|
|
552
|
+
if isinstance(value, dict):
|
|
553
|
+
return {k: redact_metadata(v) for k, v in value.items()}
|
|
554
|
+
return value
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def redact_retain_fields(
|
|
558
|
+
content: Optional[str],
|
|
559
|
+
context: Optional[str] = None,
|
|
560
|
+
metadata: Optional[dict] = None,
|
|
561
|
+
) -> Tuple[Optional[str], Optional[str], Optional[dict]]:
|
|
562
|
+
"""Redact every free-text field a retain POST carries."""
|
|
563
|
+
return redact(content), redact(context), redact_metadata(metadata)
|