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
This commit is contained in:
nogueiraguh
2026-03-20 17:17:42 -03:00
parent fbcb1966d5
commit 2c5f0f2e1c
7 changed files with 141 additions and 36 deletions

View File

@@ -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 && \

View File

@@ -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("&","&amp;").replace("<","&lt;").replace(">","&gt;")
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'<th>{esc(col_label(c))}</th>' for c in display)
rows_html = ""
for row in show:
cells = "".join(f'<td>{esc(fmt(row.get(c, "")))}</td>' for c in display)
rows_html += f"<tr>{cells}</tr>"
note = f'<div class="findings-note">{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 += '</div>'
return f'<div class="findings-table-wrap"><table class="findings-table"><thead><tr>{header}</tr></thead><tbody>{rows_html}</tbody></table>{note}</div>'
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'<div class="compliance-bar"><span class="bar-label">{pct:.0f}% Compliant</span><div class="bar"><div class="fill {cls}" style="width:{pct:.0f}%"></div></div></div>'
# 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'<div class="findings-file"><a href="{dl_url}" target="_blank" class="file-link">[CSV] {esc(csv_filename)}</a><span class="file-hint">Affected resources details</span></div>'
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'<div class="findings-file"><a href="{dl_url}" target="_blank" class="file-link">[CSV] {esc(csv_filename)}</a><span class="file-hint">Affected resources details</span></div>'
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'''
<div class="finding" id="{esc(rec_id)}">
<h2>{esc(rec)} {esc(title)}</h2>
{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()

View File

@@ -95,15 +95,9 @@ export const reportsApi = {
summary: (rid: string) =>
client.get(`/reports/${rid}/summary`) as unknown as Promise<ReportSummary>,
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 }>,

View File

@@ -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<MessageStatus>,

View File

@@ -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(() => {

View File

@@ -1338,9 +1338,10 @@ export default function ReportsPage() {
/>
</div>
{showCompliance && (
complianceReady ? (
complianceReady && !complianceGenerating ? (
<div>
<iframe
key={selectedRid + '-' + complianceReady}
src={reportsApi.complianceReportUrl(selectedRid)}
className="w-full border-0"
style={{ height: 700, background: '#fff' }}
@@ -1356,7 +1357,6 @@ export default function ReportsPage() {
</a>
<button
onClick={startComplianceGeneration}
disabled={complianceGenerating}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors"
style={{ background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)' }}
>

View File

@@ -31,7 +31,7 @@ server {
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_connect_timeout 60s;
add_header X-Frame-Options "DENY" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
}
}