From 2c5f0f2e1c08e77d60ba6d2febcd2786df351978 Mon Sep 17 00:00:00 2001 From: nogueiraguh Date: Fri, 20 Mar 2026 17:17:42 -0300 Subject: [PATCH] fix: compliance report iframe, findings tables, PDF fonts, delete session, orphan markers - Compliance report iframe: removed auth requirement (UUIDs are non-guessable), fixed X-Frame-Options SAMEORIGIN on /api/ - Findings resource tables: compact HTML tables (max 4 cols, 10 rows) per non-compliant finding with affected resources from CSV - PDF fonts: installed Liberation + DejaVu fonts in container, standardized font-family to Arial/Liberation Sans - Delete session fix: terraform.ts used wrong URL /chat/sessions/{sid} instead of /chat/{sid} - Chat deleteSession: read sessionId from store.getState() to avoid stale closure - Orphan marker cleanup: startup removes stale .compliance_generating markers, status endpoint auto-expires after 10min - Generate endpoint rejects duplicate requests while already generating - Compliance iframe condition: only renders when ready AND not generating, prevents 404 flash - Dockerfile: added fonts-liberation fonts-dejavu-core fontconfig packages --- backend/Dockerfile | 2 +- backend/app.py | 152 +++++++++++++++--- frontend-react/src/api/endpoints/reports.ts | 10 +- frontend-react/src/api/endpoints/terraform.ts | 2 +- frontend-react/src/pages/ChatPage.tsx | 5 +- frontend-react/src/pages/ReportsPage.tsx | 4 +- nginx/default.conf | 2 +- 7 files changed, 141 insertions(+), 36 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 2135888..e1d2d60 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app ARG TERRAFORM_VERSION=1.7.5 RUN apt-get update && apt-get install -y --no-install-recommends \ curl gcc libffi-dev unzip \ - chromium && \ + chromium fonts-liberation fonts-dejavu-core fontconfig && \ ARCH=$(dpkg --print-architecture) && \ 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 && \ diff --git a/backend/app.py b/backend/app.py index 5ca8aa4..6a39775 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4205,10 +4205,8 @@ async def download_report_file(rid: str, fid: str, token: str = Query(None), cre if not p.exists(): raise HTTPException(404, "File not found on disk") return FileResponse(p, filename=f["file_name"]) -@app.get("/api/reports/{rid}/html") -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") +@app.api_route("/api/reports/{rid}/html", methods=["GET", "HEAD"]) +async def report_html(rid): 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") @@ -4408,7 +4406,83 @@ def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict: return {"description": "", "remediation": ""} -def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: str, sections_data: dict, rid: str = "", file_id_map: dict = None) -> str: +def _build_findings_table(csv_path: str, max_rows: int = 10) -> str: + """Read a findings CSV and return a compact HTML table with up to max_rows.""" + import csv as csvmod, re as _re + p = Path(csv_path) if isinstance(csv_path, str) else csv_path + if not p.exists(): + return "" + try: + with open(p, "r", encoding="utf-8") as f: + all_rows = list(csvmod.DictReader(f)) + if not all_rows: + return "" + + def esc(v): return (v or "").replace("&","&").replace("<","<").replace(">",">") + + def fmt(v): + v = (v or "").strip() + if not v: return "—" + if v.startswith("=HYPERLINK"): + m = _re.search(r',\s*"([^"]+)"', v) + return m.group(1) if m else "—" + if v.startswith("ocid1.") and len(v) > 30: + return "..." + v[-12:] + if len(v) > 50: + return v[:47] + "..." + return v + + # Priority columns: pick the most useful ones, max 4 columns + priority = ["name", "display_name", "username", "lifecycle_state", "status", + "is_mfa_activated", "domain_name", "description", "email", + "key_id", "age", "region", "cidr_block", "protocol", "port_range", + "visibility", "encryption", "versioning", "type", "severity"] + all_cols = list(all_rows[0].keys()) + skip = {"extract_date", "deep_link", "domain_deeplink", "defined_tags", "compartment_id", + "domain_id", "external_identifier", "freeform_tags", "system_tags", "id", + "time_created", "email_verified", "is_federated", "groups", + "api_keys", "auth_tokens", "customer_secret_keys", "database_passwords", + "can_use_api_keys", "can_use_auth_tokens", "can_use_console_password", + "can_use_customer_secret_keys", "can_use_db_credentials", + "can_use_o_auth2_client_credentials", "can_use_smtp_credentials", + "last_successful_login_date"} + avail = [c for c in all_cols if c.lower() not in skip] + # Pick up to 4 columns in priority order + display = [] + for p_col in priority: + for a_col in avail: + if a_col.lower() == p_col and a_col not in display: + display.append(a_col) + break + if len(display) >= 4: + break + if not display: + display = avail[:4] + + # Pretty column names + def col_label(c): + return c.replace("_", " ").title() + + total = len(all_rows) + show = all_rows[:max_rows] + header = "".join(f'{esc(col_label(c))}' for c in display) + rows_html = "" + for row in show: + cells = "".join(f'{esc(fmt(row.get(c, "")))}' for c in display) + rows_html += f"{cells}" + + note = f'
{total} non-compliant resource{"s" if total != 1 else ""}.' + if total > max_rows: + note += f' Showing first {max_rows} — see CSV for complete list.' + note += '
' + + return f'
{header}{rows_html}
{note}
' + except Exception as e: + log.warning(f"Failed to build findings table from {p}: {e}") + return "" + + +def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: str, sections_data: dict, rid: str = "", file_id_map: dict = None, file_path_map: dict = None) -> str: """Build the full LAD A-Team CIS Compliance Report HTML.""" import re as _re from datetime import datetime as _dt @@ -4504,20 +4578,27 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date: cls = "bad" if pct <= 20 else "warn" if pct <= 60 else "ok" bar_html = f'
{pct:.0f}% Compliant
' - # Findings file link + # Findings file link + resource table findings_file_html = "" + findings_table_html = "" csv_filename = (r.get("Filename", "") or "").strip() - if csv_filename and not is_compliant and file_id_map: - fid = file_id_map.get(csv_filename, "") - if fid and rid: - dl_url = f"/api/reports/{rid}/files/{fid}/download" - findings_file_html = f'
[CSV] {esc(csv_filename)}Affected resources details
' + if csv_filename and not is_compliant: + if file_id_map: + fid = file_id_map.get(csv_filename, "") + if fid and rid: + dl_url = f"/api/reports/{rid}/files/{fid}/download" + findings_file_html = f'
[CSV] {esc(csv_filename)}Affected resources details
' + if file_path_map: + csv_path = file_path_map.get(csv_filename, "") + if csv_path: + findings_table_html = _build_findings_table(csv_path, max_rows=10) sections_html += f'''

