""" Secret redactor: masks sensitive patterns in rendered text. Scans for common secret patterns and replaces them with a French mask label (``[CLÉ API MASQUÉE]``, ``[MOT DE PASSE MASQUÉ]``, …) before content is served to the frontend. Prevents accidental exposure of API keys, tokens, and passwords in previews. Patterns detected: - Generic API keys (``api_key=…``, ``token: …`` — values of 8+ chars) - Passwords (``password=…``, ``"passwd": "…"`` — any length) - JWT tokens (eyJ... base64url) - Provider key formats: OpenAI/Anthropic/OpenRouter (``sk-``), Stripe, GitLab, Google (``AIza…`` / ``ya29.``), AWS, GitHub, Slack, SendGrid, Hugging Face, npm, Docker, Resend, Square, Atlassian, Discord, Telegram, ``Bearer …`` tokens - Private key blocks (-----BEGIN ... PRIVATE KEY-----) - Connection strings with passwords - Bare hex secrets next to a secret keyword (BUG-035) Interactive masking (feature #188): :func:`redact_with_placeholders` returns the text with every mask replaced by an opaque placeholder plus the list of ``(label, secret)`` entries; :func:`restore_masks` turns the placeholders back into plain labels (public shares, PDF exports, AI context) or into clickable ```` badges (authenticated app preview) so a click copies the real value to the clipboard. """ from __future__ import annotations import html as _html import logging import re logger = logging.getLogger("obsigate.redactor") # --- Patterns --- # Each entry is ``(pattern, replacement, secret_group)``: # * ``replacement``: a literal label, a ``\\1``-style template, or a # callable receiving the match and returning the visible label; # * ``secret_group``: index of the group holding the value that a click # copies to the clipboard (feature #188). _PATTERNS: list[tuple[re.Pattern[str], object, int]] = [ # Private key blocks (re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]', 0), # JWT tokens (base64url encoded, starts with eyJ) (re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]', 0), # Connection strings with passwords (re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]', 0), # Passwords — any length, bare or quoted (``password=…``, # ``"passwd": "…"``). The left side may carry a qualifier # (``db_password``, ``DATABASE.PASSWORD``, ``user_pwd``); a *bare* # ``PWD=`` (shell working directory) must NOT match, hence ``pwd`` # only in its qualified branch. (re.compile( r'(?i)((?:[A-Za-z0-9_.-]*(?:password|passwd|passphrase|mot\s+de\s+passe)' r'|[A-Za-z0-9_.-]+pwd)["\']?\s*[:=]\s*["\']?)([^\s"\',;]{4,})'), r'\1[MOT DE PASSE MASQUÉ]', 2), # Generic API key assignments: api_key=…, token=…, secret=… — values # of 8+ characters (short enough to catch real keys, long enough to # skip plain words). (re.compile(r'(?i)([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|auth[_-]?token)["\']?\s*[:=]\s*["\']?)([^\s\'"]{8,})'), lambda m: f'{m.group(1)}[MASQUÉ]' if ("=" in m.group(0) or ":" in m.group(0)) else '[MASQUÉ]', 2), # GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) (re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]', 0), # AWS access keys (re.compile(r'(?:AKIA|ASIA)[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]', 0), # Provider key formats (feature #188) — one alternation covering the # large majority of token shapes in the wild. (re.compile( r'(? tuple[str, int]: """Redact 40–64 char hex strings only when a secret keyword is nearby. Git/SHA/checksum contexts are left untouched (BUG-035). Args: text: Text to scan. mask: ``mask(original, label) -> str`` replacement builder. Returns: (redacted_text, redaction_count) tuple. """ count = 0 def _replace(match: re.Match) -> str: nonlocal count window = text[max(0, match.start() - _HEX_CONTEXT_WINDOW):match.start()] if _HASH_CONTEXT_RE.search(window): return match.group(0) if _SECRET_CONTEXT_RE.search(window): count += 1 return mask(match.group(0), '[HEX_KEY MASQUÉ]') return match.group(0) return _HEX_RE.sub(_replace, text), count def _redact(text: str, mask) -> tuple[str, int]: """Apply every pattern; ``mask(original, label) -> str`` builds the replacement (plain label, or placeholder for the interactive mode).""" count = 0 result = text for pattern, replacement, secret_group in _PATTERNS: def _sub(match: re.Match, replacement=replacement, secret_group=secret_group) -> str: if callable(replacement): label = replacement(match) else: label = match.expand(str(replacement)) return mask(match.group(secret_group), label) new_result, n = pattern.subn(_sub, result) count += n result = new_result result, hex_count = _redact_bare_hex_secrets(result, mask) return result, count + hex_count def _plain_mask(original: str, label: str) -> str: """Plain masking: only the visible label survives.""" return label def redact(text: str) -> tuple: """Redact sensitive patterns from text. Args: text: The raw text content to scan. Returns: (redacted_text, redaction_count) tuple. """ result, count = _redact(text, _plain_mask) if count > 0: logger.info(f"Redacted {count} secret(s) from content") return result, count def redact_with_placeholders(text: str, file_path: str = "") -> tuple[str, list[tuple[str, str]]]: """Redact *text*, replacing every mask with an opaque placeholder. Used by the markdown rendering pipeline: placeholders survive the markdown → HTML conversion (fenced code blocks included, where a literal ```` would be shown as text), then :func:`restore_masks` turns them back into labels or clickable badges. Args: text: The raw text content to scan. file_path: Optional file path for logging context. Returns: (text_with_placeholders, entries) where *entries* is the list of ``(label, secret)`` tuples referenced by the placeholders, in order of appearance. """ entries: list[tuple[str, str]] = [] def mask(original: str, label: str) -> str: entries.append((label, original)) return f"{_PLACEHOLDER_OPEN}{len(entries) - 1}{_PLACEHOLDER_CLOSE}" result, count = _redact(text, mask) if count > 0: logger.warning(f"Redacted {count} potential secret(s) from {file_path or ''}") return result, entries def restore_masks( text: str, entries: list[tuple[str, str]], *, click_to_copy: bool = False, ) -> str: """Turn placeholders produced by :func:`redact_with_placeholders` back into visible masks. Args: text: Rendered HTML still containing placeholders. entries: The ``(label, secret)`` list returned alongside. click_to_copy: When True (authenticated app preview), each mask becomes ``label`` so a click copies the real value. When False (public shares, PDF exports), only the plain label is restored — the secret never reaches the page. Returns: The text with every placeholder replaced. """ if not entries: return text def _sub(match: re.Match) -> str: idx = int(match.group(1)) if idx >= len(entries): return "" label, original = entries[idx] label_esc = _html.escape(str(label), quote=False) if not click_to_copy: return label_esc return ( '' + label_esc + "" ) return _PLACEHOLDER_RE.sub(_sub, text) def redact_file_content(content: str, file_path: str = "") -> str: """Redact a file's content for preview rendering. Args: content: Raw file content. file_path: Optional file path for logging context. Returns: Redacted content string. """ redacted, count = redact(content) if count > 0: logger.warning(f"Redacted {count} potential secret(s) from {file_path or ''}") return redacted