"""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}