#!/usr/bin/env python3 """Static audit for fragile SQLAlchemy text() patterns in ClientFlow. This is intentionally conservative: it does not prove a bug, it flags code that should be reviewed before deploy because PostgreSQL may be unable to infer bind parameter types in JSONB/NULL/ANY contexts. """ from __future__ import annotations import json import re import sys from pathlib import Path from typing import Dict, List ROOT = Path(__file__).resolve().parents[1] SCAN_DIRS = [ROOT / "app", ROOT / "scripts"] PATTERNS = [ ("jsonb_build_object_with_bind", re.compile(r"jsonb_build_object\s*\([^)]*:[a-zA-Z_][a-zA-Z0-9_]", re.I | re.S), "Prefer metadata || CAST(:metadata_patch AS jsonb) or cast each bind explicitly."), ("bind_is_null_without_cast", re.compile(r"\([^)]*:[a-zA-Z_][a-zA-Z0-9_]*\s+IS\s+NULL", re.I | re.S), "Use CAST(:param AS text/uuid/...) IS NULL when parameter can be null."), ("any_bind_array", re.compile(r"\bANY\s*\(\s*:[a-zA-Z_][a-zA-Z0-9_]*\s*\)", re.I), "Prefer expanding bindparams or cast array type explicitly."), ("metadata_jsonb_concat_without_cast", re.compile(r"metadata\s*=\s*COALESCE\([^\n]+\)\s*\|\|\s*(?!CAST\(:metadata_patch\s+AS\s+JSONB\))", re.I), "Prefer COALESCE(metadata,'{}'::jsonb) || CAST(:metadata_patch AS jsonb)."), ] ALLOW_HINTS = [ "CAST(:", "CAST(%(", "CAST(:metadata_patch AS JSONB)", "CAST(:metadata_patch AS jsonb)", ] def iter_files(): for base in SCAN_DIRS: if not base.exists(): continue for path in base.rglob("*.py"): if "__pycache__" in path.parts: continue yield path def line_no(text: str, pos: int) -> int: return text.count("\n", 0, pos) + 1 def excerpt(text: str, pos: int, size: int = 240) -> str: start = max(0, pos - 80) end = min(len(text), pos + size) return re.sub(r"\s+", " ", text[start:end]).strip() def main() -> int: findings: List[Dict[str, str]] = [] for path in iter_files(): text = path.read_text(errors="ignore") for code, pattern, advice in PATTERNS: for match in pattern.finditer(text): sample = excerpt(text, match.start()) # If the local expression already has explicit casts in the same # small sample, lower severity but still report as review. severity = "WARN" if any(h in sample for h in ALLOW_HINTS) else "REVIEW" findings.append({ "severity": severity, "code": code, "file": str(path.relative_to(ROOT)), "line": str(line_no(text, match.start())), "excerpt": sample, "advice": advice, }) print("ClientFlow SQL safety static audit") print("=" * 80) print(f"findings: {len(findings)}") for item in findings[:80]: print(f"{item['severity']} | {item['code']} | {item['file']}:{item['line']}") print(f" {item['excerpt']}") print(f" advice: {item['advice']}") if len(findings) > 80: print(f"... {len(findings) - 80} more findings not printed") out_dir = ROOT / "audit_reports" out_dir.mkdir(exist_ok=True) out = out_dir / "sql_safety_findings.json" out.write_text(json.dumps(findings, ensure_ascii=False, indent=2), encoding="utf-8") print(f"JSON: {out}") # Static findings are warnings by design, not deployment blockers. return 1 if findings else 0 if __name__ == "__main__": raise SystemExit(main())