7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
318 lines
11 KiB
Python
Executable File
318 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Auto-fixer for exception variable leaks and exc_info in production logs.
|
|
|
|
Handles two patterns:
|
|
1. Exception variables in log messages (f"...{e}", "...%s", e)
|
|
→ Removes the exception variable from the message
|
|
2. exc_info=True on warning/error/critical logs
|
|
→ Removes the exc_info keyword argument
|
|
|
|
Note: this hook is part of the reason we do NOT enforce ``raise X from e``
|
|
universally (see ADR-0003). Preserving exception chains via ``from e``
|
|
would re-expose the details this hook strips from logs.
|
|
|
|
Run standalone:
|
|
python .pre-commit-hooks/fix-exception-logging.py src/
|
|
|
|
Or via the pre-commit hook:
|
|
python .pre-commit-hooks/check-sensitive-logging.py --fix <files>
|
|
"""
|
|
|
|
import ast
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Optional, Set, Tuple
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AST-based violation finder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _ViolationFinder(ast.NodeVisitor):
|
|
"""Find exception-var and exc_info violations with precise locations."""
|
|
|
|
def __init__(self) -> None:
|
|
self.exc_var_violations: List[Tuple[int, int, str, Set[str]]] = []
|
|
# (start_line, end_line, level, except_var_names)
|
|
self.exc_info_violations: List[Tuple[int, int]] = []
|
|
# (keyword_line, keyword_end_line)
|
|
self._except_var_stack: List[Optional[str]] = []
|
|
|
|
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
self._except_var_stack.append(node.name)
|
|
self.generic_visit(node)
|
|
self._except_var_stack.pop()
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
if self._is_logger_call(node):
|
|
level = self._get_level(node)
|
|
if level and level not in {"exception", "debug"}:
|
|
self._check_exc_var(node, level)
|
|
if level in {"warning", "error", "critical"}:
|
|
self._check_exc_info(node)
|
|
self.generic_visit(node)
|
|
|
|
@staticmethod
|
|
def _is_logger_call(node: ast.Call) -> bool:
|
|
if isinstance(node.func, ast.Attribute):
|
|
if node.func.attr in {
|
|
"debug",
|
|
"info",
|
|
"warning",
|
|
"error",
|
|
"critical",
|
|
"exception",
|
|
}:
|
|
if isinstance(node.func.value, ast.Name):
|
|
return node.func.value.id == "logger"
|
|
if isinstance(node.func.value, ast.Attribute):
|
|
return node.func.value.attr == "logger"
|
|
return False
|
|
|
|
@staticmethod
|
|
def _get_level(node: ast.Call) -> Optional[str]:
|
|
if isinstance(node.func, ast.Attribute):
|
|
return node.func.attr
|
|
return None
|
|
|
|
def _check_exc_var(self, node: ast.Call, level: str) -> None:
|
|
except_vars = {v for v in self._except_var_stack if v is not None}
|
|
if not except_vars:
|
|
return
|
|
for arg in node.args:
|
|
if self._references(arg, except_vars):
|
|
self.exc_var_violations.append(
|
|
(
|
|
node.lineno,
|
|
node.end_lineno or node.lineno,
|
|
level,
|
|
except_vars,
|
|
)
|
|
)
|
|
return
|
|
|
|
def _check_exc_info(self, node: ast.Call) -> None:
|
|
for kw in node.keywords:
|
|
if (
|
|
kw.arg == "exc_info"
|
|
and isinstance(kw.value, ast.Constant)
|
|
and kw.value.value is True
|
|
):
|
|
self.exc_info_violations.append(
|
|
(kw.lineno, kw.end_lineno or kw.lineno)
|
|
)
|
|
|
|
def _references(self, expr: ast.AST, names: Set[str]) -> bool:
|
|
if isinstance(expr, ast.Name):
|
|
return expr.id in names
|
|
if isinstance(expr, ast.JoinedStr):
|
|
return any(
|
|
isinstance(v, ast.FormattedValue)
|
|
and self._references(v.value, names)
|
|
for v in expr.values
|
|
)
|
|
if isinstance(expr, ast.Call):
|
|
return any(self._references(a, names) for a in expr.args)
|
|
if isinstance(expr, ast.BinOp):
|
|
return self._references(expr.left, names) or self._references(
|
|
expr.right, names
|
|
)
|
|
if isinstance(expr, ast.Tuple):
|
|
return any(self._references(el, names) for el in expr.elts)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Text-based fixers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Patterns for exception variable references in f-strings
|
|
# Matches: {e}, {e!s}, {e!r}, {exc}, {err}, {error}, {ex}
|
|
_FSTR_EXC_RE = re.compile(
|
|
r"""
|
|
[,;:\s\-–—]* # leading separator/whitespace before {e}
|
|
\{ # opening brace
|
|
(?:e|ex|exc|err|error) # common exception variable names
|
|
(?:![sra])? # optional conversion (!s, !r, !a)
|
|
(?::[^}]*)? # optional format spec
|
|
\} # closing brace
|
|
[,;:\s\-–—.]* # trailing separator/punctuation after {e}
|
|
""",
|
|
re.VERBOSE,
|
|
)
|
|
|
|
# Matches trailing , e) or , exc) etc. as positional arg in logger call
|
|
_PCTARG_EXC_RE = re.compile(r",\s*(?:e|ex|exc|err|error)\s*(?=\))")
|
|
|
|
# Matches %s (or %r, %d) placeholders that correspond to the exception arg
|
|
_PCT_PLACEHOLDER_RE = re.compile(r"[,;:\s\-–—]*%[srd][,;:\s\-–—.]*")
|
|
|
|
|
|
def _fix_exc_var_in_line(line: str, except_vars: Set[str]) -> str:
|
|
"""Remove exception variable references from a single line."""
|
|
original = line
|
|
|
|
# Build dynamic regex for the specific variable names in this scope
|
|
var_names = "|".join(re.escape(v) for v in except_vars)
|
|
|
|
# Fix f-string interpolation: {e}, {exc}, etc.
|
|
fstr_re = re.compile(
|
|
r"[,;:\s\-–—]*"
|
|
r"\{"
|
|
rf"(?:{var_names})"
|
|
r"(?:![sra])?"
|
|
r"(?::[^}]*)?"
|
|
r"\}"
|
|
r"[,;:\s\-–—.]*",
|
|
)
|
|
line = fstr_re.sub("", line)
|
|
|
|
# If we removed the only content of an f-string, convert to regular string
|
|
# f"" → ""
|
|
line = re.sub(r'\bf("")', r"\1", line)
|
|
line = re.sub(r"\bf('')", r"\1", line)
|
|
|
|
# Fix %-style positional arg: , e) → )
|
|
pctarg_re = re.compile(rf",\s*(?:{var_names})\s*(?=\))")
|
|
line = pctarg_re.sub("", line)
|
|
|
|
# If we removed a positional arg, also clean up the %s in the format string
|
|
if line != original and "%s" in line:
|
|
# Only remove trailing %s (the one that matched the exception var)
|
|
# This is conservative — only handles the simple single-%s case
|
|
line = re.sub(r"[,;:\s\-–—]*%s[,;:\s\-–—.]*(?=\")", "", line, count=1)
|
|
|
|
return line
|
|
|
|
|
|
def _fix_exc_info_in_line(line: str) -> str:
|
|
"""Remove exc_info=True from a line."""
|
|
# Handle: , exc_info=True) or , exc_info=True, or (exc_info=True)
|
|
line = re.sub(r",\s*exc_info=True", "", line)
|
|
line = re.sub(r"exc_info=True,\s*", "", line)
|
|
line = re.sub(r"exc_info=True", "", line)
|
|
return line
|
|
|
|
|
|
def _remove_unused_as_clause(lines: List[str], except_vars: Set[str]) -> None:
|
|
"""Remove 'as e' from except clauses when e is no longer used."""
|
|
for i, line in enumerate(lines):
|
|
for var in except_vars:
|
|
# Match: except SomeError as e: or except Exception as e:
|
|
pattern = re.compile(
|
|
rf"(\s*except\s+\S+(?:\s*,\s*\S+)*)\s+as\s+{re.escape(var)}\s*:"
|
|
)
|
|
m = pattern.match(line)
|
|
if m:
|
|
# Check if the variable is still used anywhere in nearby lines
|
|
# (simple heuristic: check next 20 lines in the except block)
|
|
still_used = False
|
|
for j in range(i + 1, min(i + 20, len(lines))):
|
|
if re.search(rf"\b{re.escape(var)}\b", lines[j]):
|
|
still_used = True
|
|
break
|
|
# Stop at next except/def/class/return at same or lesser indent
|
|
if re.match(r"\S", lines[j]):
|
|
break
|
|
if not still_used:
|
|
lines[i] = pattern.sub(r"\1:", line)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File-level fix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def fix_file(filepath: str) -> Tuple[bool, int]:
|
|
"""Fix a file in-place. Returns (changed, fix_count)."""
|
|
path = Path(filepath)
|
|
try:
|
|
source = path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return False, 0
|
|
|
|
try:
|
|
tree = ast.parse(source, filename=filepath)
|
|
except SyntaxError:
|
|
return False, 0
|
|
|
|
finder = _ViolationFinder()
|
|
finder.visit(tree)
|
|
|
|
if not finder.exc_var_violations and not finder.exc_info_violations:
|
|
return False, 0
|
|
|
|
lines = source.splitlines(keepends=True)
|
|
fix_count = 0
|
|
|
|
# Collect all except variable names used across violations in this file
|
|
all_except_vars: Set[str] = set()
|
|
|
|
# Fix exception variable violations
|
|
for start_line, end_line, _level, except_vars in finder.exc_var_violations:
|
|
all_except_vars |= except_vars
|
|
for i in range(start_line - 1, min(end_line, len(lines))):
|
|
fixed = _fix_exc_var_in_line(lines[i], except_vars)
|
|
if fixed != lines[i]:
|
|
lines[i] = fixed
|
|
fix_count += 1
|
|
|
|
# Fix exc_info violations
|
|
for start_line, end_line in finder.exc_info_violations:
|
|
for i in range(start_line - 1, min(end_line, len(lines))):
|
|
fixed = _fix_exc_info_in_line(lines[i])
|
|
if fixed != lines[i]:
|
|
lines[i] = fixed
|
|
fix_count += 1
|
|
|
|
# Clean up unused 'as e' clauses
|
|
if all_except_vars:
|
|
_remove_unused_as_clause(lines, all_except_vars)
|
|
|
|
if fix_count > 0:
|
|
path.write_text("".join(lines), encoding="utf-8")
|
|
return True, fix_count
|
|
|
|
return False, 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main() -> int:
|
|
paths: List[str] = []
|
|
for arg in sys.argv[1:]:
|
|
p = Path(arg)
|
|
if p.is_dir():
|
|
paths.extend(str(f) for f in p.rglob("*.py"))
|
|
elif p.suffix == ".py":
|
|
paths.append(str(p))
|
|
|
|
if not paths:
|
|
print("Usage: fix-exception-logging.py <file_or_dir> ...")
|
|
return 1
|
|
|
|
total_files = 0
|
|
total_fixes = 0
|
|
for filepath in sorted(paths):
|
|
changed, fixes = fix_file(filepath)
|
|
if changed:
|
|
total_files += 1
|
|
total_fixes += fixes
|
|
print(f" Fixed {filepath} ({fixes} changes)")
|
|
|
|
if total_files:
|
|
print(f"\nFixed {total_fixes} issues in {total_files} files")
|
|
else:
|
|
print("No issues to fix")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|