"""Report routes — CIS report CRUD, compliance report, OCI health/services.""" import asyncio import io import json import os import subprocess import tempfile import time import uuid import zipfile from functools import partial from pathlib import Path from fastapi import ( APIRouter, HTTPException, Depends, Query, BackgroundTasks, Request, ) from fastapi.responses import FileResponse, HTMLResponse, Response from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from config import REPORTS, VERSION, _chat_executor, log from database import db from auth.jwt_auth import ( current_user, _user_from_token, require, _audit, _verify_config_access, _verify_report_access, ) from models import RunReportReq from utils import running_reports from services.genai import _get_adb_connection from services.cis_reports import _exec_report from services.compliance_gen import ( _extract_section, _extract_remediation_section, _extract_audit_section, _extract_rationale_section, _extract_description_section, _format_remediation_html, _search_cis_remediation, _build_findings_table, _check_oci_services, _OCI_SERVICE_DESCRIPTIONS, _build_compliance_html, _generate_compliance_report, ) from services.compliance_docx import ( _generate_camo_strip, _add_floating_image_to_header, _generate_compliance_docx, ) router = APIRouter() # ── Helper ──────────────────────────────────────────────────────────────────── # ── Report CRUD ─────────────────────────────────────────────────────────────── @router.post("/api/reports/run") async def run_report(req: RunReportReq, bg: BackgroundTasks, u=Depends(require("admin","user"))): if req.level not in (1, 2): raise HTTPException(400, "Level must be 1 or 2") _verify_config_access("oci", req.config_id, u) with db() as c: cfg = c.execute("SELECT * FROM oci_configs WHERE id=?",(req.config_id,)).fetchone() if not cfg: raise HTTPException(404, "Config não encontrada") rid = str(uuid.uuid4()) c.execute("INSERT INTO reports (id,user_id,tenancy_name,config_id,level,obp_checks,raw_data,redact_output,status) VALUES (?,?,?,?,?,?,?,?,?)", (rid, u["id"], cfg["tenancy_name"], req.config_id, req.level, int(req.obp), int(req.raw), int(req.redact_output), "running")) bg.add_task(_exec_report, rid, dict(cfg), req.regions, req.level, req.obp, req.raw, req.redact_output) _audit(u["id"], u["username"], "run_report", rid) return {"report_id": rid, "status": "running"} @router.get("/api/reports") 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,config_id,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports" rows = 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] @router.get("/api/reports/{rid}/progress") async def get_report_progress(rid: str, u=Depends(current_user)): _verify_report_access(rid, u) with db() as c: r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone() if not r: raise HTTPException(404) return dict(r) @router.post("/api/reports/{rid}/cancel") async def cancel_report(rid: str, u=Depends(require("admin", "user"))): _verify_report_access(rid, u) with db() as c: r = c.execute("SELECT status, worker_pid FROM reports WHERE id=?", (rid,)).fetchone() if not r: raise HTTPException(404) if r["status"] != "running": raise HTTPException(400, "Report is not running") proc = running_reports.get(rid) if proc: try: proc.terminate() try: await asyncio.wait_for(proc.wait(), timeout=5) except asyncio.TimeoutError: proc.kill() except ProcessLookupError: pass running_reports.pop(rid, None) elif r["worker_pid"]: import signal try: os.kill(r["worker_pid"], signal.SIGTERM) except (ProcessLookupError, OSError): pass with db() as c: c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelled by user',completed_at=datetime('now') WHERE id=?", (rid,)) _audit(u["id"], u["username"], "cancel_report", rid) return {"ok": True, "status": "cancelled"} @router.get("/api/reports/{rid}") async def get_report(rid, u=Depends(current_user)): 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) return dict(r) @router.delete("/api/reports/{rid}") async def delete_report(rid: str, u=Depends(current_user)): with db() as c: r = c.execute("SELECT id, user_id, status, json_path, html_path 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) if r["status"] == "running": raise HTTPException(400, "Cannot delete a running report") # Remove associated files for path_col in ("json_path", "html_path"): p = r[path_col] if p and Path(p).exists(): try: Path(p).unlink() except OSError: pass c.execute("DELETE FROM reports WHERE id=?", (rid,)) _audit(u["id"], u["username"], "delete_report", rid) return {"ok": True} @router.get("/api/reports/{rid}/summary") async def report_summary(rid: str, u=Depends(current_user)): with db() as c: r = c.execute("SELECT user_id,json_path,tenancy_name,level,created_at FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone() if not r: raise HTTPException(404) if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403) jp = r["json_path"] if not jp or not Path(jp).exists(): raise HTTPException(400, "Report JSON not found") data = json.loads(Path(jp).read_text()) total = passed = failed = 0 sections = {} total_findings = 0 total_compliant = 0 # Format: list of checks with Compliant=Yes/No, Section, Findings, Compliant Items, Total checks = data if isinstance(data, list) else data.get("findings", []) if isinstance(checks, dict): checks = list(checks.values()) for check in checks: total += 1 compliant = str(check.get("Compliant", check.get("status", ""))).strip().lower() is_pass = compliant in ("yes", "pass") if is_pass: passed += 1 else: failed += 1 fi = str(check.get("Findings", "0")).strip() ci = str(check.get("Compliant Items", "0")).strip() total_findings += int(fi) if fi.isdigit() else 0 total_compliant += int(ci) if ci.isdigit() else 0 sec = check.get("Section", check.get("section", "Other")) s = sections.setdefault(sec, {"passed": 0, "failed": 0, "total": 0}) s["total"] += 1 if is_pass: s["passed"] += 1 else: s["failed"] += 1 score = round((passed / total * 100), 1) if total else 0 return { "tenancy": data.get("tenancy", r["tenancy_name"]) if isinstance(data, dict) else r["tenancy_name"], "level": r["level"] or 2, "regions": data.get("regions", []) if isinstance(data, dict) else [], "generated_at": r["created_at"], "total": total, "passed": passed, "failed": failed, "total_findings": total_findings, "total_compliant": total_compliant, "score": score, "sections": sections } @router.get("/api/reports/{rid}/files") async def list_report_files(rid: str, u=Depends(current_user)): with db() as c: r = c.execute("SELECT user_id 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) files = c.execute( "SELECT id,file_name,file_type,file_category,file_size FROM report_files WHERE report_id=? ORDER BY file_category,file_name", (rid,) ).fetchall() return [dict(f) for f in files] @router.get("/api/reports/{rid}/files/{fid}/download") async def download_report_file(rid: str, fid: str, 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 user_id 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) f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone() if not f: raise HTTPException(404) p = Path(f["file_path"]) if not p.exists(): raise HTTPException(404, "File not found on disk") return FileResponse(p, filename=f["file_name"]) @router.api_route("/api/reports/{rid}/html", methods=["GET", "HEAD"]) 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") _verify_report_access(rid, u) 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") # ═══════════════════════════════════════════════════════════════════════ # OCI Health & Services # ═══════════════════════════════════════════════════════════════════════ @router.get("/api/oci-health") async def oci_health(u=Depends(current_user)): """Proxy Oracle Cloud Infrastructure public status API.""" import httpx try: async with httpx.AsyncClient(timeout=15) as client: r = await client.get("https://ocistatus.oraclecloud.com/api/v2/components.json") r.raise_for_status() return r.json() except Exception as e: log.warning(f"OCI Health API failed: {e}") raise HTTPException(502, f"Failed to fetch OCI status: {e}") _OCI_SERVICE_NAMES = ["Vulnerability Scanning Service", "Data Safe", "Cloud Guard", "Bastion", "Security Zones", "Vault"] @router.get("/api/oci-services/{config_id}") async def get_oci_services_status(config_id: str, detect: int = Query(1), u=Depends(current_user)): """Get OCI services status (auto-detected + user overrides). Use detect=0 to skip auto-detect.""" _verify_config_access("oci", config_id, u) import json as _json # Load user overrides with db() as c: row = c.execute("SELECT value FROM app_settings WHERE key=?", (f"oci_services_{config_id}",)).fetchone() overrides = _json.loads(row["value"]) if row else {} # Auto-detect (skip if detect=0) auto = {} if detect: try: detected = _check_oci_services(config_id) for svc in detected: auto[svc["service"]] = {"status": svc["status"], "description": svc["description"]} except Exception as e: log.warning(f"OCI services auto-detect failed: {e}") # Merge: override wins result = [] for name in _OCI_SERVICE_NAMES: ov = overrides.get(name, {}) au = auto.get(name, {"status": "WARNING", "description": "Unable to verify"}) result.append({ "service": name, "auto_status": au["status"], "auto_description": au["description"], "status": ov.get("status", au["status"]), "description": ov.get("description", au["description"]), "overridden": bool(ov), }) return result @router.put("/api/oci-services/{config_id}") async def set_oci_services_status(config_id: str, request: Request, u=Depends(current_user)): """Save user overrides for OCI services status.""" _verify_config_access("oci", config_id, u) import json as _json body = await request.json() overrides = {} for svc in body: name = svc.get("service", "") if name in _OCI_SERVICE_NAMES and svc.get("overridden"): overrides[name] = {"status": svc.get("status", "OK"), "description": svc.get("description", "")} with db() as c: c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (f"oci_services_{config_id}", _json.dumps(overrides))) _audit(u["id"], u["username"], "set_oci_services", config_id) return {"ok": True} # ═══════════════════════════════════════════════════════════════════════ # LAD A-Team CIS Compliance Report # ═══════════════════════════════════════════════════════════════════════ @router.api_route("/api/reports/{rid}/compliance-report", methods=["GET", "HEAD"]) 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") _verify_report_access(rid, u) rdir = REPORTS / rid cache_path = rdir / "compliance_report.html" if regen == 1 and cache_path.exists(): cache_path.unlink() if cache_path.exists(): return FileResponse(cache_path, media_type="text/html", headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}) # Return a friendly HTML page instead of JSON 404 (shown in iframe) marker = rdir / ".compliance_generating" if marker.exists(): msg = "Generating compliance report... Please wait." else: msg = "Compliance report not generated yet." return HTMLResponse( f'' f'

