Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
91
scripts/audit_sql_safety.py
Executable file
91
scripts/audit_sql_safety.py
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user