feat: comprehensive security, performance, UX and deployment audit

Security:
- CORS restricted to explicit methods/headers, configurable via CORS_ORIGINS env
- Auth added to /reports/{rid}/html and /compliance-report endpoints
- Ownership check on report downloads
- Rate limiting on login (10 attempts/5min per IP with threading.Lock)
- Non-root container user (agent via gosu entrypoint)
- Nginx security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy)
- .gitignore and .dockerignore for secret leak prevention
- .env.example with documentation

Performance:
- 16 SQLite indexes on foreign keys and frequently queried columns
- Pagination on chat messages (100), reports (50), audit log (100)
- subprocess.run wrapped in run_in_executor (3 async handlers)
- asyncio.wait_for timeouts on GenAI calls (300s) and Chromium PDF (120s)
- Thread pool reduced to 10 workers
- Code splitting with React.lazy (bundle: 1.3MB -> ~550KB initial)
- React.memo on TreeItem (recursive compartment tree)

Error handling:
- 13 bare except clauses replaced with logged Exception handlers
- Graceful shutdown handler (terminates subprocesses + executor)
- File upload validation (50MB max, extension whitelist per endpoint)
- Health check expanded (version, uptime, db_ok)
- Cache-Control on /api/genai/models (1h)
- Auto-cleanup audit_log > 30 days

Dead code removed:
- DownloadsPage.tsx, StubPage.tsx, MfaPage.tsx (moved to UsersPage)
- Legacy frontend/ directory
- 19 unused i18n keys, dist/ removed from git tracking

UX & i18n:
- 8 alert() calls replaced with styled error states (TerraformPage)
- 50+ hardcoded Portuguese strings localized to i18n (pt/en) across 11 files
- aria-label on all icon-only buttons (Chat, Explorer, Sidebar)
- focus-visible CSS for keyboard navigation
- Responsive grid fix (360px -> 280px for mobile)
- aria-hidden on decorative SVGs

Deployment:
- Docker resource limits (backend 4G, frontend 512M)
- Log rotation (json-file, 10MB x 3)
- Terraform version parameterized via ARG
This commit is contained in:
nogueiraguh
2026-03-20 13:48:30 -03:00
parent 61fbb3861d
commit fbcb1966d5
35 changed files with 1301 additions and 4903 deletions

10
backend/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitignore
__pycache__
*.pyc
.venv
.env*
*.db
*.sqlite
.DS_Store
.idea

View File

