Files
ObsiGate/backend/secret_redactor.py
T
bruno 2e2a33cef3
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 3m41s
CI / build (push) Successful in 59s
CI / e2e (push) Successful in 11m8s
fix: corrige 6 bugs mineurs (BUG-035 a BUG-040)
2026-09-17 20:05:08 -04:00

130 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 '<unknown>'}")
return redacted