"""Fail-closed redaction for text and structures leaving debug/support flows.""" from __future__ import annotations import re from agent.redact import ( _SENSITIVE_QUERY_PARAMS, REDACTION_UNAVAILABLE, SECRET_HEADER_NAME_LIST, _canonical_url_param_name, redact_sensitive_text, ) _REDACTED = "[REDACTED]" _EMAIL_ADDRESS_RE = re.compile( r"(?[?#&;])(?P[A-Za-z0-9_.~+%\-]+)=" r"(?P[^?#&;\s\"'<>]*)" ) # ``%25`` prefixes the double-encoded forms (``%253F``/``%2526``/``%253D``) that a # URL nested inside an already-encoded redirect value carries. _ENCODED_URL_SEP = r"%(?:25)?(?:3f|23|26|3b)" _ENCODED_URL_PARAM_RE = re.compile( rf"(?P{_ENCODED_URL_SEP})" rf"(?P(?:(?!%(?:25)?3d|{_ENCODED_URL_SEP})[^\s\"'<>]){{1,160}}?)" r"(?P%(?:25)?3d|=)" r"(?P.*?)" rf"(?={_ENCODED_URL_SEP}|[\s\"'<>]|$)", re.IGNORECASE, ) _SENSITIVE_HEADER_NAMES = ( "authorization", "proxy-authorization", "cookie", "set-cookie", *SECRET_HEADER_NAME_LIST, ) _HEADER_NAME_PATTERN = "|".join(re.escape(name) for name in _SENSITIVE_HEADER_NAMES) _QUOTED_HEADER_RE = re.compile( rf"(?P['\"]?(?:{_HEADER_NAME_PATTERN})['\"]?\s*[:=]\s*)" r"(?P['\"])(?P.*?)(?P=quote)", re.IGNORECASE, ) # A key may carry inner colons (``sk-abc:xyz``, ``user:pass``); a trailing colon # is prose punctuation (``see: docs``). A letters-only word (``docs``, ``Note``, # ``HTTP``) is prose rather than a key: keys mix case, digits or symbols. The # word test is case-sensitive even inside the IGNORECASE regexes below. A # single-case run of 20+ letters is no English word, so it is a key. _KEY_CHARS = r"A-Za-z0-9._~+/\-" # One key-end boundary shared by "is it a key" and "is it a word" so they cannot drift. _KEY_END = rf"(?![{_KEY_CHARS}=]|:[{_KEY_CHARS}])" _KEY = rf"[{_KEY_CHARS}]+(?::[{_KEY_CHARS}]+)*=*{_KEY_END}" _NOT_WORD = rf"(?!(?-i:[A-Z]?[a-z]{{1,19}}|[A-Z]{{1,19}}){_KEY_END})" # agent.redact masks the scheme of ``x-api-key: Basic `` to ``***`` and # keeps the key, so ``***`` is a scheme too and the key is the value when it # is 8+ key chars (any case, letters-only included: fail closed), key-shaped # (not a word; 4+ chars before any colon, or 6+ mixing letters and digits) or # a short word ending the header (``Basic short``). Shorter prose after a # masked value stays. _SCHEME = ( r"(?:Bearer|Basic|Digest)\s+|\*\*\*[ \t]+" rf"(?=[{_KEY_CHARS}]{{8,}}=*(?![{_KEY_CHARS}=])|{_NOT_WORD}(?=[^\s:]{{4}}|(?=\S*[A-Za-z])(?=\S*[0-9])\S{{6}}){_KEY}|[A-Za-z]{{4,7}}[ \t]*(?:[\r\n'\",;}}\]]|$))" rf"(?!(?:{_HEADER_NAME_PATTERN})\b\s*[:=])" ) _BARE_HEADER_RE = re.compile( rf"(?P\b(?:{_HEADER_NAME_PATTERN})\b\s*[:=]\s*)" rf"(?P(?>(?:{_SCHEME})?)(?!\[REDACTED\])[^\s,}}\]]+)", re.IGNORECASE, ) _SECRET_ARG_FLAG = ( r"--(?:api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|" r"auth[-_]?token|client[-_]?secret|private[-_]?key|secret[-_]?key|" r"password|passwd|credential|token|secret)" ) _QUOTED_ARGV_RE = re.compile( rf"(?P['\"])(?P{_SECRET_ARG_FLAG})(?P=flag_quote)" r"(?P\s*,\s*)" r"(?P['\"])(?P.*?)(?P=value_quote)", re.IGNORECASE, ) _ARG_EQUALS_RE = re.compile( rf"(?P{_SECRET_ARG_FLAG})(?P\s*=\s*)" r"(?P['\"]?)(?!\[REDACTED\])(?P[^\s,}\]'\"]+)(?P=quote)", re.IGNORECASE, ) _ARG_OPERAND_RE = re.compile( rf"(?P{_SECRET_ARG_FLAG})(?P\s+)" r"(?P['\"]?)(?!\[REDACTED\])(?P[^\s,}\]'\"]+)(?P=quote)", re.IGNORECASE, ) _HEADER_ARGV_RE = re.compile( r"(?P['\"])--header(?P=head_quote)\s*,\s*" rf"(?P['\"])(?P{_HEADER_NAME_PATTERN})(?P=name_quote)\s*,\s*" r"(?:(?P['\"])(?PBearer|Basic|Digest)(?P=scheme_quote)\s*,\s*)?" r"(?P['\"])(?P.*?)(?P=value_quote)", re.IGNORECASE, ) _PLAIN_HEADER_ARG_RE = re.compile( rf"(?P--header\s+(?:{_HEADER_NAME_PATTERN})\s+)" rf"(?>(?:{_SCHEME})?)(?!\[REDACTED\])(?P[^\s,}}\]]+)", re.IGNORECASE, ) _SUPPORT_BEARER_RE = re.compile( # Free text has no header boundary to stop at: consume the whole value, # inner ``=`` padding included (``abcd1234==x``), not just a key-shaped prefix. # An RFC 6750/7235 challenge (``Bearer realm="api"``, ``Bearer error=...``) # is auth-params, not a token: only a known param name stays, so a key-shaped # ``="..."`` is still scrubbed (fail closed). rf"(?P\bBearer\s+|\bBasic\s+{_NOT_WORD})" r"(?!(?:realm|scope|error|error_description|error_uri)=)" rf"(?=[^\s]{{8}})" rf"[{_KEY_CHARS}=]+(?::[{_KEY_CHARS}=]+)*", re.IGNORECASE, ) def _decoded_compact_name(value: object) -> str: return "".join(ch for ch in _canonical_url_param_name(str(value or "")) if ch.isalnum()) def _redact_url_params(text: str) -> str: def _raw_sub(match: re.Match[str]) -> str: if _decoded_compact_name(match.group("key")) not in _SENSITIVE_URL_PARAM_NAMES: return match.group(0) return f"{match.group('sep')}{match.group('key')}=***" def _encoded_sub(match: re.Match[str]) -> str: if _decoded_compact_name(match.group("key")) not in _SENSITIVE_URL_PARAM_NAMES: return match.group(0) eq = match.group("eq") star = f"{eq[:-2]}2A" if eq != "=" else "*" # %2A, or %252A when double-encoded return f"{match.group('sep')}{match.group('key')}{eq}{star * 3}" # Running until stable handles a raw URL nested inside another query value # without recursive parsing. The pass cap keeps malformed input bounded. for _ in range(4): redacted = _ENCODED_URL_PARAM_RE.sub(_encoded_sub, _RAW_URL_PARAM_RE.sub(_raw_sub, text)) if redacted == text: break text = redacted return text def _redact_headers_and_argv(text: str) -> str: text = _QUOTED_HEADER_RE.sub( lambda match: f"{match.group('prefix')}{match.group('quote')}{_REDACTED}{match.group('quote')}", text, ) text = _BARE_HEADER_RE.sub(lambda match: f"{match.group('prefix')}{_REDACTED}", text) text = _HEADER_ARGV_RE.sub( lambda match: ( f"{match.group('head_quote')}--header{match.group('head_quote')}, " f"{match.group('name_quote')}{match.group('name')}{match.group('name_quote')}, " + ( f"{match.group('scheme_quote')}{match.group('scheme')}{match.group('scheme_quote')}, " if match.group("scheme") else "" ) + f"{match.group('value_quote')}{_REDACTED}{match.group('value_quote')}" ), text, ) text = _QUOTED_ARGV_RE.sub( lambda match: ( f"{match.group('flag_quote')}{match.group('flag')}{match.group('flag_quote')}" f"{match.group('sep')}{match.group('value_quote')}{_REDACTED}{match.group('value_quote')}" ), text, ) text = _ARG_EQUALS_RE.sub( lambda match: f"{match.group('flag')}{match.group('sep')}{match.group('quote')}{_REDACTED}{match.group('quote')}", text, ) text = _ARG_OPERAND_RE.sub( lambda match: f"{match.group('flag')}{match.group('sep')}{match.group('quote')}{_REDACTED}{match.group('quote')}", text, ) text = _PLAIN_HEADER_ARG_RE.sub( lambda match: f"{match.group('prefix')}{_REDACTED}", text ) return _SUPPORT_BEARER_RE.sub( lambda match: f"{match.group('prefix')}{_REDACTED}", text ) def redact_debug_support_text(value: object, *, max_chars: int | None = None) -> str: """Strictly scrub one debug/support value, then apply an optional character cap. This is deliberately stricter than ordinary model/tool output: support text is leaving the process for a human or remote diagnostics store. Any redactor failure returns the shared fail-closed sentinel rather than the original. """ text = "" if value is None else str(value) if not text: return text try: text = redact_sensitive_text(text, force=True, redact_url_credentials=True) text = _redact_url_params(text) text = _redact_headers_and_argv(text) text = _EMAIL_ADDRESS_RE.sub("[REDACTED_EMAIL]", text) except Exception: return REDACTION_UNAVAILABLE return text[:max_chars] if max_chars is not None else text