707 lines
32 KiB
Python
707 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ClientFlow deep system audit (read-only)
|
||
|
||
Purpose:
|
||
Validate end-to-end operational correctness, not only technical health.
|
||
This script is intentionally READ-ONLY: it performs no UPDATE/INSERT/DELETE.
|
||
|
||
Usage from project root:
|
||
cd /mnt/ssd/home/plx/clientflow_backend
|
||
source .venv/bin/activate
|
||
export PYTHONPATH=/mnt/ssd/home/plx/clientflow_backend
|
||
set -a; source .env; set +a
|
||
python scripts/audit_deep_system.py --window-hours 72 --sample-limit 25
|
||
|
||
Outputs:
|
||
audit_reports/deep_audit_YYYYMMDD_HHMMSS.txt
|
||
audit_reports/deep_audit_YYYYMMDD_HHMMSS.json
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from dataclasses import dataclass, field, asdict
|
||
from datetime import date, datetime, timezone
|
||
from decimal import Decimal
|
||
from uuid import UUID
|
||
from pathlib import Path
|
||
from typing import Any, Iterable
|
||
|
||
try:
|
||
from dotenv import load_dotenv
|
||
except Exception: # pragma: no cover
|
||
load_dotenv = None
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1] if len(Path(__file__).resolve().parents) > 1 else Path.cwd()
|
||
if load_dotenv:
|
||
load_dotenv(PROJECT_ROOT / ".env")
|
||
|
||
try:
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||
from app.db import engine
|
||
except Exception as exc: # pragma: no cover
|
||
print(f"FATAL: não foi possível importar app.db/SQLAlchemy: {exc}")
|
||
print("Confirma: cd projeto, source .venv/bin/activate, export PYTHONPATH=<root>, source .env")
|
||
sys.exit(2)
|
||
|
||
|
||
SEVERITY_ORDER = {"FAIL": 3, "WARN": 2, "OK": 1, "INFO": 0}
|
||
EXECUTABLE_EXTENSIONS = [".exe", ".msi", ".bat", ".cmd", ".ps1", ".scr", ".vbs", ".js", ".jar"]
|
||
REMOTE_ACCESS_KEYWORDS = ["screenconnect", "connectwise", "anydesk", "teamviewer", "rustdesk", "dwservice", "remoteutilities"]
|
||
DANGEROUS_FINANCIAL_ACTIONS = {"CONFIRM_PAYMENT", "SEND_INVOICE", "SEND_PROFORMA", "SEND_QUOTE"}
|
||
FINANCIAL_ACTIONS = {"CONFIRM_PAYMENT", "SEND_INVOICE", "SEND_PROFORMA"}
|
||
MARKETING_ACTIONS = {"REMOVE_FROM_LIST"}
|
||
REVIEW_ACTIONS = {"REVIEW_MANUALLY"}
|
||
|
||
|
||
@dataclass
|
||
class Finding:
|
||
severity: str
|
||
area: str
|
||
title: str
|
||
detail: str = ""
|
||
count: int | None = None
|
||
samples: list[dict[str, Any]] = field(default_factory=list)
|
||
|
||
|
||
class Audit:
|
||
def __init__(self, *, sample_limit: int):
|
||
self.sample_limit = sample_limit
|
||
self.findings: list[Finding] = []
|
||
self.schema: dict[str, set[str]] = {}
|
||
self.table_cache: set[str] = set()
|
||
self.started_at = datetime.now(timezone.utc).isoformat()
|
||
|
||
def add(self, severity: str, area: str, title: str, detail: str = "", count: int | None = None, samples: list[dict[str, Any]] | None = None):
|
||
if severity not in SEVERITY_ORDER:
|
||
raise ValueError(f"invalid severity {severity}")
|
||
self.findings.append(Finding(severity, area, title, detail, count, samples or []))
|
||
|
||
def summary(self) -> dict[str, int]:
|
||
c = Counter(f.severity for f in self.findings)
|
||
return {k: c.get(k, 0) for k in ["OK", "WARN", "FAIL", "INFO"]}
|
||
|
||
def exit_code(self) -> int:
|
||
s = self.summary()
|
||
if s["FAIL"]:
|
||
return 2
|
||
if s["WARN"]:
|
||
return 1
|
||
return 0
|
||
|
||
|
||
def json_safe(value: Any) -> Any:
|
||
if isinstance(value, (datetime, date)):
|
||
return value.isoformat()
|
||
if isinstance(value, Decimal):
|
||
# Keep integer-like decimals as int; otherwise float for compact reports.
|
||
return int(value) if value == value.to_integral_value() else float(value)
|
||
if isinstance(value, UUID):
|
||
return str(value)
|
||
if isinstance(value, (list, tuple, set)):
|
||
return [json_safe(v) for v in value]
|
||
if isinstance(value, dict):
|
||
return {str(k): json_safe(v) for k, v in value.items()}
|
||
return value
|
||
|
||
|
||
def rows_to_dicts(rows: Iterable[Any], limit: int | None = None) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for i, r in enumerate(rows):
|
||
if limit is not None and i >= limit:
|
||
break
|
||
out.append({k: json_safe(v) for k, v in dict(r).items()})
|
||
return out
|
||
|
||
|
||
def q(conn, sql: str, params: dict[str, Any] | None = None):
|
||
return conn.execute(text(sql), params or {}).mappings().all()
|
||
|
||
|
||
def s(conn, sql: str, params: dict[str, Any] | None = None):
|
||
return conn.execute(text(sql), params or {}).scalar()
|
||
|
||
|
||
def table_exists(conn, table: str) -> bool:
|
||
return bool(s(conn, """
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM information_schema.tables
|
||
WHERE table_schema = 'public' AND table_name = :table
|
||
)
|
||
""", {"table": table}))
|
||
|
||
|
||
def columns(conn, table: str) -> set[str]:
|
||
return {r["column_name"] for r in q(conn, """
|
||
SELECT column_name FROM information_schema.columns
|
||
WHERE table_schema = 'public' AND table_name = :table
|
||
""", {"table": table})}
|
||
|
||
|
||
def require(audit: Audit, conn, tables: list[str]):
|
||
missing = [t for t in tables if not table_exists(conn, t)]
|
||
if missing:
|
||
audit.add("FAIL", "schema", "Tabelas essenciais em falta", ", ".join(missing), len(missing))
|
||
return False
|
||
return True
|
||
|
||
|
||
def safe_query(audit: Audit, conn, area: str, title: str, sql: str, params: dict[str, Any] | None = None):
|
||
try:
|
||
return q(conn, sql, params)
|
||
except OperationalError as exc:
|
||
msg = str(exc)
|
||
if "DeadlockDetected" in msg or "deadlock detected" in msg or "LockNotAvailable" in msg:
|
||
audit.add("WARN", area, f"{title}: lock/deadlock temporário", msg[:800])
|
||
return []
|
||
audit.add("FAIL", area, f"{title}: erro DB", msg[:1200])
|
||
return []
|
||
except ProgrammingError as exc:
|
||
audit.add("FAIL", area, f"{title}: erro SQL/schema", str(exc)[:1200])
|
||
return []
|
||
|
||
|
||
def inspect_schema(audit: Audit, conn):
|
||
tables = [r["table_name"] for r in q(conn, """
|
||
SELECT table_name FROM information_schema.tables
|
||
WHERE table_schema = 'public'
|
||
ORDER BY table_name
|
||
""")]
|
||
audit.table_cache = set(tables)
|
||
for t in tables:
|
||
audit.schema[t] = columns(conn, t)
|
||
audit.add("INFO", "schema", "Tabelas encontradas", count=len(tables), samples=[{"tables": tables[:80]}])
|
||
|
||
|
||
def check_base_health(audit: Audit, conn, window_hours: int):
|
||
ok_tables = require(audit, conn, ["tasks", "raw_events", "opportunities"])
|
||
if ok_tables:
|
||
audit.add("OK", "base", "Tabelas essenciais existem")
|
||
for table in ["tasks", "raw_events", "opportunities", "customers", "integration_outbox"]:
|
||
if table in audit.table_cache:
|
||
try:
|
||
count = s(conn, f"SELECT COUNT(*) FROM {table}")
|
||
audit.add("INFO", "base", f"Contagem {table}", count=int(count))
|
||
except Exception as exc:
|
||
audit.add("WARN", "base", f"Não foi possível contar {table}", str(exc)[:500])
|
||
|
||
|
||
def check_raw_events(audit: Audit, conn, window_hours: int):
|
||
if "raw_events" not in audit.table_cache:
|
||
return
|
||
cols = audit.schema["raw_events"]
|
||
needed = {"processed", "ignored", "created_at", "payload"}
|
||
if not needed.issubset(cols):
|
||
audit.add("WARN", "raw_events", "raw_events sem colunas esperadas", detail=str(sorted(needed - cols)))
|
||
return
|
||
|
||
pending = safe_query(audit, conn, "raw_events", "incoming pendentes", """
|
||
SELECT id::text AS raw_event_id, created_at, source_system, source_event_id,
|
||
payload #>> '{message_type}' AS message_type,
|
||
payload #>> '{conversation,id}' AS conversation_id,
|
||
payload #>> '{sender,email}' AS sender_email,
|
||
LEFT(COALESCE(payload #>> '{content}', payload::text), 500) AS content_excerpt
|
||
FROM raw_events
|
||
WHERE COALESCE(processed, false) = false
|
||
AND COALESCE(ignored, false) = false
|
||
AND created_at >= now() - (:window_hours || ' hours')::interval
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
""", {"window_hours": window_hours, "limit": audit.sample_limit})
|
||
if pending:
|
||
audit.add("FAIL", "raw_events", "raw_events pendentes por processar", count=len(pending), samples=rows_to_dicts(pending))
|
||
else:
|
||
audit.add("OK", "raw_events", "Sem raw_events pendentes recentes")
|
||
|
||
errors = safe_query(audit, conn, "raw_events", "erros recentes", """
|
||
SELECT id::text AS raw_event_id, created_at, source_system, source_event_id,
|
||
payload #>> '{message_type}' AS message_type,
|
||
LEFT(COALESCE(processing_error, ''), 700) AS processing_error
|
||
FROM raw_events
|
||
WHERE processing_error IS NOT NULL
|
||
AND created_at >= now() - (:window_hours || ' hours')::interval
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
""", {"window_hours": window_hours, "limit": audit.sample_limit})
|
||
real_errors = [r for r in errors if "processing_exception" in (r["processing_error"] or "")]
|
||
if real_errors:
|
||
audit.add("FAIL", "raw_events", "processing_exception em raw_events recentes", count=len(real_errors), samples=rows_to_dicts(real_errors))
|
||
elif errors:
|
||
audit.add("INFO", "raw_events", "Apenas erros/ignored benignos recentes", count=len(errors), samples=rows_to_dicts(errors))
|
||
else:
|
||
audit.add("OK", "raw_events", "Sem erros recentes em raw_events")
|
||
|
||
dupes = safe_query(audit, conn, "raw_events", "source_event_id duplicado", """
|
||
SELECT source_system, source_event_id, COUNT(*) AS total,
|
||
MIN(created_at) AS first_seen, MAX(created_at) AS last_seen
|
||
FROM raw_events
|
||
WHERE source_event_id IS NOT NULL
|
||
AND created_at >= now() - (:window_hours || ' hours')::interval
|
||
GROUP BY source_system, source_event_id
|
||
HAVING COUNT(*) > 1
|
||
ORDER BY total DESC, last_seen DESC
|
||
LIMIT :limit
|
||
""", {"window_hours": window_hours, "limit": audit.sample_limit})
|
||
if dupes:
|
||
audit.add("WARN", "raw_events", "source_event_id duplicados recentes", count=len(dupes), samples=rows_to_dicts(dupes))
|
||
else:
|
||
audit.add("OK", "raw_events", "Sem duplicados recentes por source_event_id")
|
||
|
||
|
||
def check_security_content(audit: Audit, conn, window_hours: int):
|
||
if "raw_events" not in audit.table_cache:
|
||
return
|
||
risky_sql = """
|
||
SELECT
|
||
r.id::text AS raw_event_id,
|
||
r.created_at,
|
||
r.source_event_id,
|
||
r.payload #>> '{message_type}' AS message_type,
|
||
r.payload #>> '{conversation,id}' AS conversation_id,
|
||
r.payload #>> '{sender,name}' AS sender_name,
|
||
r.payload #>> '{sender,email}' AS sender_email,
|
||
LEFT(COALESCE(r.payload #>> '{content}', r.payload::text), 1200) AS content_excerpt,
|
||
t.id::text AS task_id,
|
||
t.route,
|
||
t.action_code,
|
||
t.status,
|
||
t.priority,
|
||
t.metadata AS task_metadata
|
||
FROM raw_events r
|
||
LEFT JOIN tasks t ON t.raw_event_id = r.id
|
||
WHERE r.created_at >= now() - (:window_hours || ' hours')::interval
|
||
AND (
|
||
r.payload::text ILIKE '%%.exe%%'
|
||
OR r.payload::text ILIKE '%%.msi%%'
|
||
OR r.payload::text ILIKE '%%.bat%%'
|
||
OR r.payload::text ILIKE '%%.cmd%%'
|
||
OR r.payload::text ILIKE '%%.ps1%%'
|
||
OR r.payload::text ILIKE '%%.scr%%'
|
||
OR r.payload::text ILIKE '%%screenconnect%%'
|
||
OR r.payload::text ILIKE '%%connectwise%%'
|
||
OR r.payload::text ILIKE '%%anydesk%%'
|
||
OR r.payload::text ILIKE '%%teamviewer%%'
|
||
)
|
||
ORDER BY r.created_at DESC
|
||
LIMIT :limit
|
||
"""
|
||
risky = safe_query(audit, conn, "security", "conteúdo com indicadores de risco", risky_sql, {"window_hours": window_hours * 14, "limit": audit.sample_limit})
|
||
bad_financial = [r for r in risky if r.get("status") == "pending" and r.get("action_code") in DANGEROUS_FINANCIAL_ACTIONS and not (r.get("task_metadata") or {}).get("security_risk")]
|
||
if bad_financial:
|
||
audit.add("FAIL", "security", "Indicador de malware em task perigosa não bloqueada", count=len(bad_financial), samples=rows_to_dicts(bad_financial))
|
||
elif risky:
|
||
audit.add("WARN", "security", "Conteúdo com indicadores de risco encontrado", "Validar que está em REVIEW_MANUALLY/security_risk", len(risky), rows_to_dicts(risky))
|
||
else:
|
||
audit.add("OK", "security", "Sem indicadores de links executáveis/acesso remoto em eventos recentes")
|
||
|
||
if "tasks" in audit.table_cache and "metadata" in audit.schema["tasks"]:
|
||
security_tasks = safe_query(audit, conn, "security", "tasks security_risk pendentes", """
|
||
SELECT id::text AS task_id, created_at, route, action_code, priority, status,
|
||
conversation_id, note, metadata
|
||
FROM tasks
|
||
WHERE status = 'pending'
|
||
AND COALESCE((metadata ->> 'security_risk')::boolean, false) = true
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
""", {"limit": audit.sample_limit})
|
||
if security_tasks:
|
||
audit.add("WARN", "security", "Tasks de segurança pendentes", count=len(security_tasks), samples=rows_to_dicts(security_tasks))
|
||
else:
|
||
audit.add("OK", "security", "Sem tasks security_risk pendentes")
|
||
|
||
|
||
def check_tasks(audit: Audit, conn, window_hours: int):
|
||
if "tasks" not in audit.table_cache:
|
||
return
|
||
cols = audit.schema["tasks"]
|
||
pending_summary = safe_query(audit, conn, "tasks", "backlog por route/action", """
|
||
SELECT route, action_code, priority, status, COUNT(*) AS total,
|
||
MIN(created_at) AS oldest, MAX(created_at) AS newest
|
||
FROM tasks
|
||
WHERE status = 'pending'
|
||
GROUP BY route, action_code, priority, status
|
||
ORDER BY total DESC, oldest ASC
|
||
""")
|
||
audit.add("INFO", "tasks", "Backlog pendente por rota/action", count=sum(int(r["total"]) for r in pending_summary), samples=rows_to_dicts(pending_summary, audit.sample_limit))
|
||
|
||
old = safe_query(audit, conn, "tasks", "tasks antigas", """
|
||
SELECT t.id::text AS task_id, t.created_at, t.route, t.action_code, t.action, t.note,
|
||
t.priority, t.conversation_id, t.opportunity_id::text AS opportunity_id,
|
||
o.title AS opportunity_title, o.customer_name AS opportunity_customer_name,
|
||
o.customer_email AS opportunity_customer_email
|
||
FROM tasks t
|
||
LEFT JOIN opportunities o ON o.id = t.opportunity_id
|
||
WHERE t.status = 'pending'
|
||
AND t.created_at < now() - interval '14 days'
|
||
ORDER BY t.created_at ASC
|
||
LIMIT :limit
|
||
""", {"limit": audit.sample_limit})
|
||
if old:
|
||
audit.add("WARN", "tasks", "Tasks pendentes antigas > 14 dias", count=len(old), samples=rows_to_dicts(old))
|
||
else:
|
||
audit.add("OK", "tasks", "Sem tasks pendentes com mais de 14 dias")
|
||
|
||
review_recent = safe_query(audit, conn, "tasks", "REVIEW_MANUALLY recentes", """
|
||
SELECT t.id::text AS task_id, t.created_at, t.route, t.action_code, t.priority,
|
||
t.conversation_id, t.note, t.metadata,
|
||
o.title AS opportunity_title, o.customer_name AS opportunity_customer_name
|
||
FROM tasks t
|
||
LEFT JOIN opportunities o ON o.id = t.opportunity_id
|
||
WHERE t.status = 'pending'
|
||
AND t.action_code = 'REVIEW_MANUALLY'
|
||
AND t.created_at >= now() - (:window_hours || ' hours')::interval
|
||
ORDER BY t.created_at DESC
|
||
LIMIT :limit
|
||
""", {"window_hours": window_hours, "limit": audit.sample_limit})
|
||
if review_recent:
|
||
audit.add("WARN", "tasks", "REVIEW_MANUALLY pendentes recentes", count=len(review_recent), samples=rows_to_dicts(review_recent))
|
||
else:
|
||
audit.add("OK", "tasks", "Sem REVIEW_MANUALLY recente pendente")
|
||
|
||
dupes = safe_query(audit, conn, "tasks", "tasks duplicadas por conversa/action", """
|
||
SELECT conversation_id, action_code, status, COUNT(*) AS total,
|
||
MIN(created_at) AS first_created, MAX(created_at) AS last_created,
|
||
array_agg(id::text ORDER BY created_at DESC) AS task_ids
|
||
FROM tasks
|
||
WHERE status = 'pending'
|
||
AND conversation_id IS NOT NULL
|
||
GROUP BY conversation_id, action_code, status
|
||
HAVING COUNT(*) > 1
|
||
ORDER BY total DESC, last_created DESC
|
||
LIMIT :limit
|
||
""", {"limit": audit.sample_limit})
|
||
if dupes:
|
||
audit.add("WARN", "tasks", "Possíveis tasks duplicadas pendentes por conversa/action", count=len(dupes), samples=rows_to_dicts(dupes))
|
||
else:
|
||
audit.add("OK", "tasks", "Sem duplicados pendentes simples por conversa/action")
|
||
|
||
|
||
def check_identity(audit: Audit, conn, window_hours: int):
|
||
if not require(audit, conn, ["tasks", "opportunities"]):
|
||
return
|
||
opp_cols = audit.schema["opportunities"]
|
||
if "local_customer_id" not in opp_cols:
|
||
audit.add("WARN", "identity", "opportunities sem local_customer_id")
|
||
return
|
||
|
||
# Use customers table if available; otherwise only detect metadata/security detached.
|
||
has_customers = "customers" in audit.table_cache and {"id", "name"}.issubset(audit.schema.get("customers", set()))
|
||
if has_customers:
|
||
email_col = "email" if "email" in audit.schema["customers"] else "NULL::text"
|
||
name_expr = "c.name"
|
||
email_expr = f"c.{email_col}" if email_col != "NULL::text" else "NULL::text"
|
||
suspected = safe_query(audit, conn, "identity", "cliente fiscal possivelmente inseguro", f"""
|
||
WITH data AS (
|
||
SELECT
|
||
t.id::text AS task_id,
|
||
t.action_code,
|
||
t.route,
|
||
t.status,
|
||
t.conversation_id,
|
||
o.id::text AS opportunity_id,
|
||
o.title,
|
||
o.customer_name,
|
||
o.customer_email,
|
||
o.local_customer_id::text AS local_customer_id,
|
||
{name_expr} AS fiscal_name,
|
||
{email_expr} AS fiscal_email,
|
||
lower(regexp_replace(coalesce(o.title, '') || ' ' || coalesce(o.customer_name, ''), '[^[:alnum:]À-ÿ]+', ' ', 'g')) AS process_text,
|
||
lower(coalesce({name_expr}, '')) AS fiscal_text,
|
||
lower(split_part(coalesce(o.customer_email, ''), '@', 2)) AS opp_domain,
|
||
lower(split_part(coalesce({email_expr}, ''), '@', 2)) AS fiscal_domain
|
||
FROM tasks t
|
||
JOIN opportunities o ON o.id = t.opportunity_id
|
||
JOIN customers c ON c.id = o.local_customer_id
|
||
WHERE t.status = 'pending'
|
||
AND o.local_customer_id IS NOT NULL
|
||
)
|
||
SELECT * FROM data
|
||
WHERE
|
||
-- suspicious: fiscal email domain does not match opportunity email domain
|
||
(fiscal_domain IS NOT NULL AND fiscal_domain <> '' AND opp_domain IS NOT NULL AND opp_domain <> '' AND fiscal_domain <> opp_domain)
|
||
AND NOT (
|
||
-- accept if process text contains a strong fiscal token >= 5 chars
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM regexp_split_to_table(fiscal_text, '\\s+') tok
|
||
WHERE length(tok) >= 5
|
||
AND process_text LIKE '%%' || tok || '%%'
|
||
)
|
||
)
|
||
ORDER BY title
|
||
LIMIT :limit
|
||
""", {"limit": audit.sample_limit})
|
||
if suspected:
|
||
audit.add("FAIL", "identity", "Cliente fiscal inseguro em task pendente", count=len(suspected), samples=rows_to_dicts(suspected))
|
||
else:
|
||
audit.add("OK", "identity", "Sem suspeitas fortes de cliente fiscal inseguro")
|
||
else:
|
||
audit.add("INFO", "identity", "Tabela customers indisponível para comparação nominal detalhada")
|
||
|
||
repair_marked = safe_query(audit, conn, "identity", "identity repair/detached ainda pendente", """
|
||
SELECT t.id::text AS task_id, t.route, t.action_code, t.status, t.priority,
|
||
t.conversation_id, o.id::text AS opportunity_id, o.title,
|
||
o.customer_name, o.customer_email, o.local_customer_id::text AS local_customer_id,
|
||
o.metadata AS opportunity_metadata, t.metadata AS task_metadata
|
||
FROM tasks t
|
||
LEFT JOIN opportunities o ON o.id = t.opportunity_id
|
||
WHERE t.status = 'pending'
|
||
AND (
|
||
t.metadata::text ILIKE '%%identity_detached%%'
|
||
OR t.metadata::text ILIKE '%%identity_repair%%'
|
||
OR o.metadata::text ILIKE '%%identity_detached%%'
|
||
OR o.metadata::text ILIKE '%%identity_repair%%'
|
||
OR COALESCE((t.metadata ->> 'requires_fiscal_customer_review')::boolean, false) = true
|
||
)
|
||
ORDER BY t.created_at DESC
|
||
LIMIT :limit
|
||
""", {"limit": audit.sample_limit})
|
||
if repair_marked:
|
||
audit.add("WARN", "identity", "Tasks pendentes marcadas para revisão de identidade/fiscal", count=len(repair_marked), samples=rows_to_dicts(repair_marked))
|
||
else:
|
||
audit.add("OK", "identity", "Sem tasks pendentes marcadas por identity repair")
|
||
|
||
|
||
def check_financial_safety(audit: Audit, conn, window_hours: int):
|
||
if not require(audit, conn, ["tasks", "opportunities"]):
|
||
return
|
||
no_customer = safe_query(audit, conn, "financial", "financeiras sem cliente fiscal", """
|
||
SELECT t.id::text AS task_id, t.created_at, t.route, t.action_code, t.priority,
|
||
t.conversation_id, t.action, t.note,
|
||
o.id::text AS opportunity_id, o.title, o.customer_name, o.customer_email,
|
||
o.local_customer_id::text AS local_customer_id, t.metadata
|
||
FROM tasks t
|
||
LEFT JOIN opportunities o ON o.id = t.opportunity_id
|
||
WHERE t.status = 'pending'
|
||
AND t.action_code IN ('CONFIRM_PAYMENT', 'SEND_INVOICE', 'SEND_PROFORMA')
|
||
AND (o.local_customer_id IS NULL OR t.opportunity_id IS NULL)
|
||
ORDER BY t.created_at DESC
|
||
LIMIT :limit
|
||
""", {"limit": audit.sample_limit})
|
||
if no_customer:
|
||
audit.add("FAIL", "financial", "Tasks financeiras pendentes sem cliente fiscal confirmado", count=len(no_customer), samples=rows_to_dicts(no_customer))
|
||
else:
|
||
audit.add("OK", "financial", "Sem tasks financeiras pendentes sem cliente fiscal")
|
||
|
||
security_fin = safe_query(audit, conn, "financial", "financeiras com security_risk", """
|
||
SELECT id::text AS task_id, created_at, route, action_code, priority, status,
|
||
conversation_id, note, metadata
|
||
FROM tasks
|
||
WHERE status = 'pending'
|
||
AND action_code IN ('CONFIRM_PAYMENT', 'SEND_INVOICE', 'SEND_PROFORMA')
|
||
AND COALESCE((metadata ->> 'security_risk')::boolean, false) = true
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
""", {"limit": audit.sample_limit})
|
||
if security_fin:
|
||
audit.add("FAIL", "financial", "Task financeira com security_risk ainda não bloqueada", count=len(security_fin), samples=rows_to_dicts(security_fin))
|
||
else:
|
||
audit.add("OK", "financial", "Nenhuma task financeira pendente marcada como security_risk")
|
||
|
||
|
||
def check_llm_quality(audit: Audit, conn, window_hours: int):
|
||
if "tasks" not in audit.table_cache or "metadata" not in audit.schema["tasks"]:
|
||
return
|
||
summary = safe_query(audit, conn, "llm", "qualidade LLM por decision_source/action", """
|
||
SELECT
|
||
COALESCE(metadata ->> 'decision_source', 'unknown') AS decision_source,
|
||
action_code,
|
||
route,
|
||
COUNT(*) AS total,
|
||
COUNT(*) FILTER (WHERE COALESCE((metadata ->> 'needs_review')::boolean, false) = true) AS needs_review,
|
||
COUNT(*) FILTER (WHERE COALESCE((metadata ->> 'llm_confidence')::numeric, 0) = 0) AS zero_confidence,
|
||
ROUND(AVG(NULLIF((metadata ->> 'llm_confidence')::numeric, 0)), 3) AS avg_nonzero_confidence
|
||
FROM tasks
|
||
WHERE created_at >= now() - (:window_hours || ' hours')::interval
|
||
GROUP BY decision_source, action_code, route
|
||
ORDER BY total DESC
|
||
LIMIT :limit
|
||
""", {"window_hours": window_hours, "limit": audit.sample_limit})
|
||
audit.add("INFO", "llm", "Resumo de classificação LLM recente", count=sum(int(r["total"]) for r in summary), samples=rows_to_dicts(summary))
|
||
|
||
invalid_json = safe_query(audit, conn, "llm", "LLM sem JSON/parse inválido", """
|
||
SELECT id::text AS task_id, created_at, route, action_code, priority, conversation_id,
|
||
note, metadata
|
||
FROM tasks
|
||
WHERE created_at >= now() - (:window_hours || ' hours')::interval
|
||
AND (
|
||
note ILIKE '%%sem JSON%%'
|
||
OR note ILIKE '%%fallback-parse%%'
|
||
OR note ILIKE '%%Resposta LLM inválida%%'
|
||
)
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
""", {"window_hours": window_hours, "limit": audit.sample_limit})
|
||
if invalid_json:
|
||
audit.add("WARN", "llm", "Falhas recentes de formato/parse LLM", count=len(invalid_json), samples=rows_to_dicts(invalid_json))
|
||
else:
|
||
audit.add("OK", "llm", "Sem falhas recentes de formato/parse LLM")
|
||
|
||
high_conf_review = safe_query(audit, conn, "llm", "Alta confiança mas revisão/segurança", """
|
||
SELECT id::text AS task_id, created_at, route, action_code, priority, conversation_id,
|
||
note, metadata
|
||
FROM tasks
|
||
WHERE created_at >= now() - (:window_hours || ' hours')::interval
|
||
AND COALESCE((metadata ->> 'llm_confidence')::numeric, 0) >= 0.90
|
||
AND (action_code = 'REVIEW_MANUALLY' OR COALESCE((metadata ->> 'security_risk')::boolean, false) = true)
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
""", {"window_hours": window_hours, "limit": audit.sample_limit})
|
||
if high_conf_review:
|
||
audit.add("WARN", "llm", "Alta confiança mas caiu em revisão/segurança", "Pode indicar regra de segurança ou erro da IA", len(high_conf_review), rows_to_dicts(high_conf_review))
|
||
|
||
|
||
def check_opportunities(audit: Audit, conn, window_hours: int):
|
||
if "opportunities" not in audit.table_cache:
|
||
return
|
||
cols = audit.schema["opportunities"]
|
||
audit.add("INFO", "opportunities", "Colunas opportunities", samples=[{"columns": sorted(cols)}])
|
||
|
||
dupes = safe_query(audit, conn, "opportunities", "possíveis duplicados por email", """
|
||
SELECT lower(customer_email) AS customer_email, COUNT(*) AS total,
|
||
array_agg(id::text ORDER BY created_at DESC) AS opportunity_ids,
|
||
array_agg(title ORDER BY created_at DESC) AS titles,
|
||
MAX(created_at) AS newest
|
||
FROM opportunities
|
||
WHERE customer_email IS NOT NULL AND customer_email <> ''
|
||
GROUP BY lower(customer_email)
|
||
HAVING COUNT(*) > 1
|
||
ORDER BY total DESC, newest DESC
|
||
LIMIT :limit
|
||
""", {"limit": audit.sample_limit})
|
||
if dupes:
|
||
audit.add("WARN", "opportunities", "Possíveis oportunidades duplicadas por email", count=len(dupes), samples=rows_to_dicts(dupes))
|
||
else:
|
||
audit.add("OK", "opportunities", "Sem duplicados simples por customer_email")
|
||
|
||
no_task = safe_query(audit, conn, "opportunities", "oportunidades abertas sem task pendente", """
|
||
SELECT o.id::text AS opportunity_id, o.created_at, o.title, o.customer_name, o.customer_email,
|
||
o.local_customer_id::text AS local_customer_id, o.status, o.stage, o.metadata
|
||
FROM opportunities o
|
||
LEFT JOIN tasks t ON t.opportunity_id = o.id AND t.status = 'pending'
|
||
WHERE t.id IS NULL
|
||
AND o.created_at >= now() - (:window_hours || ' hours')::interval
|
||
AND COALESCE(o.status, '') NOT IN ('closed', 'won', 'lost', 'DONE')
|
||
ORDER BY o.created_at DESC
|
||
LIMIT :limit
|
||
""", {"window_hours": window_hours * 7, "limit": audit.sample_limit})
|
||
if no_task:
|
||
audit.add("WARN", "opportunities", "Oportunidades recentes sem task pendente", count=len(no_task), samples=rows_to_dicts(no_task))
|
||
else:
|
||
audit.add("OK", "opportunities", "Sem oportunidades recentes abertas sem task pendente")
|
||
|
||
|
||
def check_outbox(audit: Audit, conn, window_hours: int):
|
||
if "integration_outbox" not in audit.table_cache:
|
||
audit.add("INFO", "outbox", "Tabela integration_outbox não encontrada")
|
||
return
|
||
cols = audit.schema["integration_outbox"]
|
||
status_col = "status" if "status" in cols else None
|
||
if not status_col:
|
||
audit.add("WARN", "outbox", "integration_outbox sem coluna status")
|
||
return
|
||
failed = safe_query(audit, conn, "outbox", "failed/error", """
|
||
SELECT * FROM integration_outbox
|
||
WHERE status IN ('failed', 'error')
|
||
ORDER BY created_at DESC NULLS LAST
|
||
LIMIT :limit
|
||
""", {"limit": min(audit.sample_limit, 10)})
|
||
if failed:
|
||
audit.add("FAIL", "outbox", "integration_outbox com failed/error", count=len(failed), samples=rows_to_dicts(failed))
|
||
else:
|
||
audit.add("OK", "outbox", "Sem integration_outbox failed/error")
|
||
|
||
pending_old = safe_query(audit, conn, "outbox", "pending antigo", """
|
||
SELECT * FROM integration_outbox
|
||
WHERE status IN ('pending', 'claimed')
|
||
AND created_at < now() - interval '24 hours'
|
||
ORDER BY created_at ASC NULLS LAST
|
||
LIMIT :limit
|
||
""", {"limit": min(audit.sample_limit, 10)})
|
||
if pending_old:
|
||
audit.add("WARN", "outbox", "integration_outbox pending/claimed antigo", count=len(pending_old), samples=rows_to_dicts(pending_old))
|
||
else:
|
||
audit.add("OK", "outbox", "Sem outbox pending/claimed antigo")
|
||
|
||
|
||
def write_reports(audit: Audit, out_dir: Path):
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||
txt_path = out_dir / f"deep_audit_{stamp}.txt"
|
||
json_path = out_dir / f"deep_audit_{stamp}.json"
|
||
|
||
lines: list[str] = []
|
||
lines.append("ClientFlow Deep System Audit")
|
||
lines.append("=" * 90)
|
||
lines.append(f"started_at={audit.started_at}")
|
||
lines.append(f"summary={audit.summary()} exit_code={audit.exit_code()}")
|
||
lines.append("")
|
||
for f in audit.findings:
|
||
icon = {"OK": "✅", "WARN": "⚠️", "FAIL": "❌", "INFO": "ℹ️"}[f.severity]
|
||
count = "" if f.count is None else f" count={f.count}"
|
||
lines.append(f"{icon} [{f.severity}] {f.area}: {f.title}{count}")
|
||
if f.detail:
|
||
lines.append(f" {f.detail}")
|
||
for sample in f.samples[:5]:
|
||
lines.append(" sample: " + json.dumps(json_safe(sample), ensure_ascii=False)[:2000])
|
||
lines.append("")
|
||
|
||
txt_path.write_text("\n".join(lines), encoding="utf-8")
|
||
json_path.write_text(json.dumps({
|
||
"started_at": audit.started_at,
|
||
"summary": audit.summary(),
|
||
"exit_code": audit.exit_code(),
|
||
"findings": [asdict(f) for f in audit.findings],
|
||
}, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||
return txt_path, json_path
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="ClientFlow deep read-only system audit")
|
||
parser.add_argument("--window-hours", type=int, default=72)
|
||
parser.add_argument("--sample-limit", type=int, default=25)
|
||
parser.add_argument("--out-dir", default=str(PROJECT_ROOT / "audit_reports"))
|
||
args = parser.parse_args()
|
||
|
||
audit = Audit(sample_limit=args.sample_limit)
|
||
try:
|
||
with engine.begin() as conn:
|
||
inspect_schema(audit, conn)
|
||
check_base_health(audit, conn, args.window_hours)
|
||
check_raw_events(audit, conn, args.window_hours)
|
||
check_security_content(audit, conn, args.window_hours)
|
||
check_tasks(audit, conn, args.window_hours)
|
||
check_identity(audit, conn, args.window_hours)
|
||
check_financial_safety(audit, conn, args.window_hours)
|
||
check_llm_quality(audit, conn, args.window_hours)
|
||
check_opportunities(audit, conn, args.window_hours)
|
||
check_outbox(audit, conn, args.window_hours)
|
||
except Exception as exc:
|
||
audit.add("FAIL", "runtime", "Auditoria abortou com exceção", str(exc)[:2000])
|
||
|
||
txt_path, json_path = write_reports(audit, Path(args.out_dir))
|
||
print("ClientFlow Deep System Audit")
|
||
print("=" * 90)
|
||
for k, v in audit.summary().items():
|
||
print(f"{k}: {v}")
|
||
print(f"Relatório TXT: {txt_path}")
|
||
print(f"Relatório JSON: {json_path}")
|
||
print(f"exit_code={audit.exit_code()}")
|
||
# Print FAIL/WARN concise list
|
||
for f in audit.findings:
|
||
if f.severity in {"FAIL", "WARN"}:
|
||
print(f"{f.severity} | {f.area} | {f.title} | count={f.count}")
|
||
if f.samples:
|
||
print(json.dumps(f.samples[:2], ensure_ascii=False, indent=2, default=str)[:3000])
|
||
return audit.exit_code()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|