{esc(rec)} {esc(title)}

{bar_html} {result_html} + {findings_table_html} {findings_file_html} {description_html} {rationale_html} @@ -4586,7 +4667,7 @@ html,body {{ -webkit-print-color-adjust:exact; print-color-adjust:exact; }} --fail:#c00000; --pass:#0f7b0f; --bad:#ef4444; --warn:#f59e0b; --ok:#22c55e; --strip-w:28px; }} -body {{ margin:0; font-family:'Oracle Sans','Segoe UI',Arial,Helvetica,sans-serif; color:var(--text); background:#fff; font-size:11.5px; line-height:1.6; }} +body {{ margin:0; font-family:Arial,Helvetica,'Liberation Sans',sans-serif; color:var(--text); background:#fff; font-size:11.5px; line-height:1.6; }} /* Decorative left strip on every page */ .page-strip {{ position:fixed; top:0; left:0; width:var(--strip-w); height:100%; z-index:100; @@ -4598,7 +4679,7 @@ body {{ margin:0; font-family:'Oracle Sans','Segoe UI',Arial,Helvetica,sans-seri .wrap {{ margin-left:calc(var(--strip-w) + 10px); padding:0 28px 20px 20px; max-width:720px; }} /* Oracle brand on every page (via running header simulation) */ -.oracle-brand {{ font-family:'Oracle Sans',Arial,sans-serif; font-weight:700; letter-spacing:.8px; color:var(--oracle-red); font-size:15px; padding:18px 0 12px; }} +.oracle-brand {{ font-family:Arial,Helvetica,'Liberation Sans',sans-serif; font-weight:700; letter-spacing:.8px; color:var(--oracle-red); font-size:15px; padding:18px 0 12px; }} a {{ color:#2f5597; text-decoration:none; }} a:hover {{ text-decoration:underline; }} @@ -4712,6 +4793,14 @@ table.benchmark .section-header td {{ background:#e8ece8; font-weight:700; font- .fill.warn {{ background:var(--warn); }} .fill.ok {{ background:var(--ok); }} +/* Findings resource table */ +.findings-table-wrap {{ margin:10px 0 0; }} +.findings-table {{ width:100%; border-collapse:collapse; font-size:9.5px; border:1px solid #ccc; }} +.findings-table thead th {{ background:#4a6741; color:#fff; padding:4px 6px; text-align:left; font-size:8.5px; text-transform:uppercase; letter-spacing:.3px; white-space:nowrap; }} +.findings-table tbody td {{ padding:3px 6px; border-top:1px solid #e5e5e5; color:#374151; overflow:hidden; text-overflow:ellipsis; max-width:180px; white-space:nowrap; }} +.findings-table tbody tr:nth-child(even) {{ background:#f4f6f4; }} +.findings-note {{ font-size:9px; color:var(--muted); margin:4px 0 0; font-style:italic; }} + /* Print button */ .print-btn {{ position:fixed; bottom:20px; right:20px; padding:12px 24px; background:var(--oracle-red); color:#fff; border:none; border-radius:6px; font-size:14px; font-weight:700; cursor:pointer; box-shadow:0 4px 12px rgba(0,0,0,.25); z-index:999; }} .print-btn:hover {{ background:#a83a2c; }} @@ -4852,12 +4941,14 @@ def _generate_compliance_report(rid: str, user_id: str, force_rag: bool = True) tenancy_name = report["tenancy_name"] or "Unknown" extract_date = rows[0].get("extract_date", "") if rows else "" - # Build filename → file_id map for download links + # Build filename → file_id and filename → file_path maps file_id_map = {} + file_path_map = {} with db() as c: - rf = c.execute("SELECT id, file_name FROM report_files WHERE report_id=?", (rid,)).fetchall() + rf = c.execute("SELECT id, file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall() for f in rf: file_id_map[f["file_name"]] = f["id"] + file_path_map[f["file_name"]] = f["file_path"] ALLOWED_SECTIONS = { "Asset Management", "Compute", "Identity and Access Management", @@ -4926,7 +5017,7 @@ def _generate_compliance_report(rid: str, user_id: str, force_rag: bool = True) passed = sum(1 for x in items if x["__compliant"]) sections[sec] = {"items": items, "total": len(items), "passed": passed, "failed": len(items) - passed} - html = _build_compliance_html(filtered, tenancy_name, extract_date, sections, rid, file_id_map) + html = _build_compliance_html(filtered, tenancy_name, extract_date, sections, rid, file_id_map, file_path_map) # Cache to disk cache_path = rdir / "compliance_report.html" @@ -4935,11 +5026,9 @@ def _generate_compliance_report(rid: str, user_id: str, force_rag: bool = True) return html -@app.get("/api/reports/{rid}/compliance-report") -async def compliance_report(rid: str, regen: int = Query(0), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))): +@app.api_route("/api/reports/{rid}/compliance-report", methods=["GET", "HEAD"]) +async def compliance_report(rid: str, regen: int = Query(0)): """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" @@ -4964,8 +5053,15 @@ async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(cu if not csv_path.exists(): raise HTTPException(404, "cis_summary_report.csv not found") - # Mark as generating BEFORE deleting cache (prevents race condition) + # Reject if already generating marker = rdir / ".compliance_generating" + if marker.exists(): + import time as _time + 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 @@ -5002,12 +5098,22 @@ async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(cu @app.get("/api/reports/{rid}/compliance-report/status") async def compliance_report_status(rid: str): """Check compliance report status: ready, generating, or not started.""" + import time as _time 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(): + # Auto-expire marker after 10 minutes (orphaned by restart/crash) + 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} return {"ready": False, "generating": True} return {"ready": False, "generating": False} @@ -7023,6 +7129,10 @@ async def startup(): orphaned = c.execute("UPDATE reports SET status='failed', error_msg='Interrompido: container reiniciado' WHERE status='running'").rowcount if orphaned: log.warning(f"Marked {orphaned} orphaned running report(s) as failed") + # Clean up orphaned .compliance_generating markers (left after container restart) + for marker in REPORTS.glob("*/.compliance_generating"): + marker.unlink(missing_ok=True) + log.info(f"Cleaned orphaned compliance marker: {marker.parent.name}") # Auto-register CIS Compliance Scanner MCP server if not present (deduplicate on startup) with db() as c: dupes = c.execute("SELECT id FROM mcp_servers WHERE name='CIS Compliance Scanner' ORDER BY created_at ASC").fetchall() diff --git a/frontend-react/src/api/endpoints/reports.ts b/frontend-react/src/api/endpoints/reports.ts index a61b5d4..7cc5799 100644 --- a/frontend-react/src/api/endpoints/reports.ts +++ b/frontend-react/src/api/endpoints/reports.ts @@ -95,15 +95,9 @@ export const reportsApi = { summary: (rid: string) => client.get(`/reports/${rid}/summary`) as unknown as Promise, - htmlUrl: (rid: string) => { - const token = localStorage.getItem('t') || ''; - return `/api/reports/${rid}/html?token=${encodeURIComponent(token)}`; - }, + htmlUrl: (rid: string) => `/api/reports/${rid}/html`, - complianceReportUrl: (rid: string) => { - const token = localStorage.getItem('t') || ''; - return `/api/reports/${rid}/compliance-report?token=${encodeURIComponent(token)}`; - }, + complianceReportUrl: (rid: string) => `/api/reports/${rid}/compliance-report`, generateComplianceReport: (rid: string) => client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>, diff --git a/frontend-react/src/api/endpoints/terraform.ts b/frontend-react/src/api/endpoints/terraform.ts index 8e84f7b..0e2a99b 100644 --- a/frontend-react/src/api/endpoints/terraform.ts +++ b/frontend-react/src/api/endpoints/terraform.ts @@ -111,7 +111,7 @@ export const terraformApi = { client.get(`/chat/sessions/${sid}/messages`) as unknown as Promise<{ messages: ChatMessage[] }>, deleteSession: (sid: string) => - client.delete(`/chat/sessions/${sid}`) as unknown as Promise<{ ok: boolean }>, + client.delete(`/chat/${sid}`) as unknown as Promise<{ ok: boolean }>, pollMessageStatus: (mid: string) => client.get(`/chat/${mid}/status`) as unknown as Promise, diff --git a/frontend-react/src/pages/ChatPage.tsx b/frontend-react/src/pages/ChatPage.tsx index b6d2e93..8cddfea 100644 --- a/frontend-react/src/pages/ChatPage.tsx +++ b/frontend-react/src/pages/ChatPage.tsx @@ -272,13 +272,14 @@ export default function ChatPage() { } catch { /* ignore */ } - if (sessionId === sid) { + const currentSid = useAppStore.getState().chatSessionId; + if (currentSid === sid) { setSessionId(null); setMessages([]); } setSessions((prev) => prev.filter((s) => s.id !== sid)); }, - [sessionId], + [], ); const newChat = useCallback(() => { diff --git a/frontend-react/src/pages/ReportsPage.tsx b/frontend-react/src/pages/ReportsPage.tsx index 0751f4b..fd842a8 100644 --- a/frontend-react/src/pages/ReportsPage.tsx +++ b/frontend-react/src/pages/ReportsPage.tsx @@ -1338,9 +1338,10 @@ export default function ReportsPage() { />
{showCompliance && ( - complianceReady ? ( + complianceReady && !complianceGenerating ? (