| """Regex-based secret redaction for logs and tool output. |
| |
| Applies pattern matching to mask API keys, tokens, and credentials |
| before they reach log files, verbose output, or gateway logs. |
| |
| Short tokens (< 18 chars) are fully masked. Longer tokens preserve |
| the first 6 and last 4 characters for debuggability. |
| """ |
|
|
| import logging |
| import os |
| import re |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
| _SENSITIVE_QUERY_PARAMS = frozenset({ |
| "access_token", |
| "refresh_token", |
| "id_token", |
| "token", |
| "api_key", |
| "apikey", |
| "client_secret", |
| "password", |
| "auth", |
| "jwt", |
| "session", |
| "secret", |
| "key", |
| "code", |
| "signature", |
| "x-amz-signature", |
| }) |
|
|
| |
| |
| |
| _SENSITIVE_BODY_KEYS = frozenset({ |
| "access_token", |
| "refresh_token", |
| "id_token", |
| "token", |
| "api_key", |
| "apikey", |
| "client_secret", |
| "password", |
| "auth", |
| "jwt", |
| "secret", |
| "private_key", |
| "authorization", |
| "key", |
| }) |
|
|
| |
| |
| _REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() not in ("0", "false", "no", "off") |
|
|
| |
| _PREFIX_PATTERNS = [ |
| r"sk-[A-Za-z0-9_-]{10,}", |
| r"ghp_[A-Za-z0-9]{10,}", |
| r"github_pat_[A-Za-z0-9_]{10,}", |
| r"gho_[A-Za-z0-9]{10,}", |
| r"ghu_[A-Za-z0-9]{10,}", |
| r"ghs_[A-Za-z0-9]{10,}", |
| r"ghr_[A-Za-z0-9]{10,}", |
| r"xox[baprs]-[A-Za-z0-9-]{10,}", |
| r"AIza[A-Za-z0-9_-]{30,}", |
| r"pplx-[A-Za-z0-9]{10,}", |
| r"fal_[A-Za-z0-9_-]{10,}", |
| r"fc-[A-Za-z0-9]{10,}", |
| r"bb_live_[A-Za-z0-9_-]{10,}", |
| r"gAAAA[A-Za-z0-9_=-]{20,}", |
| r"AKIA[A-Z0-9]{16}", |
| r"sk_live_[A-Za-z0-9]{10,}", |
| r"sk_test_[A-Za-z0-9]{10,}", |
| r"rk_live_[A-Za-z0-9]{10,}", |
| r"SG\.[A-Za-z0-9_-]{10,}", |
| r"hf_[A-Za-z0-9]{10,}", |
| r"r8_[A-Za-z0-9]{10,}", |
| r"npm_[A-Za-z0-9]{10,}", |
| r"pypi-[A-Za-z0-9_-]{10,}", |
| r"dop_v1_[A-Za-z0-9]{10,}", |
| r"doo_v1_[A-Za-z0-9]{10,}", |
| r"am_[A-Za-z0-9_-]{10,}", |
| r"sk_[A-Za-z0-9_]{10,}", |
| r"tvly-[A-Za-z0-9]{10,}", |
| r"exa_[A-Za-z0-9]{10,}", |
| r"gsk_[A-Za-z0-9]{10,}", |
| r"syt_[A-Za-z0-9]{10,}", |
| r"retaindb_[A-Za-z0-9]{10,}", |
| r"hsk-[A-Za-z0-9]{10,}", |
| r"mem0_[A-Za-z0-9]{10,}", |
| r"brv_[A-Za-z0-9]{10,}", |
| ] |
|
|
| |
| _SECRET_ENV_NAMES = r"(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)" |
| _ENV_ASSIGN_RE = re.compile( |
| rf"([A-Z0-9_]{{0,50}}{_SECRET_ENV_NAMES}[A-Z0-9_]{{0,50}})\s*=\s*(['\"]?)(\S+)\2", |
| ) |
|
|
| |
| _JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)" |
| _JSON_FIELD_RE = re.compile( |
| rf'("{_JSON_KEY_NAMES}")\s*:\s*"([^"]+)"', |
| re.IGNORECASE, |
| ) |
|
|
| |
| _AUTH_HEADER_RE = re.compile( |
| r"(Authorization:\s*Bearer\s+)(\S+)", |
| re.IGNORECASE, |
| ) |
|
|
| |
| |
| _TELEGRAM_RE = re.compile( |
| r"(bot)?(\d{8,}):([-A-Za-z0-9_]{30,})", |
| ) |
|
|
| |
| _PRIVATE_KEY_RE = re.compile( |
| r"-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----" |
| ) |
|
|
| |
| |
| _DB_CONNSTR_RE = re.compile( |
| r"((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:]+:)([^@]+)(@)", |
| re.IGNORECASE, |
| ) |
|
|
| |
| |
| _JWT_RE = re.compile( |
| r"eyJ[A-Za-z0-9_-]{10,}" |
| r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" |
| ) |
|
|
| |
| |
| _DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>") |
|
|
| |
| |
| _SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])") |
|
|
| |
| |
| |
| _URL_WITH_QUERY_RE = re.compile( |
| r"(https?|wss?|ftp)://" |
| r"([^\s/?#]+)" |
| r"([^\s?#]*)" |
| r"\?([^\s#]+)" |
| r"(#\S*)?", |
| ) |
|
|
| |
| |
| |
| _URL_USERINFO_RE = re.compile( |
| r"(https?|wss?|ftp)://([^/\s:@]+):([^/\s@]+)@", |
| ) |
|
|
| |
| |
| _FORM_BODY_RE = re.compile( |
| r"^[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*(?:&[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*)+$" |
| ) |
|
|
| |
| _PREFIX_RE = re.compile( |
| r"(?<![A-Za-z0-9_-])(" + "|".join(_PREFIX_PATTERNS) + r")(?![A-Za-z0-9_-])" |
| ) |
|
|
|
|
| def _mask_token(token: str) -> str: |
| """Mask a token, preserving prefix for long tokens.""" |
| if len(token) < 18: |
| return "***" |
| return f"{token[:6]}...{token[-4:]}" |
|
|
|
|
| def _redact_query_string(query: str) -> str: |
| """Redact sensitive parameter values in a URL query string. |
| |
| Handles `k=v&k=v` format. Sensitive keys (case-insensitive) have values |
| replaced with `***`. Non-sensitive keys pass through unchanged. |
| Empty or malformed pairs are preserved as-is. |
| """ |
| if not query: |
| return query |
| parts = [] |
| for pair in query.split("&"): |
| if "=" not in pair: |
| parts.append(pair) |
| continue |
| key, _, value = pair.partition("=") |
| if key.lower() in _SENSITIVE_QUERY_PARAMS: |
| parts.append(f"{key}=***") |
| else: |
| parts.append(pair) |
| return "&".join(parts) |
|
|
|
|
| def _redact_url_query_params(text: str) -> str: |
| """Scan text for URLs with query strings and redact sensitive params. |
| |
| Catches opaque tokens that don't match vendor prefix regexes, e.g. |
| `https://example.com/cb?code=ABC123&state=xyz` → `...?code=***&state=xyz`. |
| """ |
| def _sub(m: re.Match) -> str: |
| scheme = m.group(1) |
| authority = m.group(2) |
| path = m.group(3) |
| query = _redact_query_string(m.group(4)) |
| fragment = m.group(5) or "" |
| return f"{scheme}://{authority}{path}?{query}{fragment}" |
| return _URL_WITH_QUERY_RE.sub(_sub, text) |
|
|
|
|
| def _redact_url_userinfo(text: str) -> str: |
| """Strip `user:password@` from HTTP/WS/FTP URLs. |
| |
| DB protocols (postgres, mysql, mongodb, redis, amqp) are handled |
| separately by `_DB_CONNSTR_RE`. |
| """ |
| return _URL_USERINFO_RE.sub( |
| lambda m: f"{m.group(1)}://{m.group(2)}:***@", |
| text, |
| ) |
|
|
|
|
| def _redact_form_body(text: str) -> str: |
| """Redact sensitive values in a form-urlencoded body. |
| |
| Only applies when the entire input looks like a pure form body |
| (k=v&k=v with no newlines, no other text). Single-line non-form |
| text passes through unchanged. This is a conservative pass — the |
| `_redact_url_query_params` function handles embedded query strings. |
| """ |
| if not text or "\n" in text or "&" not in text: |
| return text |
| |
| if not _FORM_BODY_RE.match(text.strip()): |
| return text |
| return _redact_query_string(text.strip()) |
|
|
|
|
| def redact_sensitive_text(text: str) -> str: |
| """Apply all redaction patterns to a block of text. |
| |
| Safe to call on any string -- non-matching text passes through unchanged. |
| Disabled when security.redact_secrets is false in config.yaml. |
| """ |
| if text is None: |
| return None |
| if not isinstance(text, str): |
| text = str(text) |
| if not text: |
| return text |
| if not _REDACT_ENABLED: |
| return text |
|
|
| |
| text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text) |
|
|
| |
| def _redact_env(m): |
| name, quote, value = m.group(1), m.group(2), m.group(3) |
| return f"{name}={quote}{_mask_token(value)}{quote}" |
| text = _ENV_ASSIGN_RE.sub(_redact_env, text) |
|
|
| |
| def _redact_json(m): |
| key, value = m.group(1), m.group(2) |
| return f'{key}: "{_mask_token(value)}"' |
| text = _JSON_FIELD_RE.sub(_redact_json, text) |
|
|
| |
| text = _AUTH_HEADER_RE.sub( |
| lambda m: m.group(1) + _mask_token(m.group(2)), |
| text, |
| ) |
|
|
| |
| def _redact_telegram(m): |
| prefix = m.group(1) or "" |
| digits = m.group(2) |
| return f"{prefix}{digits}:***" |
| text = _TELEGRAM_RE.sub(_redact_telegram, text) |
|
|
| |
| text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text) |
|
|
| |
| text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text) |
|
|
| |
| text = _JWT_RE.sub(lambda m: _mask_token(m.group(0)), text) |
|
|
| |
| |
| text = _redact_url_userinfo(text) |
|
|
| |
| text = _redact_url_query_params(text) |
|
|
| |
| text = _redact_form_body(text) |
|
|
| |
| text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text) |
|
|
| |
| def _redact_phone(m): |
| phone = m.group(1) |
| if len(phone) <= 8: |
| return phone[:2] + "****" + phone[-2:] |
| return phone[:4] + "****" + phone[-4:] |
| text = _SIGNAL_PHONE_RE.sub(_redact_phone, text) |
|
|
| return text |
|
|
|
|
| class RedactingFormatter(logging.Formatter): |
| """Log formatter that redacts secrets from all log messages.""" |
|
|
| def __init__(self, fmt=None, datefmt=None, style='%', **kwargs): |
| super().__init__(fmt, datefmt, style, **kwargs) |
|
|
| def format(self, record: logging.LogRecord) -> str: |
| original = super().format(record) |
| return redact_sensitive_text(original) |
|
|