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''
+ 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''
- # 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''
+ 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''
+ 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 ? (