164 lines
5.4 KiB
Python
Executable File
164 lines
5.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Static RLS access-path audit for AWOOOI API runtime code.
|
|
|
|
The goal is narrow: find production runtime DB access that may bypass
|
|
get_db()/get_db_context() and therefore miss SET LOCAL app.project_id.
|
|
It is intentionally conservative and read-only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SRC_ROOT = ROOT / "apps/api/src"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
severity: str
|
|
path: Path
|
|
line: int
|
|
rule: str
|
|
text: str
|
|
reason: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AllowRule:
|
|
path: str
|
|
rule: str
|
|
text_pattern: re.Pattern[str]
|
|
reason: str
|
|
|
|
|
|
RULES: list[tuple[str, re.Pattern[str]]] = [
|
|
("session_factory", re.compile(r"\bget_session_factory\s*\(")),
|
|
("create_async_engine", re.compile(r"\bcreate_async_engine\s*\(")),
|
|
("asyncpg_connect", re.compile(r"\basyncpg\.connect\s*\(")),
|
|
("settings_database_url", re.compile(r"\bsettings\.DATABASE_URL\b")),
|
|
("env_database_url", re.compile(r"os\.environ(?:\.get)?\([\"']DATABASE_URL[\"']|os\.environ\[[\"']DATABASE_URL[\"']")),
|
|
]
|
|
|
|
|
|
ALLOW_RULES: tuple[AllowRule, ...] = (
|
|
AllowRule(
|
|
"apps/api/src/db/base.py",
|
|
"settings_database_url",
|
|
re.compile(r"\bdatabase_url\s*=\s*settings\.DATABASE_URL\b"),
|
|
"DB engine owner reads DATABASE_URL and sets RLS context in get_db/get_db_context.",
|
|
),
|
|
AllowRule(
|
|
"apps/api/src/db/base.py",
|
|
"create_async_engine",
|
|
re.compile(r"\b_engine\s*=\s*create_async_engine\("),
|
|
"DB engine owner creates the shared async engine.",
|
|
),
|
|
AllowRule(
|
|
"apps/api/src/db/base.py",
|
|
"session_factory",
|
|
re.compile(r"\bdef\s+get_session_factory\("),
|
|
"Factory definition, not a call-site bypass.",
|
|
),
|
|
AllowRule(
|
|
"apps/api/src/db/base.py",
|
|
"session_factory",
|
|
re.compile(r"\bfactory\s*=\s*get_session_factory\(\)"),
|
|
"get_db/get_db_context wrap factory and set app.project_id.",
|
|
),
|
|
AllowRule(
|
|
"apps/api/src/routes/health.py",
|
|
"settings_database_url",
|
|
re.compile(r"\bdb_url\s*=\s*settings\.DATABASE_URL\.replace\("),
|
|
"Health check parses DATABASE_URL for SELECT 1 only.",
|
|
),
|
|
AllowRule(
|
|
"apps/api/src/routes/health.py",
|
|
"asyncpg_connect",
|
|
re.compile(r"\basyncpg\.connect\(db_url\)"),
|
|
"Health check raw asyncpg SELECT 1 does not read tenant tables.",
|
|
),
|
|
AllowRule(
|
|
"apps/api/src/main.py",
|
|
"settings_database_url",
|
|
re.compile(r"\bdb_url\s*=\s*settings\.DATABASE_URL\b"),
|
|
"Startup logs sanitized DB host suffix after init_db.",
|
|
),
|
|
AllowRule(
|
|
"apps/api/src/workers/signal_worker.py",
|
|
"settings_database_url",
|
|
re.compile(r"\bdatabase_url=settings\.DATABASE_URL\.split\(\"@\"\)\[-1\]"),
|
|
"Structured log uses redacted DATABASE_URL suffix only.",
|
|
),
|
|
AllowRule(
|
|
"apps/api/src/services/incident_approval_service.py",
|
|
"session_factory",
|
|
re.compile(r"\bsession_factory=get_session_factory\(\),"),
|
|
"IncidentApprovalService injects UnitOfWork; UnitOfWork now sets app.project_id.",
|
|
),
|
|
)
|
|
|
|
|
|
def classify(path: Path, rule: str, line_text: str) -> tuple[str, str]:
|
|
rel = path.relative_to(ROOT).as_posix()
|
|
for allow in ALLOW_RULES:
|
|
if allow.path == rel and allow.rule == rule and allow.text_pattern.search(line_text):
|
|
return "ALLOW", allow.reason
|
|
return "BLOCKED", "Runtime DB access must set app.project_id through get_db/get_db_context or UnitOfWork."
|
|
|
|
|
|
def scan() -> list[Finding]:
|
|
findings: list[Finding] = []
|
|
for path in sorted(SRC_ROOT.rglob("*.py")):
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except UnicodeDecodeError:
|
|
lines = path.read_text(errors="replace").splitlines()
|
|
|
|
for idx, line in enumerate(lines, start=1):
|
|
for rule, pattern in RULES:
|
|
if not pattern.search(line):
|
|
continue
|
|
severity, reason = classify(path, rule, line)
|
|
findings.append(
|
|
Finding(
|
|
severity=severity,
|
|
path=path.relative_to(ROOT),
|
|
line=idx,
|
|
rule=rule,
|
|
text=line.strip(),
|
|
reason=reason,
|
|
)
|
|
)
|
|
return findings
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Audit API runtime DB access paths for RLS readiness.")
|
|
parser.add_argument("--show-allowed", action="store_true", help="Print allowed findings too.")
|
|
args = parser.parse_args()
|
|
|
|
findings = scan()
|
|
blocked = [item for item in findings if item.severity == "BLOCKED"]
|
|
allowed = [item for item in findings if item.severity == "ALLOW"]
|
|
|
|
print(f"AwoooP RLS access audit: BLOCKED={len(blocked)} ALLOW={len(allowed)}")
|
|
for item in blocked:
|
|
print(f"{item.severity} {item.path}:{item.line} [{item.rule}] {item.text}")
|
|
print(f" reason: {item.reason}")
|
|
|
|
if args.show_allowed:
|
|
for item in allowed:
|
|
print(f"{item.severity} {item.path}:{item.line} [{item.rule}] {item.text}")
|
|
print(f" reason: {item.reason}")
|
|
|
|
return 2 if blocked else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|