Spaces:
Sleeping
Sleeping
File size: 8,411 Bytes
2e818da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | """Attribute and log redaction for the observability boundary.
Every value that leaves the process boundary -- span attribute, metric
dimension, structured log field, exported observation -- passes through here
first. The global constraint from the plan preamble is absolute:
"Never export prompts, full responses, PDF text, chunks, annotation
contents, filesystem paths, URL query strings, base64, credentials,
API headers, or raw document IDs."
Two complementary defences:
* key-based -> a key naming forbidden content (``prompt``, ``chunk``,
``api_key``, ``document_id`` ...) has its *value* replaced with the
``[redacted]`` marker regardless of what the value is;
* value-based -> a string value that *looks* like a secret, base64 blob,
filesystem path, or URL query string is redacted/stripped even under an
innocuous key, and oversized strings are truncated.
This module emits nothing; it only transforms values.
"""
from __future__ import annotations
import re
from typing import Any
REDACTED = "[redacted]"
# Oversized-string bounds. Attribute strings are metadata, not content, so a
# generous-but-bounded cap is enough to keep an accidental blob out of export.
MAX_ATTR_STRING = 256
MAX_EXCEPTION_CHARS = 500
_TRUNCATION_SUFFIX = "[truncated]"
SAFE_EXCEPTION_MESSAGE = "Exception details redacted."
# --- Forbidden keys -------------------------------------------------
# Word-level tokens: a key is split on ``. _ -`` and matched token-by-token so
# that "context_input_chars" (token {context,input,chars}) is NOT caught by the
# "text" token, while "prompt_text" (token {prompt,text}) is. Never add generic
# tokens like "key"/"id"/"document" here -- they collide with legitimate
# measures (document_diversity, requested_top_k). Those are handled by the
# narrower phrase list below plus value-based detection.
_FORBIDDEN_KEY_TOKENS: frozenset[str] = frozenset(
{
"prompt",
"prompts",
"response",
"responses",
"completion",
"completions",
"chunk",
"chunks",
"content",
"contents",
"text",
"body",
"message",
"messages",
"pdf",
"annotation",
"annotations",
"note",
"notes",
"token",
"tokens",
"cookie",
"cookies",
"header",
"headers",
"auth",
"password",
"passwd",
"secret",
"secrets",
"credential",
"credentials",
"bearer",
}
)
# Substring phrases matched against the key with all separators removed, for
# compound names that survive token-splitting (``api_key`` -> "apikey",
# ``document_id`` -> "documentid"). Each phrase is chosen so it cannot appear
# inside a legitimate measure name.
_FORBIDDEN_KEY_PHRASES: tuple[str, ...] = (
"apikey",
"password",
"secret",
"authorization",
"credential",
"bearer",
"accesstoken",
"documentid",
"docid",
)
_TOKEN_SPLIT = re.compile(r"[._\-\s]+")
def is_forbidden_key(key: str) -> bool:
"""True if ``key`` names content/credentials that must never be exported."""
lowered = str(key).lower()
tokens = {tok for tok in _TOKEN_SPLIT.split(lowered) if tok}
if tokens & _FORBIDDEN_KEY_TOKENS:
return True
collapsed = re.sub(r"[^a-z0-9]", "", lowered)
return any(phrase in collapsed for phrase in _FORBIDDEN_KEY_PHRASES)
# --- Value-based string detection -------------------------------------------------
# A URL: strip its query string and fragment, keep scheme/host/path.
_URL_RE = re.compile(r"^[a-z][a-z0-9+.\-]*://", re.IGNORECASE)
# Filesystem paths -- redacted whole. Unix absolute path of >=2 segments, a
# Windows drive/UNC path, or an explicit user-home reference.
_UNIX_PATH_RE = re.compile(r"(?:^|[\s'\"(=])(?:/[\w.\-]+){2,}")
_WIN_PATH_RE = re.compile(r"[A-Za-z]:[\\/]|\\\\[\w.\-]+\\")
# Exception messages often prefix a path with prose, so the broad detector
# above is insufficient for replacing the entire Windows path in-place.
_WIN_EXCEPTION_PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/]|\\\\)[^\s'\"()=]*")
# Secret / opaque-blob shapes.
_DATA_URI_RE = re.compile(r"^data:[^;,]*;base64,", re.IGNORECASE)
_BASE64_RE = re.compile(r"^[A-Za-z0-9+/]{40,}={0,2}$")
_OPAQUE_TOKEN_RE = re.compile(r"^[A-Za-z0-9_\-]{32,}$") # hashes, raw doc IDs, keys
_BEARER_RE = re.compile(r"(?i)\bbearer\b")
_SK_KEY_RE = re.compile(r"sk-[A-Za-z0-9]{12,}")
_INLINE_SECRET_RE = re.compile(r"(?i)(api[_\-]?key|secret|password|token)\s*[=:]\s*\S+")
def _looks_secret_or_blob(value: str) -> bool:
if _DATA_URI_RE.match(value):
return True
if _BASE64_RE.match(value):
return True
if _OPAQUE_TOKEN_RE.match(value):
return True
if _BEARER_RE.search(value):
return True
if _SK_KEY_RE.search(value):
return True
if _INLINE_SECRET_RE.search(value):
return True
return False
def _looks_like_path(value: str) -> bool:
if _WIN_PATH_RE.search(value):
return True
if _UNIX_PATH_RE.search(value):
return True
return False
def _sanitize_string(value: str) -> str:
# URLs first: a query string like "?token=secret" would otherwise trip the
# inline-secret detector, and "/v1/traces" would trip the path detector.
if _URL_RE.match(value):
stripped = value.split("?", 1)[0].split("#", 1)[0]
return _truncate(stripped)
if _looks_secret_or_blob(value):
return REDACTED
if _looks_like_path(value):
return REDACTED
return _truncate(value)
def _truncate(value: str, limit: int = MAX_ATTR_STRING) -> str:
if len(value) <= limit:
return value
return value[:limit] + _TRUNCATION_SUFFIX
def sanitize_attribute_value(value: Any) -> Any:
"""Sanitize one attribute value.
* ``bool``/``int``/``float`` -> unchanged (a metric-safe scalar).
* ``str`` -> credential/base64/path redaction, URL query stripping,
oversize truncation.
* ``list``/``tuple`` of scalars/strings -> each element sanitized.
* ``None`` and mappings -> ``None`` (dropped by :func:`sanitize_attributes`,
since a nested mapping can smuggle forbidden content).
"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value
if isinstance(value, str):
return _sanitize_string(value)
if isinstance(value, (list, tuple)):
cleaned = [sanitize_attribute_value(item) for item in value]
return [item for item in cleaned if item is not None]
return None
def sanitize_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
"""Return a redacted copy of ``attributes`` (input is never mutated).
Forbidden keys keep their name but get the ``[redacted]`` marker as value;
allowed keys get value-based sanitization; values that sanitize to ``None``
(``None`` itself, nested mappings) are dropped entirely.
"""
out: dict[str, Any] = {}
for key, value in attributes.items():
skey = str(key)
if is_forbidden_key(skey):
out[skey] = REDACTED
continue
sanitized = sanitize_attribute_value(value)
if sanitized is None:
continue
out[skey] = sanitized
return out
def sanitize_exception_text(text: str) -> str:
"""Strip filesystem paths from an exception message and bound its length."""
# Remove Windows and Unix absolute paths, leaving a marker in their place.
cleaned = _WIN_EXCEPTION_PATH_RE.sub(REDACTED, text)
cleaned = re.sub(r"(?:/[\w.\-]+){2,}", REDACTED, cleaned)
cleaned = cleaned.strip()
if _looks_secret_or_blob(cleaned):
return REDACTED
return _truncate(cleaned, MAX_EXCEPTION_CHARS)
def sanitize_exception(exc: BaseException) -> tuple[str, str]:
"""Return safe exception metadata without inspecting ``str(exc)``.
Exception text is untrusted product data: it can contain prompts, paper
text, annotation contents, or even raise while rendering itself. Keep only
the stable exception class and a fixed summary suitable for export.
"""
try:
exception_type = type(exc).__name__[:120]
except BaseException: # Defensive: this helper must never replace product flow.
exception_type = "Exception"
return exception_type, SAFE_EXCEPTION_MESSAGE
|