fix(redact): close emission gaps - env suffix keys, control-char splits, process(list) (#77484)

This commit is contained in:
Soheil Fakour 2026-08-06 19:26:08 -04:00 committed by kshitij
parent 15d7103aa7
commit 8563fe3435
4 changed files with 180 additions and 5 deletions

View File

@ -140,10 +140,26 @@ _PREFIX_PATTERNS = [
# ENV assignment patterns: KEY=value where KEY contains a secret-like name.
# Uppercase keys tolerate spaces around "=" (e.g. ``FOO_SECRET = bar``) because
# an all-caps key is almost never prose/code.
_SECRET_ENV_NAMES = r"(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)"
# Bare ``KEY`` / ``PASS`` / ``PW`` suffixes are included (``FAL_KEY=…``,
# ``MYSQL_PASS=…``, ``DB_PW=…``) — issue #77484. The regex is IGNORECASE so
# lowercase env names (``openai_key=…``) are caught here too. The secret name
# must sit at a word boundary (``_``-delimited or whole-word) so generic
# prose words (``password=``, ``token=``, ``KEYBOARD=``, ``PASSAGE=``) do not
# match — those are handled by the config/form/URL paths, and a bare
# ``password=…`` in a form body must not be swallowed greedily by ``\S+``.
_SECRET_ENV_NAMES = r"(?:API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PW|CREDENTIAL|AUTH)"
# Uppercase keys keep the legacy embedded match (``MYTOKEN=…``, ``FOO_SECRET``)
# — an all-caps key is almost never prose.
_ENV_ASSIGN_RE = re.compile(
rf"([A-Z0-9_]{{0,50}}{_SECRET_ENV_NAMES}[A-Z0-9_]{{0,50}})\s*=\s*(['\"]?)(\S+)\2",
)
# Lowercase env names: only underscore-boundary forms (``openai_key=…``,
# ``FAL_KEY=…``, ``db_pw=…``) — NOT bare ``password=``/``token=``/``secret=``,
# which appear in prose, URLs, and form bodies (issue #77484).
_ENV_ASSIGN_LOWER_RE = re.compile(
rf"([a-z0-9_]+(?:_|^)(?:key|pass|pw|token|secret|password|passwd|credential|auth)(?=[^a-z0-9_]|$))\s*=\s*(['\"]?)(\S+)\2",
re.IGNORECASE,
)
# Lowercase / dotted / hyphenated config keys from config files
# (application.properties, .env, YAML-ish dumps): ``spring.datasource.password=secret``,
@ -236,8 +252,8 @@ _YAML_ASSIGN_RE = re.compile(
# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an
# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE.
_KEY_KEYWORD_RE = re.compile(
r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)"
r"|token|secret|passwd|password|credential|auth",
r"(?:api|auth|access|refresh|session|secret)[ _.\\-]?(?:key|token)"
r"|token|secret|passwd|password|pass|pw|credential|auth|key",
re.IGNORECASE,
)
@ -283,7 +299,15 @@ def _key_has_secret_keyword(key: str) -> bool:
"""
letters = [c for c in key if c.isalpha()]
if letters and all(c.isupper() for c in letters):
return True # legacy all-caps behavior (MYTOKEN=…)
# Legacy all-caps behavior (MYTOKEN=…): an all-caps key is almost
# never prose. Exception: a bare ``KEY``/``PASS``/``PW`` embedded in
# a longer all-caps word (``KEYBOARD``, ``PASSAGE``) is prose, not a
# credential — only a word-bounded compound (``API_KEY``,
# ``MYSQL_PASSWORD``, ``FAL_KEY``, ``DB_PW``) counts (issue #77484).
for m in _KEY_KEYWORD_RE.finditer(key):
if _is_word_start(key, m.start()) and _is_word_end(key, m.end()):
return True
return False
for m in _KEY_KEYWORD_RE.finditer(key):
if _is_word_start(key, m.start()) and _is_word_end(key, m.end()):
return True
@ -440,12 +464,63 @@ _FORM_BODY_RE = re.compile(
r"^[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*(?:&[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*)+$"
)
# Control / zero-width characters that can split a token body: a secret
# smuggled as ``sk-abc\x1bdef…`` or ``ghp_abc\n123…`` escapes the contiguous
# prefix regexes (issue #77484). Used by _mask_control_split_tokens.
_CONTROL_CHARS_RE = re.compile(
r"[\x00-\x1f\x7f\u200b-\u200f\u2028-\u202f\u2060\ufeff]"
)
# Union of every _PREFIX_PATTERNS body class — a control-stripped match may
# only span original chars that are token-body or control chars (see
# _mask_control_split_tokens). ``=`` is deliberately excluded: a KEY=value
# assignment separator must never let a match span across unrelated text.
_TOKEN_BODY_CHARS = frozenset(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-."
)
# Compile known prefix patterns into one alternation
_PREFIX_RE = re.compile(
r"(?<![A-Za-z0-9_-])(" + "|".join(_PREFIX_PATTERNS) + r")(?![A-Za-z0-9_-])"
)
def _mask_control_split_tokens(text: str, mask_fn) -> str:
"""Mask tokens whose body is split by control/zero-width characters.
A credential like ``sk-abc\\x1bdef456`` or ``ghp_abc\\n123def`` has its
token body interrupted, so the contiguous _PREFIX_RE cannot match it and
the secret leaks verbatim (issue #77484). Strategy: build a copy with all
control chars removed (the token is contiguous again, matching even when
each fragment alone is too short), match on that, then mask the
corresponding span in the *original* but only when the original span
contains solely token-body and control chars (a match that crosses into a
different line's unrelated text, e.g. ``EXA_API_KEY=*** is rejected).
"""
stripped = _CONTROL_CHARS_RE.sub("", text)
if stripped == text:
return text
orig_idx = [i for i, c in enumerate(text) if not _CONTROL_CHARS_RE.match(c)]
out = list(text)
matches = []
for m in _PREFIX_RE.finditer(stripped):
body = m.group(1)
start_orig = orig_idx[m.start(1)]
end_orig = orig_idx[m.end(1) - 1] + 1
# Reject matches whose original span crosses a non-token char
# (e.g. ``sk_abc…\nTAVILY_API_KEY=…`` — the ``=`` is not part of a
# token body, so the regex matched across unrelated lines). Also
# reject when the match runs into a ``KEY=`` name: a real token value
# is followed by a newline/space/end, not ``=``.
if (all(c in _TOKEN_BODY_CHARS or _CONTROL_CHARS_RE.match(c)
for c in text[start_orig:end_orig])
and (end_orig >= len(text) or text[end_orig] != "=")):
matches.append((start_orig, end_orig, mask_fn(body)))
for start_orig, end_orig, replacement in reversed(matches):
out[start_orig:end_orig] = list(replacement)
return "".join(out)
def mask_secret(
value: str,
*,
@ -725,6 +800,13 @@ def redact_sensitive_text(
# Known prefixes (sk-, ghp_, etc.) — gate on substring presence
if _has_known_prefix_substring(text):
_prefix_sub = _mask_token_nonreusable if file_read else _mask_token
# Control/zero-width chars (\\n, \\r, ESC, U+200B, …) split a token
# body so _PREFIX_RE cannot match across them — a secret smuggled as
# ``sk-abc\\x1bdef…`` leaks verbatim (issue #77484). Mask such runs by
# first matching on a control-stripped copy, then re-masking the
# corresponding span in the original (the stripped copy and the
# original are aligned 1:1 for non-control chars).
text = _mask_control_split_tokens(text, _prefix_sub)
text = _PREFIX_RE.sub(lambda m: _prefix_sub(m.group(1)), text)
# ENV assignments: OPENAI_API_KEY=*** (skip for code files — false positives)
@ -746,6 +828,14 @@ def redact_sensitive_text(
return m.group(0)
return f"{name}={quote}{_mask_token(value)}{quote}"
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
# Lowercase env names (``openai_key=…``). Skip URLs — the query
# string may contain ``token=``/``key=`` params that are
# intentionally passed through (see note near the bottom of this
# function; _redact_strict_url_credentials handles the opt-in
# case). The uppercase regex above is all-caps-only, so it never
# matches URL params; the lowercase one would (issue #77484).
if "://" not in text:
text = _ENV_ASSIGN_LOWER_RE.sub(_redact_env, text)
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
# web-URL query params are intentionally passed through (see note
# near the bottom of this function); _DB_CONNSTR_RE still guards

View File

@ -114,6 +114,68 @@ class TestEnvAssignments:
assert "mypassword" not in result
class TestBareSecretEnvSuffixes:
"""Bare *_KEY / *_PASS / *_PW env suffixes mask, incl. lowercase — #77484."""
def test_upper_suffix_keys_mask(self):
for text in ("FAL_KEY=sk-abc123def456", "OPENAI_KEY=sk-abc123def456",
"MYSQL_PASS=ghi789", "DB_PW=jkl012"):
result = redact_sensitive_text(text, force=True)
assert "=" in result and result.split("=", 1)[1] != text.split("=", 1)[1]
def test_lowercase_env_name_masks(self):
result = redact_sensitive_text("openai_key=sk-abc123def456", force=True)
assert "sk-abc123def456" not in result
def test_prose_words_with_keyword_unchanged(self):
# KEYBOARD / PASSAGE embed the bare keyword but are prose, not creds
for text in ("KEYBOARD=notsecret", "PASSAGE=notsecret"):
result = redact_sensitive_text(text, force=True)
assert result == text
def test_form_body_not_swallowed(self):
# A bare `password=`/`token=` in a form body must not be eaten greedily
text = "password=mysecret&username=bob&token=opaqueValue"
result = redact_sensitive_text(text, force=True)
assert "mysecret" not in result
assert "opaqueValue" not in result
assert "username=bob" in result
class TestControlCharSplitTokens:
"""Tokens split by control/zero-width chars must still mask — #77484."""
def test_newline_split_token_masks(self):
tok = "ghp_abcdef1234567890ABCDEF1234567890abcdef"
text = f"token={tok[:10]}\n{tok[10:]}"
result = redact_sensitive_text(text, force=True)
assert tok not in result
def test_esc_split_token_masks(self):
tok = "ghp_abcdef1234567890ABCDEF1234567890abcdef"
text = f"token={tok[:10]}\x1b{tok[10:]}"
result = redact_sensitive_text(text, force=True)
assert tok not in result
def test_zero_width_split_token_masks(self):
tok = "ghp_abcdef1234567890ABCDEF1234567890abcdef"
text = f"token={tok[:10]}\u200b{tok[10:]}"
result = redact_sensitive_text(text, force=True)
assert tok not in result
def test_env_dump_lines_not_joined(self):
# Control-stripping must not join unrelated env lines into one match
env_dump = (
"HOME=/home/user\n"
"ELEVENLABS_API_KEY=sk_abc123def456ghi789jkl\n"
"EXA_API_KEY=exa_XY789abcdef01234\n"
"SHELL=/bin/bash\n"
)
result = redact_sensitive_text(env_dump, force=True)
assert "SHELL=/bin/bash" in result
assert "HOME=/home/user" in result
class TestEnvLookupPreserved:
"""Programmatic env var lookups must not be corrupted (issue #2852)."""

View File

@ -1417,6 +1417,24 @@ class TestHandleProcessRedaction:
out = json.loads(pr._handle_process({"action": "poll", "session_id": sess.id}))
assert "abc123def456" not in out["output_preview"]
def test_list_redacts_command_and_output(self, monkeypatch):
"""`process(action=list)` redacts command + output_preview — issue #77484.
The list branch previously returned raw ``command[:200]`` and
``output_preview[-200:]`` with no redaction wrap, leaking inline
secrets (unlike poll/log/wait/kill).
"""
pr, sess = self._setup(
monkeypatch, "curl -H 'Authorization: Bearer sk-abc123def456ghi789jkl012345'",
"opaque token sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGG output",
)
out = json.loads(pr._handle_process({"action": "list"}))
assert len(out["processes"]) >= 1
entry = out["processes"][0]
assert "sk-abc123def456ghi789jkl012345" not in entry["command"]
assert "sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGG" not in entry["output_preview"]
assert "curl" in entry["command"]
def test_disabled_passes_through(self, monkeypatch):
import agent.redact as _r
monkeypatch.setattr(_r, "_REDACT_ENABLED", False)

View File

@ -2493,7 +2493,12 @@ def _handle_process(args, **kw):
except Exception:
session_key = ""
return json.dumps(
{"processes": process_registry.list_sessions(task_id=task_id, session_key=session_key or None)},
{
"processes": [
_redact_process_result(p)
for p in process_registry.list_sessions(task_id=task_id, session_key=session_key or None)
]
},
ensure_ascii=False,
)
elif action in {"poll", "log", "wait", "kill", "write", "submit", "close"}: