feat: remove legacy frontend, root routing, compliance ZIP with Chromium PDF, chat markdown styling
- Legacy frontend removed: React SPA served at / (no /app/ prefix) - Compliance report ZIP: Chromium headless --print-to-pdf (identical to browser) + CSV findings - Server-side generation state: .compliance_generating marker survives page navigation - RAG enriched: Description + Rationale + Remediation + Audit extracted from CIS vector chunks - Chat markdown: syntax highlighting (Catppuccin), styled headings/lists/tables/blockquotes/code - Default model auto-selected on Chat Agent load
This commit is contained in:
@@ -4,7 +4,8 @@ WORKDIR /app
|
||||
|
||||
# Install system deps + Terraform CLI
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl gcc libffi-dev unzip && \
|
||||
curl gcc libffi-dev unzip \
|
||||
chromium && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_${ARCH}.zip" -o /tmp/tf.zip && \
|
||||
unzip /tmp/tf.zip -d /usr/local/bin/ && rm /tmp/tf.zip && \
|
||||
|
||||
151
backend/app.py
151
backend/app.py
@@ -4140,6 +4140,16 @@ def _extract_remediation_section(text: str) -> str:
|
||||
return _extract_section(text, "Remediation")
|
||||
|
||||
|
||||
def _extract_audit_section(text: str) -> str:
|
||||
"""Extract the Audit section (verification steps)."""
|
||||
return _extract_section(text, "Audit")
|
||||
|
||||
|
||||
def _extract_rationale_section(text: str) -> str:
|
||||
"""Extract the Rationale section (why this matters)."""
|
||||
return _extract_section(text, "Rationale")
|
||||
|
||||
|
||||
def _extract_description_section(text: str) -> str:
|
||||
"""Extract the Description section (what this CIS finding is about)."""
|
||||
return _extract_section(text, "Description")
|
||||
@@ -4268,11 +4278,28 @@ def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict:
|
||||
if matched:
|
||||
best = matched[0]
|
||||
desc = _extract_description_section(best)
|
||||
rationale = _extract_rationale_section(best)
|
||||
rem = _extract_remediation_section(best)
|
||||
# Try second chunk if first has no remediation
|
||||
if not rem and len(matched) > 1:
|
||||
rem = _extract_remediation_section(matched[1])
|
||||
return {"description": desc, "remediation": rem}
|
||||
audit = _extract_audit_section(best)
|
||||
# Try additional chunks for fuller content
|
||||
for extra in matched[1:3]:
|
||||
if not rem:
|
||||
rem = _extract_remediation_section(extra)
|
||||
elif len(rem) < 200:
|
||||
extra_rem = _extract_remediation_section(extra)
|
||||
if extra_rem and extra_rem not in rem:
|
||||
rem = rem.rstrip() + "\n\n" + extra_rem
|
||||
if not audit:
|
||||
audit = _extract_audit_section(extra)
|
||||
if not desc:
|
||||
desc = _extract_description_section(extra)
|
||||
if not rationale:
|
||||
rationale = _extract_rationale_section(extra)
|
||||
# Combine remediation + audit for complete guidance
|
||||
full_rem = rem or ""
|
||||
if audit and len(audit) > 50:
|
||||
full_rem = (full_rem + "\n\nAudit / Verification:\n" + audit).strip() if full_rem else audit
|
||||
return {"description": desc, "rationale": rationale, "remediation": full_rem}
|
||||
except Exception as e:
|
||||
log.warning(f"CIS remediation search failed for {adb_cfg.get('config_name','?')}: {e}")
|
||||
return {"description": "", "remediation": ""}
|
||||
@@ -4339,8 +4366,9 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date:
|
||||
csv_remediation = (r.get("Remediation", "") or "").strip()
|
||||
rag_data = r.get("__rag_remediation", {})
|
||||
if isinstance(rag_data, str):
|
||||
rag_data = {"description": "", "remediation": rag_data}
|
||||
rag_data = {"description": "", "rationale": "", "remediation": rag_data}
|
||||
rag_desc = (rag_data.get("description", "") or "").strip()
|
||||
rag_rationale = (rag_data.get("rationale", "") or "").strip()
|
||||
rag_rem = (rag_data.get("remediation", "") or "").strip()
|
||||
|
||||
# Status line
|
||||
@@ -4352,12 +4380,17 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date:
|
||||
detail = f" ({findings_num} of {esc(total_items)} items)"
|
||||
result_html = f'<div class="result-box fail"><b>Result:</b> Tenancy {esc(tenancy_name)}: <span class="fail-text">Non-Compliant{detail}</span></div>'
|
||||
|
||||
# Description (what this CIS finding is about)
|
||||
# Description
|
||||
description_html = ""
|
||||
if not is_compliant and rag_desc:
|
||||
description_html = f'<div class="finding-desc"><h4>Description:</h4><p>{esc(rag_desc)}</p></div>'
|
||||
|
||||
# Remediation (only for non-compliant) — formatted with code snippets
|
||||
# Rationale (why this matters)
|
||||
rationale_html = ""
|
||||
if not is_compliant and rag_rationale:
|
||||
rationale_html = f'<div class="finding-rationale"><h4>Rationale:</h4><p>{esc(rag_rationale)}</p></div>'
|
||||
|
||||
# Remediation — formatted with code snippets
|
||||
remediation_html = ""
|
||||
if not is_compliant:
|
||||
rem_text = rag_rem or csv_remediation
|
||||
@@ -4387,6 +4420,7 @@ def _build_compliance_html(filtered_rows: list, tenancy_name: str, extract_date:
|
||||
{result_html}
|
||||
{findings_file_html}
|
||||
{description_html}
|
||||
{rationale_html}
|
||||
{remediation_html}
|
||||
</div>'''
|
||||
|
||||
@@ -4555,6 +4589,11 @@ table.benchmark .section-header td {{ background:#e8ece8; font-weight:700; font-
|
||||
.finding-desc h4 {{ font-size:11.5px; margin:0 0 6px; color:#2f5597; font-weight:700; }}
|
||||
.finding-desc p {{ font-size:11px; line-height:1.65; text-align:justify; color:#374151; margin:0; padding:10px 14px; background:#f8f9fa; border-left:3px solid #2f5597; }}
|
||||
|
||||
/* Rationale */
|
||||
.finding-rationale {{ margin:10px 0 0; }}
|
||||
.finding-rationale h4 {{ font-size:11.5px; margin:0 0 6px; color:#92400e; font-weight:700; }}
|
||||
.finding-rationale p {{ font-size:11px; line-height:1.65; text-align:justify; color:#374151; margin:0; padding:10px 14px; background:#fffbeb; border-left:3px solid #f59e0b; }}
|
||||
|
||||
/* Remediation */
|
||||
.remediation {{ margin:10px 0 0; }}
|
||||
.remediation h4 {{ font-size:11.5px; margin:0 0 6px; color:var(--oracle-red); font-weight:700; }}
|
||||
@@ -4823,7 +4862,11 @@ 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")
|
||||
|
||||
# Delete existing cache to signal "generating"
|
||||
# Mark as generating BEFORE deleting cache (prevents race condition)
|
||||
marker = rdir / ".compliance_generating"
|
||||
marker.write_text(str(uuid.uuid4()), encoding="utf-8")
|
||||
|
||||
# Delete existing cache
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
if cache_path.exists():
|
||||
cache_path.unlink()
|
||||
@@ -4844,6 +4887,11 @@ async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(cu
|
||||
log.info(f"Compliance report generation completed for {rid}")
|
||||
except Exception as e:
|
||||
log.error(f"Compliance report generation failed for {rid}: {e}", exc_info=True)
|
||||
finally:
|
||||
# Remove generating marker
|
||||
m = REPORTS / rid / ".compliance_generating"
|
||||
if m.exists():
|
||||
m.unlink(missing_ok=True)
|
||||
|
||||
_chat_executor.submit(_bg_generate)
|
||||
return {"status": "generating", "has_rag": has_adb}
|
||||
@@ -4851,9 +4899,90 @@ 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 if the compliance report exists."""
|
||||
cache_path = REPORTS / rid / "compliance_report.html"
|
||||
return {"ready": cache_path.exists()}
|
||||
"""Check compliance report status: ready, generating, or not started."""
|
||||
rdir = REPORTS / rid
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
marker = rdir / ".compliance_generating"
|
||||
if cache_path.exists():
|
||||
return {"ready": True, "generating": False}
|
||||
if marker.exists():
|
||||
return {"ready": False, "generating": True}
|
||||
return {"ready": False, "generating": False}
|
||||
|
||||
|
||||
@app.get("/api/reports/{rid}/compliance-report/download")
|
||||
async def compliance_report_download(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
"""Download ZIP with compliance report PDF (Chromium headless) + CSV files."""
|
||||
import zipfile, io, re as _re, subprocess, tempfile
|
||||
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
|
||||
html_path = rdir / "compliance_report.html"
|
||||
if not html_path.exists():
|
||||
raise HTTPException(404, "Compliance report not generated yet")
|
||||
|
||||
with db() as c:
|
||||
report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not report: raise HTTPException(404)
|
||||
files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall()
|
||||
|
||||
tenancy = report["tenancy_name"] or "report"
|
||||
safe_name = _re.sub(r'[^\w\-]', '_', tenancy)
|
||||
file_map = {f["file_name"]: f["file_path"] for f in files}
|
||||
|
||||
# Prepare HTML for PDF: rewrite CSV links to relative paths
|
||||
html = html_path.read_text(encoding="utf-8")
|
||||
html = _re.sub(r'<button class="print-btn[^"]*"[^>]*>.*?</button>', '', html, flags=_re.DOTALL)
|
||||
html = _re.sub(r'<script>.*?</script>', '', html, flags=_re.DOTALL)
|
||||
for fname in file_map:
|
||||
html = _re.sub(
|
||||
r'href="[^"]*?/download[^"]*?"([^>]*>)\s*\[CSV\]\s*' + _re.escape(fname),
|
||||
f'href="csv/{fname}"\\1[CSV] {fname}',
|
||||
html
|
||||
)
|
||||
|
||||
# Generate PDF via Chromium headless (same engine as browser print)
|
||||
pdf_bytes = None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_html = Path(tmpdir) / "report.html"
|
||||
tmp_pdf = Path(tmpdir) / "report.pdf"
|
||||
tmp_html.write_text(html, encoding="utf-8")
|
||||
result = subprocess.run([
|
||||
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
|
||||
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
|
||||
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
|
||||
f"file://{tmp_html}"
|
||||
], capture_output=True, timeout=120)
|
||||
if tmp_pdf.exists():
|
||||
pdf_bytes = tmp_pdf.read_bytes()
|
||||
log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes")
|
||||
else:
|
||||
log.error(f"Chromium PDF failed: {result.stderr.decode()[:500]}")
|
||||
except Exception as e:
|
||||
log.error(f"PDF generation failed: {e}", exc_info=True)
|
||||
|
||||
if not pdf_bytes:
|
||||
raise HTTPException(500, "PDF generation failed")
|
||||
|
||||
# Build ZIP: PDF + CSVs
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(f"CIS_Compliance_Report_{safe_name}.pdf", pdf_bytes)
|
||||
for fname, fpath in file_map.items():
|
||||
p = Path(fpath)
|
||||
if p.exists() and fname.lower().endswith('.csv'):
|
||||
zf.write(str(p), f"csv/{fname}")
|
||||
|
||||
buf.seek(0)
|
||||
from starlette.responses import Response
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.zip"'}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/reports/{rid}/download")
|
||||
async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||
|
||||
Reference in New Issue
Block a user