|
|
|
|
@@ -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'<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()
|
|
|
|
|
|