""" Secret redactor: masks sensitive patterns in rendered text. Scans for common secret patterns and replaces them with [MASQUÉ] before content is served to the frontend. Prevents accidental exposure of API keys, tokens, and passwords in previews. Patterns detected: - Generic API keys (long alphanumeric strings with key/secret/token prefix) - JWT tokens (eyJ... base64url) - AWS-style keys (AKIA..., sk-..., etc.) - Private key blocks (-----BEGIN ... PRIVATE KEY-----) - Connection strings with passwords """ import logging import re logger = logging.getLogger("obsigate.redactor") # --- Patterns --- # Order matters: more specific patterns first _PATTERNS = [ # 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]'), # 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É]'), # Connection strings with passwords (re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'), # Generic API key patterns: key=... or token=... or secret=... (re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE), lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'), # Prefixed API keys (sk-..., pk-..., rk-...) (re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'), # AWS access keys (re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'), # GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) (re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'), ] # BUG-035: bare 40–64 char hex strings used to be redacted unconditionally, # which mangled legitimate git commit SHAs, checksums and hashes in notes. # They are now only redacted when a secret-ish keyword sits in the immediate # context; hash/commit keywords explicitly exempt them. _HEX_RE = re.compile(r'\b[a-fA-F0-9]{40,64}\b') _SECRET_CONTEXT_RE = re.compile( r'(?i)\b(?:secret|token|key|apikey|api[_-]?key|password|passwd|auth|bearer|' r'credential|x-api-key|x-auth-token)\b' ) _HASH_CONTEXT_RE = re.compile( r'(?i)\b(?:commit|sha\d*|hash|md5|blob|git|checksum|digest|integrity|' r'revision|rev|etag|fingerprint)\b' ) #: How far before the hex string a keyword may appear to count as context. _HEX_CONTEXT_WINDOW = 60 def _redact_bare_hex_secrets(text: str) -> tuple: """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. 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 '[HEX_KEY MASQUÉ]' return match.group(0) return _HEX_RE.sub(_replace, text), count def redact(text: str) -> tuple: """Redact sensitive patterns from text. Args: text: The raw text content to scan. Returns: (redacted_text, redaction_count) tuple. """ count = 0 result = text for pattern, replacement in _PATTERNS: if callable(replacement): new_result, n = pattern.subn(replacement, result) else: new_result, n = pattern.subn(str(replacement), result) count += n result = new_result result, hex_count = _redact_bare_hex_secrets(result) count += hex_count if count > 0: logger.info(f"Redacted {count} secret(s) from content") return result, count 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