{msg}

', status_code=200, headers={"Cache-Control": "no-cache"}) @router.post("/api/reports/{rid}/compliance-report/generate") async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(current_user)): """Start generating the compliance report in background.""" _verify_report_access(rid, u) with db() as c: report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone() if not report: raise HTTPException(404, "Report not found") rdir = REPORTS / rid csv_path = rdir / "cis_summary_report.csv" if not csv_path.exists(): raise HTTPException(404, "cis_summary_report.csv not found") # Reject if already generating marker = rdir / ".compliance_generating" if marker.exists(): age = time.time() - marker.stat().st_mtime if age < 600: return {"status": "already_generating", "has_rag": False} # Mark as generating BEFORE deleting cache marker.write_text(str(uuid.uuid4()), encoding="utf-8") # Delete existing cache cache_path = rdir / "compliance_report.html" if cache_path.exists(): cache_path.unlink() has_adb = False with db() as c: adb_cfgs = c.execute("SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1", (u["id"],)).fetchall() if adb_cfgs: has_adb = True # Pre-check ADB connection before starting (fail fast instead of timeout loop) if has_adb: adb_cfg = dict(adb_cfgs[0]) try: conn = _get_adb_connection(adb_cfg) conn.ping() conn.close() log.info(f"ADB connection OK for compliance report {rid}") except Exception as e: marker.unlink(missing_ok=True) log.warning(f"ADB connection failed for compliance report {rid}: {e}") raise HTTPException(503, f"Conexao com ADB falhou. Verifique a whitelist e a configuracao do ADB. Erro: {e}") user_id = u["id"] log.info(f"Compliance report generation started for {rid} (has_adb={has_adb})") def _bg_generate(): try: log.info(f"Compliance report thread running for {rid}") _generate_compliance_report(rid, user_id, force_rag=has_adb) log.info(f"Compliance report generation completed for {rid}") except Exception as e: log.error(f"Compliance report generation failed for {rid}: {e}", exc_info=True) finally: # Remove generating marker m = REPORTS / rid / ".compliance_generating" if m.exists(): m.unlink(missing_ok=True) _chat_executor.submit(_bg_generate) return {"status": "generating", "has_rag": has_adb} @router.get("/api/reports/{rid}/compliance-report/status") async def compliance_report_status(rid: str, u=Depends(current_user)): """Check compliance report status: ready, generating, or not started.""" _verify_report_access(rid, u) rdir = REPORTS / rid cache_path = rdir / "compliance_report.html" marker = rdir / ".compliance_generating" if cache_path.exists(): # Clean stale marker if report is ready if marker.exists(): marker.unlink(missing_ok=True) return {"ready": True, "generating": False} if marker.exists(): age = time.time() - marker.stat().st_mtime if age > 600: marker.unlink(missing_ok=True) log.warning(f"Expired stale compliance marker for {rid} (age={age:.0f}s)") return {"ready": False, "generating": False} # Read progress from marker file try: import json as _json progress = _json.loads(marker.read_text(encoding="utf-8")) except Exception: progress = {} return {"ready": False, "generating": True, "current": progress.get("current", 0), "total": progress.get("total", 0), "step": progress.get("step", ""), "rec": progress.get("rec", "")} return {"ready": False, "generating": False} @router.get("/api/reports/{rid}/compliance-report/docx") async def compliance_report_docx(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))): """Download DOCX compliance report directly.""" import re as _re u = _user_from_token(token) if token else (await current_user(cred) if cred else None) if not u: raise HTTPException(401, "Not authenticated") _verify_report_access(rid, u) with db() as c: report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone() if not report: raise HTTPException(404) files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall() tenancy = report["tenancy_name"] or "report" safe_name = _re.sub(r'[^\w\-]', '_', tenancy) try: file_map = {f["file_name"]: f["file_path"] for f in files} docx_bytes = _generate_compliance_docx(rid, tenancy, file_map) if not docx_bytes: raise HTTPException(500, "DOCX generation failed") log.info(f"DOCX direct download: {len(docx_bytes)} bytes") except HTTPException: raise except Exception as e: log.error(f"DOCX generation failed: {e}", exc_info=True) raise HTTPException(500, f"DOCX generation failed: {e}") return Response( content=docx_bytes, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.docx"'} ) @router.get("/api/reports/{rid}/compliance-report/download") async def compliance_report_download(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))): """Download ZIP with compliance report PDF (Chromium headless) + CSV files.""" import re as _re u = _user_from_token(token) if token else (await current_user(cred) if cred else None) if not u: raise HTTPException(401, "Not authenticated") _verify_report_access(rid, u) rdir = REPORTS / rid html_path = rdir / "compliance_report.html" if not html_path.exists(): raise HTTPException(404, "Compliance report not generated yet") with db() as c: report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone() if not report: raise HTTPException(404) files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall() tenancy = report["tenancy_name"] or "report" safe_name = _re.sub(r'[^\w\-]', '_', tenancy) file_map = {f["file_name"]: f["file_path"] for f in files} # Prepare HTML for PDF: rewrite CSV links to relative paths html = html_path.read_text(encoding="utf-8") html = _re.sub(r'', '', html, flags=_re.DOTALL) html = _re.sub(r'', '', html, flags=_re.DOTALL) for fname in file_map: html = _re.sub( r'href="[^"]*?/download[^"]*?"([^>]*>)\s*\[CSV\]\s*' + _re.escape(fname), f'href="csv/{fname}"\\1[CSV] {fname}', html ) # Generate PDF via Chromium headless (same engine as browser print) pdf_bytes = None try: with tempfile.TemporaryDirectory() as tmpdir: tmp_html = Path(tmpdir) / "report.html" tmp_pdf = Path(tmpdir) / "report.pdf" tmp_html.write_text(html, encoding="utf-8") 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") else: log.error(f"Chromium PDF failed: {result.stderr.decode()[:500]}") except Exception as e: log.error(f"PDF generation failed: {e}", exc_info=True) if not pdf_bytes: raise HTTPException(500, "PDF generation failed") # Generate DOCX docx_bytes = None try: docx_bytes = _generate_compliance_docx(rid, tenancy, file_map) if docx_bytes: log.info(f"DOCX generated: {len(docx_bytes)} bytes") except Exception as e: log.warning(f"DOCX generation failed (non-fatal): {e}") # Build ZIP: PDF + DOCX + CSVs buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: zf.writestr(f"CIS_Compliance_Report_{safe_name}.pdf", pdf_bytes) if docx_bytes: zf.writestr(f"CIS_Compliance_Report_{safe_name}.docx", docx_bytes) for fname, fpath in file_map.items(): p = Path(fpath) if p.exists() and fname.lower().endswith('.csv'): zf.write(str(p), f"csv/{fname}") buf.seek(0) return Response( content=buf.getvalue(), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.zip"'} ) @router.get("/api/reports/{rid}/download") async def report_dl(rid, fmt: str = Query("json"), 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") _verify_report_access(rid, u) with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone() if not r: raise HTTPException(404) 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}")