Files
awoooi/scripts/ops/awooop-rls-manual-script-audit.py
Your Name 8c4dc7a5a8
Some checks failed
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m5s
CD Pipeline / build-and-deploy (push) Failing after 10m6s
CD Pipeline / post-deploy-checks (push) Has been skipped
chore(rls): 新增 manual script gate 與 canary wave1
2026-05-12 20:23:27 +08:00

192 lines
5.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Static review inventory for manual DB scripts before AwoooP RLS enablement.
This is intentionally not a runtime gate. It separates:
- BLOCKED: secrets or hardcoded connection strings in scripts.
- REVIEW: manual/operator scripts that need a migration role or explicit review.
- PASS: scripts that already set app.project_id or use get_db_context().
"""
from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCAN_ROOTS = (
ROOT / "apps/api/scripts",
ROOT / "scripts",
)
SKIP_PATHS = {
"scripts/ops/awooop-rls-access-audit.py",
"scripts/ops/awooop-rls-manual-script-audit.py",
}
SCRIPT_SUFFIXES = {".py", ".sh", ".sql"}
HARDCODED_DB_URL_RE = re.compile(
r"postgresql(?:\+asyncpg)?://[^:'\"\s/]+:[^@'\"\s]+@",
re.IGNORECASE,
)
DIRECT_DB_RE = re.compile(
r"\b(asyncpg\.connect|create_async_engine|psql\b|pg_dump\b|DATABASE_URL|PGPASSWORD)\b"
)
PROJECT_CONTEXT_RE = re.compile(
r"set_config\('app\.project_id'|SET\s+(?:LOCAL\s+)?app\.project_id|get_db_context\(",
re.IGNORECASE,
)
MIGRATION_HINT_RE = re.compile(
r"\b(ALTER\s+TABLE|CREATE\s+TABLE|CREATE\s+INDEX|CREATE\s+EXTENSION|DROP\s+POLICY|ENABLE\s+ROW\s+LEVEL\s+SECURITY)\b",
re.IGNORECASE,
)
TENANT_TABLES = (
"incidents",
"knowledge_entries",
"playbooks",
"audit_logs",
"budget_ledger",
"approval_records",
"notification_outcomes",
"rag_chunks",
"playbook_embeddings",
"awooop_projects",
"awooop_contract_revisions",
"awooop_run_state",
"awooop_mcp_tool_registry",
"awooop_mcp_grants",
"awooop_mcp_credential_refs",
"awooop_mcp_gateway_audit",
"awooop_conversation_event",
"awooop_outbound_message",
)
OPERATOR_REVIEW_PATHS = {
"apps/api/scripts/awooop_phase1_batch1_backfill.py":
"RLS/project_id bootstrap backfill; run only with migration/operator role.",
"apps/api/scripts/run_migration.py":
"DDL migration script; run only with migration/operator role.",
"scripts/ops/awooop_rls_preflight.py":
"Read-only preflight that probes app.project_id inside the API pod.",
"scripts/ops/awooop-rls-role-bootstrap.sql":
"Role bootstrap SQL; must be reviewed and run by postgres/CREATEROLE operator.",
"scripts/sync_dev_db.py":
"Dev DB schema sync; use DEV_DATABASE_URL and run only against non-production DB.",
}
@dataclass(frozen=True)
class Finding:
severity: str
path: str
reason: str
def rel(path: Path) -> str:
return path.relative_to(ROOT).as_posix()
def iter_script_paths() -> list[Path]:
paths: list[Path] = []
for root in SCAN_ROOTS:
if not root.exists():
continue
for path in root.rglob("*"):
if not path.is_file() or path.suffix not in SCRIPT_SUFFIXES:
continue
if rel(path) in SKIP_PATHS:
continue
paths.append(path)
return sorted(set(paths))
def classify(path: Path) -> list[Finding]:
text = path.read_text(encoding="utf-8", errors="replace")
path_rel = rel(path)
findings: list[Finding] = []
hardcoded_db_url = False
for line in text.splitlines():
if "<password>" in line or ":password@" in line:
continue
if HARDCODED_DB_URL_RE.search(line):
hardcoded_db_url = True
break
if hardcoded_db_url:
findings.append(
Finding(
"BLOCKED",
path_rel,
"hardcoded PostgreSQL URL with inline credentials; move to environment/secret store.",
)
)
if not DIRECT_DB_RE.search(text):
return findings
touches_tenant_table = any(re.search(rf"\b{re.escape(table)}\b", text) for table in TENANT_TABLES)
has_project_context = PROJECT_CONTEXT_RE.search(text) is not None
if path_rel in OPERATOR_REVIEW_PATHS:
findings.append(Finding("REVIEW", path_rel, OPERATOR_REVIEW_PATHS[path_rel]))
elif touches_tenant_table and not has_project_context:
findings.append(
Finding(
"REVIEW",
path_rel,
"direct DB access touches tenant tables without app.project_id; add project context or use operator role.",
)
)
elif touches_tenant_table and has_project_context:
findings.append(Finding("PASS", path_rel, "tenant table access sets app.project_id or uses get_db_context."))
elif MIGRATION_HINT_RE.search(text):
findings.append(Finding("REVIEW", path_rel, "DDL/operator script; verify role and maintenance window before use."))
else:
findings.append(Finding("PASS", path_rel, "no tenant table access detected in direct DB usage."))
return findings
def main() -> int:
parser = argparse.ArgumentParser(description="Audit manual scripts for AwoooP RLS readiness.")
parser.add_argument("--show-pass", action="store_true", help="Print PASS findings.")
parser.add_argument("--strict-review", action="store_true", help="Exit non-zero when REVIEW findings exist.")
args = parser.parse_args()
findings: list[Finding] = []
for path in iter_script_paths():
findings.extend(classify(path))
blocked = [f for f in findings if f.severity == "BLOCKED"]
review = [f for f in findings if f.severity == "REVIEW"]
passed = [f for f in findings if f.severity == "PASS"]
print(
"AwoooP RLS manual script audit: "
f"BLOCKED={len(blocked)} REVIEW={len(review)} PASS={len(passed)}"
)
for item in blocked + review:
print(f"{item.severity} {item.path}")
print(f" reason: {item.reason}")
if args.show_pass:
for item in passed:
print(f"{item.severity} {item.path}")
print(f" reason: {item.reason}")
if blocked:
return 2
if review and args.strict_review:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())