130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
"""
|
||
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
|