@@ -2,12 +2,13 @@ FROM python:3.12-slim
WORKDIR /app
# Install system deps + Terraform CLI
# Install system deps + Chromium + Terraform CLI
ARG TERRAFORM_VERSION=1.7.5
RUN apt-get update && apt-get install -y --no-install-recommends \
curl gcc libffi-dev unzip \
chromium && \
ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_${ARCH}.zip" -o /tmp/tf.zip && \
curl -fsSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${ARCH}.zip" -o /tmp/tf.zip && \
unzip /tmp/tf.zip -d /usr/local/bin/ && rm /tmp/tf.zip && \
rm -rf /var/lib/apt/lists/*
@@ -22,12 +23,21 @@ COPY cis_reports.py .
COPY mcp_cis_server.py .
COPY gen_tf_reference.py .
# Data volume
RUN mkdir -p /data
# Non-root user setup
RUN groupadd -r agent && useradd -r -g agent -d /app -s /bin/bash agent && \
mkdir -p /data && chown -R agent:agent /app /data
# Generate OCI Terraform resource reference at build time
RUN python gen_tf_reference.py || echo "WARN: tf reference generation skipped (terraform init may need network)"
# Entrypoint: fix /data ownership then exec as agent user
RUN printf '#!/bin/bash\nchown -R agent:agent /data 2>/dev/null || true\nexec gosu agent "$@"\n' > /entrypoint.sh && \
chmod +x /entrypoint.sh && \
apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/*
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "8"]

View File

@@ -5,7 +5,7 @@ OCI Account Explorer, MCP Server registry with VectorDB tool integration,
Autonomous DB vector storage, CIS reports, chat agent, audit log.
"""
import os, json, uuid, hashlib, hmac, time, base64, struct, secrets, subprocess
import shutil, asyncio, sqlite3, logging, re, concurrent.futures
import shutil, asyncio, sqlite3, logging, re, concurrent.futures, threading
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, List, Dict, Any
@@ -33,6 +33,8 @@ REPORTS = DATA / "reports"
MCP_DIR = DATA / "mcp_servers"
WALLET_DIR = DATA / "wallets"
VERSION = "1.1"
_START_TIME = time.time()
_MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50MB global limit for file uploads
for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]:
d.mkdir(parents=True, exist_ok=True)
@@ -41,7 +43,7 @@ _running_reports: dict[str, asyncio.subprocess.Process] = {} # rid → subproce
_running_terraform: dict[str, asyncio.subprocess.Process] = {} # wid → subprocess
TERRAFORM_DIR = DATA / "terraform"
TERRAFORM_DIR.mkdir(parents=True, exist_ok=True)
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=16, thread_name_prefix="chat")
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="chat")
# ── Chat Memory Compaction Settings ──
COMPACT_TOKEN_THRESHOLD = 6000 # estimated tokens before triggering compaction
@@ -52,9 +54,26 @@ COMPACT_MIN_MESSAGES = 8
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("agent")
async def _validate_upload(file: "UploadFile", allowed_exts: set[str] | None = None, max_bytes: int = _MAX_UPLOAD_BYTES):
"""Validate uploaded file size and extension. Seeks back to 0 after size check."""
data = await file.read()
if len(data) > max_bytes:
raise HTTPException(400, f"Arquivo excede o limite de {max_bytes // (1024*1024)}MB")
if allowed_exts and file.filename:
ext = Path(file.filename).suffix.lower()
if ext not in allowed_exts:
raise HTTPException(400, f"Extensão '{ext}' não permitida. Aceitas: {', '.join(sorted(allowed_exts))}")
return data
app = FastAPI(title="AI Agent - Infrastructure & Security Engineer", version=VERSION)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
_CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else []
app.add_middleware(CORSMiddleware,
allow_origins=_CORS_ORIGINS or ["http://localhost:8080", "http://127.0.0.1:8080"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"])
security = HTTPBearer()
# ── OCI GenAI Models Catalog ──────────────────────────────────────────────────
@@ -363,6 +382,27 @@ def init_db():
""")
c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')")
c.execute("DELETE FROM chat_logs WHERE created_at < datetime('now', '-30 days')")
c.execute("DELETE FROM audit_log WHERE created_at < datetime('now', '-30 days')")
# ── Indexes for performance ──
for idx in [
"CREATE INDEX IF NOT EXISTS idx_chat_msg_session ON chat_messages(session_id)",
"CREATE INDEX IF NOT EXISTS idx_chat_msg_user ON chat_messages(user_id)",
"CREATE INDEX IF NOT EXISTS idx_chat_msg_status ON chat_messages(status)",
"CREATE INDEX IF NOT EXISTS idx_chat_sess_user ON chat_sessions(user_id)",
"CREATE INDEX IF NOT EXISTS idx_reports_user ON reports(user_id)",
"CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status)",
"CREATE INDEX IF NOT EXISTS idx_report_files_report ON report_files(report_id)",
"CREATE INDEX IF NOT EXISTS idx_oci_cfg_user ON oci_configs(user_id)",
"CREATE INDEX IF NOT EXISTS idx_genai_cfg_user ON genai_configs(user_id)",
"CREATE INDEX IF NOT EXISTS idx_mcp_srv_user ON mcp_servers(user_id)",
"CREATE INDEX IF NOT EXISTS idx_adb_cfg_user ON adb_vector_configs(user_id)",
"CREATE INDEX IF NOT EXISTS idx_adb_tables_cfg ON adb_vector_tables(adb_config_id)",
"CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)",
"CREATE INDEX IF NOT EXISTS idx_tf_ws_user ON terraform_workspaces(user_id)",
"CREATE INDEX IF NOT EXISTS idx_tf_ws_session ON terraform_workspaces(session_id)",
"CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id)",
]:
c.execute(idx)
# ── Migrations ──
for col in ["system_prompt TEXT DEFAULT ''", "reasoning_effort TEXT"]:
try:
@@ -401,8 +441,8 @@ def init_db():
if not c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=?", (cfg_row["id"], cfg_row["table_name"])).fetchone():
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
(str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente"))
except Exception:
pass
except Exception as e:
log.warning(f"DB migration: adb_vector_tables backfill failed: {e}")
# Backfill chat_sessions from existing chat_messages
try:
c.execute("""INSERT OR IGNORE INTO chat_sessions (id, user_id, agent_type, title, created_at, updated_at)
@@ -414,8 +454,8 @@ def init_db():
LEFT JOIN terraform_workspaces tw ON tw.session_id = cm.session_id
WHERE cm.session_id NOT IN (SELECT id FROM chat_sessions)
GROUP BY cm.session_id""")
except Exception:
pass
except Exception as e:
log.warning(f"DB migration: chat_sessions backfill failed: {e}")
# Seed default system prompt if none exists for chat agent
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone():
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
@@ -591,9 +631,28 @@ class MCPServerReq(BaseModel):
module_path: Optional[str] = None; tools: Optional[List[dict]] = None
linked_adb_id: Optional[str] = None
# ── Rate limiting (login brute-force protection) ─────────────────────────────
_login_attempts: dict = {} # {ip: [timestamps]}
_login_attempts_lock = threading.Lock()
_LOGIN_WINDOW = 300 # 5 minutes
_LOGIN_MAX = 10 # max attempts per window
def _check_rate_limit(ip: str):
"""Raise 429 if too many login attempts from this IP."""
import time
now = time.time()
with _login_attempts_lock:
attempts = _login_attempts.get(ip, [])
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
if len(attempts) >= _LOGIN_MAX:
raise HTTPException(429, "Muitas tentativas de login. Aguarde 5 minutos.")
attempts.append(now)
_login_attempts[ip] = attempts
# ── Auth endpoints ────────────────────────────────────────────────────────────
@app.post("/api/auth/login")
async def login(req: LoginReq, request: Request):
_check_rate_limit(request.client.host if request.client else "unknown")
with db() as c:
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone()
if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas")
@@ -697,6 +756,13 @@ async def save_oci(
):
if auth_type not in ("api_key", "session_token"):
raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'")
_PEM_EXTS = {".pem", ".key"}
if private_key and private_key.filename:
await _validate_upload(private_key, _PEM_EXTS)
private_key.file.seek(0)
if public_key and public_key.filename:
await _validate_upload(public_key, _PEM_EXTS)
public_key.file.seek(0)
cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
kp = cdir / "oci_api_key.pem"
@@ -810,6 +876,13 @@ async def update_oci(
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
u = Depends(require("admin","user"))
):
_PEM_EXTS = {".pem", ".key"}
if private_key and private_key.filename:
await _validate_upload(private_key, _PEM_EXTS)
private_key.file.seek(0)
if public_key and public_key.filename:
await _validate_upload(public_key, _PEM_EXTS)
public_key.file.seek(0)
with db() as c:
existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone()
if not existing: raise HTTPException(404)
@@ -874,8 +947,10 @@ async def test_oci(cid: str, u=Depends(require("admin","user"))):
_config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"])
return {"status":"error","message":"Config não encontrada"}
try:
r = subprocess.run(["oci","iam","region","list","--config-file",str(cp),"--output","json"],
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL)
loop = asyncio.get_event_loop()
r = await loop.run_in_executor(None, partial(
subprocess.run, ["oci","iam","region","list","--config-file",str(cp),"--output","json"],
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL))
if r.returncode == 0:
_config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"])
return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)}
@@ -1030,10 +1105,12 @@ async def explore_compartment_tree(cid: str, u=Depends(current_user)):
cp = identity.get_compartment(user_comp).data
tree.append({"id":cp.id, "name":cp.name, "parent_id":cp.compartment_id,
"lifecycle_state":cp.lifecycle_state})
except Exception:
except Exception as e:
log.warning(f"Failed to get compartment {user_comp}: {e}")
tree.append({"id":user_comp, "name":"Meu Compartment", "parent_id":tenancy,
"lifecycle_state":"ACTIVE"})
except Exception:
except Exception as e:
log.warning(f"Failed to list compartments from tenancy: {e}")
# If listing from tenancy fails (no permission), list from user's compartment
if user_comp and user_comp != tenancy:
try:
@@ -1042,7 +1119,8 @@ async def explore_compartment_tree(cid: str, u=Depends(current_user)):
sub = identity.list_compartments(user_comp, compartment_id_in_subtree=True).data
tree += [{"id":s.id, "name":s.name, "parent_id":s.compartment_id,
"lifecycle_state":s.lifecycle_state} for s in sub if s.lifecycle_state == "ACTIVE"]
except Exception:
except Exception as e:
log.warning(f"Failed to list sub-compartments for {user_comp}: {e}")
tree = [{"id":user_comp, "name":"Meu Compartment", "parent_id": None, "lifecycle_state":"ACTIVE"}]
return tree
except Exception as e:
@@ -1851,7 +1929,10 @@ async def explore_network_firewall_policies(cid: str, compartment_id: str = Quer
# ── OCI GenAI Config & Chat ───────────────────────────────────────────────────
@app.get("/api/genai/models")
async def list_genai_models(u=Depends(current_user)):
return {"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS}
return JSONResponse(
content={"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS},
headers={"Cache-Control": "max-age=3600"},
)
@app.post("/api/genai/config")
async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))):
@@ -2281,7 +2362,7 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
args = {}
if hasattr(tc, 'arguments') and tc.arguments:
try: args = json.loads(tc.arguments) if isinstance(tc.arguments, str) else tc.arguments
except: args = {}
except Exception as e: log.warning(f"Failed to parse tool call arguments: {e}"); args = {}
tool_calls.append({
"id": tc.id if hasattr(tc, 'id') else tc.name,
"name": tc.name if hasattr(tc, 'name') else "",
@@ -2762,7 +2843,8 @@ def _get_table_embedding_dim(cfg: dict, table_name: str) -> int:
row = cur.fetchone()
cur.close()
return int(row[0]) if row and row[0] else 0
except Exception:
except Exception as e:
log.warning(f"Failed to get embedding dimension for table '{table_name}': {e}")
return 0
finally:
conn.close()
@@ -2818,7 +2900,8 @@ def _enrich_doc_content(doc: dict) -> str:
if isinstance(meta, str) and meta:
try:
meta = json.loads(meta)
except Exception:
except Exception as e:
log.warning(f"Failed to parse document metadata JSON: {e}")
meta = {}
if isinstance(meta, dict):
# If TEXT column is short/empty, use metadata.text instead
@@ -2904,7 +2987,7 @@ async def list_mcp(u=Depends(current_user)):
for k in ("args","env_vars","tools"):
if d.get(k):
try: d[k] = json.loads(d[k])
except: pass
except Exception as e: log.warning(f"Failed to parse MCP server field '{k}': {e}")
res.append(d)
return res
@@ -2940,6 +3023,8 @@ async def toggle_mcp(mid: str, u=Depends(require("admin","user"))):
@app.post("/api/mcp/servers/{mid}/upload")
async def upload_mcp_file(mid: str, file: UploadFile = File(...), u=Depends(require("admin","user"))):
await _validate_upload(file, {".py", ".js", ".ts", ".json", ".yaml", ".yml", ".toml", ".sh"})
file.file.seek(0)
sdir = MCP_DIR / mid; sdir.mkdir(parents=True, exist_ok=True)
fp = sdir / file.filename
fp.write_bytes(await file.read())
@@ -2966,7 +3051,7 @@ async def discover_mcp_tools(mid: str, u=Depends(require("admin","user"))):
existing = []
if mcp_srv.get("tools"):
try: existing = json.loads(mcp_srv["tools"]) if isinstance(mcp_srv["tools"], str) else mcp_srv["tools"]
except: pass
except Exception as e: log.warning(f"Failed to parse existing MCP tools JSON: {e}")
existing_names = {t["name"] for t in existing if isinstance(t, dict)}
for dt in discovered:
if dt["name"] not in existing_names:
@@ -3076,7 +3161,8 @@ def _get_active_mcp_tools(user_id: str) -> list[dict]:
continue
try:
tools = json.loads(tools_raw) if isinstance(tools_raw, str) else tools_raw
except:
except Exception as e:
log.warning(f"Failed to parse tools JSON for MCP server '{srv.get('name', '?')}': {e}")
continue
for t in tools:
if isinstance(t, dict) and t.get("name"):
@@ -3087,6 +3173,8 @@ def _get_active_mcp_tools(user_id: str) -> list[dict]:
@app.post("/api/adb/parse-wallet")
async def parse_wallet(wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
"""Extract DSN names from tnsnames.ora inside wallet ZIP (temporary parse, no save)."""
await _validate_upload(wallet, {".zip"})
wallet.file.seek(0)
import zipfile, tempfile
try:
data = await wallet.read()
@@ -3115,6 +3203,9 @@ async def save_adb(
wallet: Optional[UploadFile] = File(None),
u=Depends(require("admin","user"))
):
if wallet and wallet.filename:
await _validate_upload(wallet, {".zip"})
wallet.file.seek(0)
vid = str(uuid.uuid4())
use_mtls_bool = use_mtls.lower() in ("true", "1", "yes")
with db() as c:
@@ -3138,6 +3229,8 @@ async def save_adb(
@app.post("/api/adb/{vid}/upload-wallet")
async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
await _validate_upload(wallet, {".zip"})
wallet.file.seek(0)
cname = None
with db() as c:
row = c.execute("SELECT config_name FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
@@ -3249,6 +3342,9 @@ async def update_adb(
wallet: Optional[UploadFile] = File(None),
u=Depends(require("admin","user"))
):
if wallet and wallet.filename:
await _validate_upload(wallet, {".zip"})
wallet.file.seek(0)
with db() as c:
existing = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
if not existing: raise HTTPException(404)
@@ -3631,6 +3727,8 @@ async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""
allowed = ('.txt', '.pdf', '.csv', '.json', '.md')
if not any(fname.endswith(ext) for ext in allowed):
raise HTTPException(400, f"Formatos aceitos: {', '.join(allowed)}")
await _validate_upload(file, allowed)
file.file.seek(0)
raw = await file.read()
if fname.endswith('.pdf'):
content = _extract_pdf_text(raw)
@@ -3965,11 +4063,11 @@ async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", str(e)[:2000])
@app.get("/api/reports")
async def list_reports(u=Depends(current_user)):
async def list_reports(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), u=Depends(current_user)):
with db() as c:
q = "SELECT id,user_id,tenancy_name,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
rows = c.execute(q+" ORDER BY created_at DESC").fetchall() if u["role"]=="admin" \
else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall()
rows = c.execute(q+" ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, skip)).fetchall() if u["role"]=="admin" \
else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?",(u["id"], limit, skip)).fetchall()
return [dict(r) for r in rows]
@app.get("/api/reports/{rid}/progress")
@@ -4108,7 +4206,9 @@ async def download_report_file(rid: str, fid: str, token: str = Query(None), cre
return FileResponse(p, filename=f["file_name"])
@app.get("/api/reports/{rid}/html")
async def report_html(rid):
async def report_html(rid, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone()
if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404)
return FileResponse(r["html_path"], media_type="text/html")
@@ -4255,14 +4355,14 @@ def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict:
actual_dim = _get_table_embedding_dim(adb_cfg, tbl_name)
if actual_dim and actual_dim in _DIM_TO_MODEL:
tbl_model = _DIM_TO_MODEL[actual_dim]
except Exception:
pass
except Exception as e:
log.warning(f"Failed to detect embedding dimension for table '{tbl_name}': {e}")
query_embedding = _embed_text(query, emb_genai, tbl_model)
docs = _vector_search(adb_cfg, query_embedding, top_k=10, table_name=tbl_name)
if docs:
all_docs.extend(docs)
except Exception:
pass
except Exception as e:
log.warning(f"Vector search failed for table '{tbl_name}': {e}")
if all_docs:
import re as _re
@@ -4836,8 +4936,10 @@ def _generate_compliance_report(rid: str, user_id: str, force_rag: bool = True)
@app.get("/api/reports/{rid}/compliance-report")
async def compliance_report(rid: str, regen: int = Query(0)):
async def compliance_report(rid: str, regen: int = Query(0), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
"""Serve cached LAD A-Team CIS Compliance Report, or return 404 if not generated yet."""
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
rdir = REPORTS / rid
cache_path = rdir / "compliance_report.html"
@@ -4949,12 +5051,16 @@ async def compliance_report_download(rid: str, token: str = Query(None), cred: H
tmp_html = Path(tmpdir) / "report.html"
tmp_pdf = Path(tmpdir) / "report.pdf"
tmp_html.write_text(html, encoding="utf-8")
result = subprocess.run([
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
f"file://{tmp_html}"
], capture_output=True, timeout=120)
loop = asyncio.get_event_loop()
result = await asyncio.wait_for(
loop.run_in_executor(None, partial(
subprocess.run, [
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
f"file://{tmp_html}"
], capture_output=True, timeout=120)),
timeout=120)
if tmp_pdf.exists():
pdf_bytes = tmp_pdf.read_bytes()
log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes")
@@ -4990,6 +5096,7 @@ async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cre
if not u: raise HTTPException(401, "Not authenticated")
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
if not r: raise HTTPException(404)
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
p = r["json_path"] if fmt=="json" else r["html_path"]
if not p or not Path(p).exists(): raise HTTPException(404)
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
@@ -5274,9 +5381,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
hist = history[:-1] if len(history) > 1 else None
loop = asyncio.get_event_loop()
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, None, None, attachments))
try:
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, None, None, attachments)),
timeout=300)
except asyncio.TimeoutError:
log.error(f"GenAI call timed out after 300s for session {sid}")
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
all_tool_results = []
accumulated_msgs = []
@@ -5314,9 +5427,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
tr_obj.call = tc_obj
tr_obj.outputs = [{"result": tr["content"]}]
cohere_results.append(tr_obj)
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, cohere_results))
try:
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, cohere_results)),
timeout=300)
except asyncio.TimeoutError:
log.error(f"GenAI tool-use call timed out after 300s (Cohere, iteration {iterations})")
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
else:
import oci
assistant_msg = oci.generative_ai_inference.models.AssistantMessage()
@@ -5329,9 +5448,15 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
tool_content.text = tr["content"]
tool_msg.content = [tool_content]
accumulated_msgs.append(tool_msg)
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, None, accumulated_msgs))
try:
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
loop.run_in_executor(
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
tool_defs, None, accumulated_msgs)),
timeout=300)
except asyncio.TimeoutError:
log.error(f"GenAI tool-use call timed out after 300s (Generic, iteration {iterations})")
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
resp = resp_text
if all_tool_results:
@@ -5416,7 +5541,8 @@ async def chat_with_files(
else:
try:
text = data.decode("utf-8", errors="replace")
except Exception:
except Exception as e:
log.warning(f"UTF-8 decode failed for file attachment, falling back to latin-1: {e}")
text = data.decode("latin-1", errors="replace")
message = f"{message}\n\n--- Conteúdo de {f.filename} ---\n{text[:50000]}"
file_names.append(f.filename)
@@ -5507,15 +5633,18 @@ async def list_chat_sessions(agent_type: str = "chat", limit: int = 50, u=Depend
@app.get("/api/chat/sessions/{sid}/messages")
async def get_session_messages(sid: str, u=Depends(current_user)):
async def get_session_messages(sid: str, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), u=Depends(current_user)):
with db() as c:
session = c.execute("SELECT * FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone()
if not session:
raise HTTPException(404, "Session not found")
total = c.execute(
"SELECT COUNT(*) as cnt FROM chat_messages "
"WHERE session_id=? AND status='done'", (sid,)).fetchone()["cnt"]
msgs = c.execute(
"SELECT id, role, content, model_id, status, created_at FROM chat_messages "
"WHERE session_id=? AND status='done' ORDER BY created_at ASC", (sid,)).fetchall()
return {"session": dict(session), "messages": [dict(m) for m in msgs]}
"WHERE session_id=? AND status='done' ORDER BY created_at ASC LIMIT ? OFFSET ?", (sid, limit, skip)).fetchall()
return {"session": dict(session), "messages": [dict(m) for m in msgs], "total": total}
@app.put("/api/chat/sessions/{sid}/title")
@@ -6105,7 +6234,13 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)):
async def tf_refresh_reference(u=Depends(current_user)):
"""Regenerate the OCI Terraform resource reference from provider schema (internal, triggered by UI button)."""
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(_chat_executor, _regenerate_tf_reference)
try:
result = await asyncio.wait_for(
loop.run_in_executor(_chat_executor, _regenerate_tf_reference),
timeout=300)
except asyncio.TimeoutError:
log.error("TF reference regeneration timed out after 300s")
raise HTTPException(504, "Geração de referência Terraform demorou demais. Tente novamente.")
# Add status info
p = Path(_TF_RESOURCE_REF_PATH)
if p.exists():
@@ -6548,8 +6683,8 @@ provider "oci" {{
# ── Audit ─────────────────────────────────────────────────────────────────────
@app.get("/api/audit-log")
async def audit_log(limit:int=Query(100,le=500), u=Depends(require("admin"))):
with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?",(limit,)).fetchall()
async def audit_log(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), u=Depends(require("admin"))):
with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?",(limit, skip)).fetchall()
return [dict(r) for r in rows]
# ── Config Logs ───────────────────────────────────────────────────────────────
@@ -6693,6 +6828,8 @@ async def export_config(u=Depends(require("admin"))):
@app.post("/api/config/import")
async def import_config(file: UploadFile = File(...), u=Depends(require("admin"))):
"""Import configurations from exported JSON. Merges by ID (skip existing)."""
await _validate_upload(file, {".json"})
file.file.seek(0)
content = await file.read()
try:
data = json.loads(content)
@@ -6843,7 +6980,40 @@ async def cis_engine_update(u=Depends(require("admin"))):
@app.get("/api/health")
async def health():
return {"status":"ok","ts":datetime.utcnow().isoformat(),"version":VERSION}
db_ok = False
try:
with db() as c:
c.execute("SELECT 1")
db_ok = True
except Exception:
pass
return {
"status": "ok",
"version": VERSION,
"uptime_seconds": int(time.time() - _START_TIME),
"db_ok": db_ok,
"ts": datetime.utcnow().isoformat(),
}
@app.on_event("shutdown")
async def shutdown():
"""Graceful shutdown: terminate running subprocesses and thread pool."""
for rid, proc in list(_running_reports.items()):
try:
proc.terminate()
log.info(f"Terminated report process {rid}")
except Exception as e:
log.warning(f"Failed to terminate report process {rid}: {e}")
_running_reports.clear()
for wid, proc in list(_running_terraform.items()):
try:
proc.terminate()
log.info(f"Terminated terraform process {wid}")
except Exception as e:
log.warning(f"Failed to terminate terraform process {wid}: {e}")
_running_terraform.clear()
_chat_executor.shutdown(wait=False)
log.info("Graceful shutdown completed")
@app.on_event("startup")
async def startup():
@@ -6885,7 +7055,9 @@ async def startup():
ref_path = Path(_TF_RESOURCE_REF_PATH)
if not ref_path.exists():
try:
result = subprocess.run(["python", "/app/gen_tf_reference.py"], capture_output=True, text=True, timeout=300)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, partial(
subprocess.run, ["python", "/app/gen_tf_reference.py"], capture_output=True, text=True, timeout=300))
if result.returncode == 0 and ref_path.exists():
log.info(f"Generated TF resource reference at startup: {ref_path.stat().st_size} bytes")
else: