feat: LAD A-Team CIS Compliance Report, React SPA, i18n, report management, explorer UX fixes
- Professional Oracle-format compliance report with RAG remediation from ADB vector store - React 19 SPA at /app/ (16 pages, TypeScript, Vite, Zustand, i18n pt/en 625 keys) - Report delete endpoint, embed individual report files into vector store - Terraform ZIP download (client-side, pure JS) - Explorer silent polling during start/stop (no flickering) - HTML report and compliance report sections start minimized - Token auth fix for compliance report CSV download links
This commit is contained in:
738
backend/app.py
738
backend/app.py
@@ -17,7 +17,7 @@ from fastapi import (
|
||||
Query, Body, BackgroundTasks
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
|
||||
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
import jwt as pyjwt
|
||||
@@ -3566,6 +3566,38 @@ async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(requi
|
||||
_audit(u["id"], u["username"], "embed_report", rid, f"{label}, tenancy={tenancy}")
|
||||
return {"ok": True, "message": f"Embedding de {len(documents)} seção(ões) iniciado (tenancy: {tenancy})", "sections": len(documents), "tenancy": tenancy}
|
||||
|
||||
@app.post("/api/embeddings/report/{rid}/file/{fid}")
|
||||
async def embed_report_file(rid: str, fid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||
"""Embed a specific report file (CSV, JSON, TXT, etc.) into ADB vector store."""
|
||||
vid = req.get("adb_config_id")
|
||||
if not vid: raise HTTPException(400, "adb_config_id is required")
|
||||
with db() as c:
|
||||
r = c.execute("SELECT tenancy_name, config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404, "Report not found or not completed")
|
||||
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
|
||||
if not f: raise HTTPException(404, "File not found")
|
||||
p = Path(f["file_path"])
|
||||
if not p.exists(): raise HTTPException(404, "File not found on disk")
|
||||
fname = f["file_name"].lower()
|
||||
allowed = ('.txt', '.csv', '.json', '.md', '.pdf')
|
||||
if not any(fname.endswith(ext) for ext in allowed):
|
||||
raise HTTPException(400, f"Formatos aceitos para embedding: {', '.join(allowed)}")
|
||||
raw = p.read_bytes()
|
||||
if fname.endswith('.pdf'):
|
||||
content = _extract_pdf_text(raw)
|
||||
else:
|
||||
content = raw.decode("utf-8", errors="replace")
|
||||
if not content.strip(): raise HTTPException(400, "File is empty")
|
||||
documents = _chunk_text_file(content, f["file_name"])
|
||||
if not documents: raise HTTPException(400, "No content chunks found")
|
||||
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r.get("config_id"))
|
||||
target_table = req.get("table_name") or None
|
||||
tenancy = r["tenancy_name"] or "unknown"
|
||||
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"],
|
||||
table_name=target_table, tenancy=tenancy)
|
||||
_audit(u["id"], u["username"], "embed_report_file", f"{rid}/{fid}", f"{f['file_name']}, {len(documents)} chunks, tenancy={tenancy}")
|
||||
return {"ok": True, "message": f"Embedding de {f['file_name']} iniciado ({len(documents)} chunks)", "chunks": len(documents)}
|
||||
|
||||
def _extract_pdf_text(file_bytes: bytes) -> str:
|
||||
"""Extract text from a PDF file using PyPDF2 or pdfplumber."""
|
||||
try:
|
||||
@@ -3983,6 +4015,23 @@ async def get_report(rid, u=Depends(current_user)):
|
||||
if u["role"]!="admin" and r["user_id"]!=u["id"]: raise HTTPException(403)
|
||||
return dict(r)
|
||||
|
||||
@app.delete("/api/reports/{rid}")
|
||||
async def delete_report(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
r = c.execute("SELECT id, user_id, status, json_path, html_path FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not r: raise HTTPException(404)
|
||||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||||
if r["status"] == "running": raise HTTPException(400, "Cannot delete a running report")
|
||||
# Remove associated files
|
||||
for path_col in ("json_path", "html_path"):
|
||||
p = r[path_col]
|
||||
if p and Path(p).exists():
|
||||
try: Path(p).unlink()
|
||||
except OSError: pass
|
||||
c.execute("DELETE FROM reports WHERE id=?", (rid,))
|
||||
_audit(u["id"], u["username"], "delete_report", rid)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/reports/{rid}/summary")
|
||||
async def report_summary(rid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
@@ -4064,6 +4113,689 @@ async def report_html(rid):
|
||||
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")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# LAD A-Team CIS Compliance Report
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _extract_section(text: str, section_name: str) -> str:
|
||||
"""Extract a named section from a CIS Benchmark chunk (Description, Remediation, etc.)."""
|
||||
import re as _re
|
||||
pattern = _re.compile(r'\n' + _re.escape(section_name) + r'[:\s]*\n', _re.IGNORECASE)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return ""
|
||||
start = match.end()
|
||||
end_match = _re.search(
|
||||
r'\n(?:Description|Rationale|Audit|Remediation|Additional Information|CIS Controls|Controls\s*\nVersion|References|Default Value|Impact|Recommendation|Profile Applicability)[:\s]*\n',
|
||||
text[start:], _re.IGNORECASE
|
||||
)
|
||||
if end_match:
|
||||
return text[start:start + end_match.start()].strip()
|
||||
return text[start:].strip()
|
||||
|
||||
|
||||
def _extract_remediation_section(text: str) -> str:
|
||||
"""Extract only the Remediation section."""
|
||||
return _extract_section(text, "Remediation")
|
||||
|
||||
|
||||
def _extract_description_section(text: str) -> str:
|
||||
"""Extract the Description section (what this CIS finding is about)."""
|
||||
return _extract_section(text, "Description")
|
||||
|
||||
|
||||
def _format_remediation_html(text: str) -> str:
|
||||
"""Format remediation text with code snippets and proper indentation."""
|
||||
import re as _re
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
def esc(v):
|
||||
return (v or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
lines = text.split('\n')
|
||||
html_parts = []
|
||||
in_code = False
|
||||
code_buf = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
# Detect CLI commands and policy statements
|
||||
is_code = (
|
||||
stripped.startswith('oci ') or
|
||||
stripped.startswith('Allow ') or
|
||||
stripped.startswith('Define ') or
|
||||
stripped.startswith('Endorse ') or
|
||||
stripped.startswith('Admit ') or
|
||||
stripped.startswith('terraform ') or
|
||||
stripped.startswith('curl ') or
|
||||
stripped.startswith('openssl ') or
|
||||
stripped.startswith('ssh ') or
|
||||
stripped.startswith('python') or
|
||||
stripped.startswith('$ ') or
|
||||
stripped.startswith('# ') and not stripped.startswith('# ') and len(stripped) < 5 or
|
||||
(in_code and stripped and not stripped[0].isupper() and not stripped.startswith('http'))
|
||||
)
|
||||
|
||||
if is_code:
|
||||
if not in_code:
|
||||
in_code = True
|
||||
code_buf = []
|
||||
code_buf.append(stripped)
|
||||
else:
|
||||
if in_code and code_buf:
|
||||
html_parts.append('<pre class="code-snippet">' + esc('\n'.join(code_buf)) + '</pre>')
|
||||
code_buf = []
|
||||
in_code = False
|
||||
if stripped:
|
||||
# Numbered steps
|
||||
step_match = _re.match(r'^(\d+)\.\s+(.+)', stripped)
|
||||
if step_match:
|
||||
html_parts.append(f'<div class="rem-step"><span class="step-num">{step_match.group(1)}.</span> {esc(step_match.group(2))}</div>')
|
||||
else:
|
||||
html_parts.append(f'<p class="rem-text">{esc(stripped)}</p>')
|
||||
|
||||
# Flush remaining code
|
||||
if code_buf:
|
||||
html_parts.append('<pre class="code-snippet">' + esc('\n'.join(code_buf)) + '</pre>')
|
||||
|
||||
return '\n'.join(html_parts)
|
||||
|
||||
|
||||
def _search_cis_remediation(title: str, rec_num: str, user_id: str) -> dict:
|
||||
"""Search CIS Recommendations in vector DB. Returns {'description': str, 'remediation': str}."""
|
||||
try:
|
||||
with db() as c:
|
||||
adb_cfgs = c.execute(
|
||||
"SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1", (user_id,)
|
||||
).fetchall()
|
||||
if not adb_cfgs:
|
||||
return {"description": "", "remediation": ""}
|
||||
query = f"Recommendation: {rec_num} {title} Remediation Audit From Console From Command Line"
|
||||
for adb_cfg in adb_cfgs:
|
||||
adb_cfg = dict(adb_cfg)
|
||||
try:
|
||||
# Resolve embedding config: use the ADB's own embedding model
|
||||
emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||||
|
||||
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
||||
if not tables:
|
||||
continue
|
||||
|
||||
# Resolve GenAI config for embedding generation
|
||||
genai_linked = None
|
||||
if adb_cfg.get("genai_config_id"):
|
||||
with db() as c:
|
||||
row = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone()
|
||||
if row: genai_linked = dict(row)
|
||||
emb_genai = _resolve_embed_config(genai_cfg=genai_linked)
|
||||
|
||||
# Search across cisrecom + section-specific tables
|
||||
all_docs = []
|
||||
search_tables = [t["table_name"] for t in tables if "cisrecom" in t["table_name"].lower()]
|
||||
if not search_tables:
|
||||
search_tables = [t["table_name"] for t in tables]
|
||||
|
||||
for tbl_name in search_tables:
|
||||
try:
|
||||
# Detect dimension per table
|
||||
tbl_model = emb_model
|
||||
try:
|
||||
actual_dim = _get_table_embedding_dim(adb_cfg, tbl_name)
|
||||
if actual_dim and actual_dim in _DIM_TO_MODEL:
|
||||
tbl_model = _DIM_TO_MODEL[actual_dim]
|
||||
except Exception:
|
||||
pass
|
||||
query_embedding = _embed_text(query, emb_genai, tbl_model)
|
||||
docs = _vector_search(adb_cfg, query_embedding, top_k=10, table_name=tbl_name)
|
||||
if docs:
|
||||
all_docs.extend(docs)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if all_docs:
|
||||
import re as _re
|
||||
all_docs.sort(key=lambda d: d.get("distance", 999))
|
||||
# STRICT FILTER: only keep chunks that mention THIS exact recommendation number
|
||||
exact_pattern = _re.compile(
|
||||
r'(?:Recommendation[:\s]*' + _re.escape(rec_num) + r'|^' + _re.escape(rec_num) + r')\s',
|
||||
_re.MULTILINE | _re.IGNORECASE
|
||||
)
|
||||
matched = [d["content"] for d in all_docs
|
||||
if d.get("content") and len(d["content"]) > 500
|
||||
and exact_pattern.search(d["content"])]
|
||||
if matched:
|
||||
best = matched[0]
|
||||
desc = _extract_description_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 or best}
|
||||
except Exception as e:
|
||||
log.warning(f"CIS remediation search failed for {adb_cfg.get('config_name','?')}: {e}")
|
||||
return {"description": "", "remediation": ""}
|
||||
except Exception as e:
|
||||
log.warning(f"CIS remediation search error: {e}")
|
||||
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:
|
||||
"""Build the full LAD A-Team CIS Compliance Report HTML."""
|
||||
import re as _re
|
||||
from datetime import datetime as _dt
|
||||
|
||||
now = _dt.utcnow()
|
||||
months_pt = ["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"]
|
||||
month_year = f"{months_pt[now.month - 1]}, {now.year}"
|
||||
|
||||
def esc(v): return (v or "").replace("&","&").replace("<","<").replace(">",">").replace('"',""")
|
||||
def slug(s): return _re.sub(r"[^\w-]","", _re.sub(r"\s+","-",(s or "").strip().lower()))
|
||||
|
||||
total_all = len(filtered_rows)
|
||||
total_passed = sum(1 for r in filtered_rows if r["__compliant"])
|
||||
total_failed = total_all - total_passed
|
||||
pct_global = round(total_passed / total_all * 100) if total_all else 0
|
||||
|
||||
# ── Domains table rows ──
|
||||
domains_rows = ""
|
||||
for sec_name, sd in sections_data.items():
|
||||
domains_rows += f'<tr><td class="dom">{esc(sec_name)}</td><td class="num">{sd["total"]}</td><td class="num fail">{sd["failed"]}</td><td class="num pass">{sd["passed"]}</td></tr>'
|
||||
|
||||
# ── CIS Benchmark Summary table ──
|
||||
summary_rows = ""
|
||||
current_section = ""
|
||||
for r in filtered_rows:
|
||||
sec = r["Section"]
|
||||
rec = r.get("Recommendation #", "")
|
||||
title = r.get("Title", "")
|
||||
is_compliant = r["__compliant"]
|
||||
if sec != current_section:
|
||||
current_section = sec
|
||||
summary_rows += f'<tr class="section-header"><td colspan="3"><b>{esc(sec)}</b></td></tr>'
|
||||
status_cls = "pass" if is_compliant else "fail"
|
||||
status_txt = "OK" if is_compliant else "FAILED"
|
||||
summary_rows += f'<tr><td class="rec-num">{esc(rec)}</td><td>{esc(title)}</td><td class="num {status_cls}">{status_txt}</td></tr>'
|
||||
|
||||
# ── Detailed sections ──
|
||||
sections_html = ""
|
||||
for sec_name, sd in sections_data.items():
|
||||
sec_id = f"section-{slug(sec_name)}"
|
||||
sections_html += f'<div class="detail-section page-break-before" id="{esc(sec_id)}">'
|
||||
sections_html += f'<h1 class="section-num">{esc(sec_name)}</h1>'
|
||||
|
||||
for r in sd["items"]:
|
||||
rec = r.get("Recommendation #", "")
|
||||
title = r.get("Title", "")
|
||||
rec_id = f"rec-{slug(sec_name)}-{slug(rec)}"
|
||||
is_compliant = r["__compliant"]
|
||||
findings = r.get("Findings", "").strip()
|
||||
findings_num = r["__findings"]
|
||||
total_items = r.get("Total", "").strip()
|
||||
compliant_items = r.get("Compliant Items", "").strip()
|
||||
pct = r["__pct"]
|
||||
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_desc = (rag_data.get("description", "") or "").strip()
|
||||
rag_rem = (rag_data.get("remediation", "") or "").strip()
|
||||
|
||||
# Status line
|
||||
if is_compliant:
|
||||
result_html = f'<div class="result-box ok"><b>Result:</b> Tenancy {esc(tenancy_name)}: <span class="pass-text">Compliant</span></div>'
|
||||
else:
|
||||
detail = ""
|
||||
if findings_num is not None and total_items:
|
||||
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_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
|
||||
remediation_html = ""
|
||||
if not is_compliant:
|
||||
rem_text = rag_rem or csv_remediation
|
||||
if rem_text:
|
||||
formatted = _format_remediation_html(rem_text)
|
||||
remediation_html = f'<div class="remediation"><h4>Remediation:</h4><div class="remediation-content">{formatted}</div></div>'
|
||||
|
||||
# Compliance bar
|
||||
bar_html = ""
|
||||
if pct is not None:
|
||||
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_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">📄 {esc(csv_filename)}</a><span class="file-hint">Affected resources details</span></div>'
|
||||
|
||||
sections_html += f'''
|
||||
<div class="finding" id="{esc(rec_id)}">
|
||||
<h2>{esc(rec)} {esc(title)}</h2>
|
||||
{bar_html}
|
||||
{result_html}
|
||||
{findings_file_html}
|
||||
{description_html}
|
||||
{remediation_html}
|
||||
</div>'''
|
||||
|
||||
sections_html += '</div>'
|
||||
|
||||
# ── TOC ──
|
||||
toc_lines = '<div class="toc-line toc-l0"><a href="#cis-summary">CIS Security Assessment Summary</a></div>'
|
||||
toc_lines += '<div class="toc-line toc-l0"><a href="#benchmark-table">CIS Benchmark Recommendation Summary</a></div>'
|
||||
for sec_name, sd in sections_data.items():
|
||||
sec_id = f"section-{slug(sec_name)}"
|
||||
toc_lines += f'<div class="toc-line toc-l0"><a href="#{sec_id}">{esc(sec_name)}</a><span class="toc-count">{sd["total"]}</span></div>'
|
||||
for r in sd["items"]:
|
||||
rec = r.get("Recommendation #", "-")
|
||||
title = r.get("Title", "-")
|
||||
rec_id = f"rec-{slug(sec_name)}-{slug(rec)}"
|
||||
toc_lines += f'<div class="toc-line toc-l1"><a href="#{rec_id}">{esc(rec)} {esc(title)}</a></div>'
|
||||
|
||||
PURPOSE_TEXT = "The purpose of the cloud security assessment is to comprehensively evaluate the organization's security posture in its cloud environment, identifying vulnerabilities and gaps that could compromise data integrity, confidentiality, and availability. This assessment aims to enhance information security maturity, strengthen defenses against cyber threats, ensure compliance with regulations, and foster a culture of security awareness. Through periodic assessments, the organization proactively mitigates risks, ensuring continuous security for cloud-configured assets and maintaining stakeholders' trust in data protection capabilities."
|
||||
DISCLAIMER_TEXT = "This document in any form, software or printed matter, contains proprietary information that is the exclusive property of Oracle. Your access to and use of this confidential material is subject to the terms and conditions of your Oracle software license and service agreement, which has been executed and with which you agree to comply. This document and information contained herein may not be disclosed, copied, reproduced or distributed to anyone outside Oracle without prior written consent of Oracle. This document is not part of your license agreement, nor can it be incorporated into any contractual agreement with Oracle or its subsidiaries or affiliates.\n\nThis document is for informational purposes only and is intended solely to assist you in planning for the implementation and upgrade of the product features described. It is not a commitment to deliver any material, code, or functionality, and should not be relied upon in making purchasing decisions."
|
||||
SECURITY_OVERVIEW = """Oracle's mission is to build cloud infrastructure and platform services for your business to have effective and manageable security to run your mission-critical workloads and store your data with confidence. Oracle Cloud Infrastructure's security approach is based on seven core pillars:
|
||||
|
||||
<b>Customer Isolation</b> — Allow customers to deploy their application and data assets in an environment that commits full isolation from other tenants and Oracle's staff.
|
||||
|
||||
<b>Data Encryption</b> — Protect customer data at-rest and in-transit in a way that allows customers to meet their security and compliance requirements for cryptographic algorithms and key management.
|
||||
|
||||
<b>Security Controls</b> — Offer customers effective and easy-to-use security management solutions that allow them to constrain access to their services and segregate operational responsibilities.
|
||||
|
||||
<b>Visibility</b> — Offer customers comprehensive log data and security analytics that they can use to audit and monitor actions on their resources.
|
||||
|
||||
<b>Secure Hybrid Cloud</b> — Enable customers to use their existing security assets, such as user accounts and policies, as well as third-party security solutions when accessing their cloud resources.
|
||||
|
||||
<b>High Availability</b> — Offer fault-independent data centers that enable high availability scale-out architectures and are resilient against network attacks.
|
||||
|
||||
<b>Verifiably Secure Infrastructure</b> — Follow rigorous processes and use effective security controls in all phases of cloud service development and operation."""
|
||||
|
||||
return f'''<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>LAD A-Team CIS Compliance Report — {esc(tenancy_name)}</title>
|
||||
<style>
|
||||
@page {{ size: A4; margin: 14mm; }}
|
||||
@media print {{ .no-print {{ display: none !important; }} body {{ background: #fff !important; }} }}
|
||||
html,body {{ -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
:root {{
|
||||
--text:#0f172a; --muted:#64748b; --line:#e2e8f0; --soft:#f8fafc;
|
||||
--oracle-red:#ff0000; --header-blue:#2f5597; --row-alt:#e8f0f7;
|
||||
--fail:#c00000; --pass:#0f7b0f; --bad:#ef4444; --warn:#f59e0b; --ok:#22c55e;
|
||||
}}
|
||||
body {{ margin:0; font-family:Arial,Helvetica,sans-serif; color:var(--text); background:#fff; font-size:12px; line-height:1.5; }}
|
||||
.wrap {{ max-width:960px; margin:0 auto; padding:0 14px; }}
|
||||
a {{ color:var(--header-blue); text-decoration:none; }}
|
||||
a:hover {{ text-decoration:underline; }}
|
||||
.page-break-before {{ break-before:page; page-break-before:always; }}
|
||||
|
||||
/* Cover */
|
||||
.cover {{ position:relative; min-height:calc(297mm - 28mm); page-break-after:always; }}
|
||||
.cover-oracle {{ position:absolute; top:0; left:0; font-weight:700; letter-spacing:.6px; color:var(--oracle-red); font-size:18px; }}
|
||||
.cover-rect {{ position:absolute; top:26mm; left:6mm; right:6mm; height:82mm; border:1px solid #9ca3af; }}
|
||||
.cover-title {{ position:absolute; left:0; bottom:62mm; font-size:42px; line-height:1.08; font-weight:500; color:#111827; max-width:165mm; }}
|
||||
.cover-subtitle {{ position:absolute; left:0; bottom:52mm; font-size:12px; color:#111827; }}
|
||||
.cover-meta {{ position:absolute; left:0; bottom:18mm; font-size:11px; color:#111827; line-height:1.45; }}
|
||||
|
||||
/* Text blocks */
|
||||
.text-block {{ border:1px solid var(--line); border-radius:10px; padding:14px 18px; margin:0 0 14px; break-inside:avoid; page-break-inside:avoid; }}
|
||||
.text-block h2 {{ font-size:14px; margin:0 0 8px; color:var(--oracle-red); }}
|
||||
.text-block p {{ margin:0; font-size:11px; line-height:1.5; white-space:pre-wrap; overflow-wrap:anywhere; text-align:justify; }}
|
||||
|
||||
/* TOC */
|
||||
.toc {{ margin:0 0 14px; }}
|
||||
.toc-brand {{ color:var(--oracle-red); font-weight:700; letter-spacing:.6px; font-size:16px; margin:0 0 6px; }}
|
||||
.toc-heading {{ color:var(--oracle-red); font-size:14px; margin:0 0 10px; }}
|
||||
.toc-rule {{ height:1px; background:#cbd5e1; margin:0 0 10px; }}
|
||||
.toc-lines {{ display:flex; flex-direction:column; gap:5px; font-size:11px; }}
|
||||
.toc-line {{ display:flex; align-items:baseline; gap:6px; }}
|
||||
.toc-l0 {{ font-weight:700; }}
|
||||
.toc-l1 {{ padding-left:16px; font-weight:400; color:#374151; }}
|
||||
.toc-line a {{ flex:1; min-width:0; overflow-wrap:anywhere; }}
|
||||
.toc-count {{ font-weight:700; color:var(--muted); min-width:20px; text-align:right; }}
|
||||
|
||||
/* Summary tables */
|
||||
.summary-card {{ border:1px solid var(--line); border-radius:10px; padding:14px 18px; margin:0 0 14px; }}
|
||||
.summary-card h1 {{ font-size:16px; margin:0 0 4px; }}
|
||||
.summary-card .sub {{ font-size:11px; color:var(--muted); margin:0 0 10px; }}
|
||||
table.domains {{ width:100%; border-collapse:collapse; border:1px solid #cbd5e1; font-size:11px; }}
|
||||
table.domains thead th {{ background:var(--header-blue); color:#fff; padding:7px 10px; text-transform:uppercase; letter-spacing:.4px; font-size:10px; text-align:center; border-right:1px solid rgba(255,255,255,.2); }}
|
||||
table.domains thead th:first-child {{ text-align:left; }}
|
||||
table.domains thead th:last-child {{ border-right:none; }}
|
||||
table.domains tbody td {{ padding:6px 10px; border-top:1px solid #cbd5e1; border-right:1px solid #cbd5e1; }}
|
||||
table.domains tbody td:last-child {{ border-right:none; }}
|
||||
table.domains tbody tr:nth-child(odd) {{ background:var(--row-alt); }}
|
||||
.dom {{ font-weight:600; }}
|
||||
.num {{ text-align:center; font-variant-numeric:tabular-nums; }}
|
||||
.fail {{ color:var(--fail); font-weight:700; }}
|
||||
.pass {{ color:var(--pass); font-weight:700; }}
|
||||
.totals {{ font-size:12px; margin-top:8px; color:var(--muted); }}
|
||||
|
||||
/* Benchmark summary table */
|
||||
table.benchmark {{ width:100%; border-collapse:collapse; border:1px solid #cbd5e1; font-size:11px; }}
|
||||
table.benchmark thead th {{ background:var(--header-blue); color:#fff; padding:7px 10px; text-align:left; font-size:10px; text-transform:uppercase; border-right:1px solid rgba(255,255,255,.2); }}
|
||||
table.benchmark thead th:last-child {{ text-align:center; border-right:none; }}
|
||||
table.benchmark tbody td {{ padding:5px 10px; border-top:1px solid #e5e7eb; }}
|
||||
table.benchmark .section-header td {{ background:#f1f5f9; font-weight:700; font-size:11px; padding:8px 10px; border-top:2px solid #cbd5e1; }}
|
||||
.rec-num {{ width:40px; font-weight:600; color:var(--header-blue); white-space:nowrap; }}
|
||||
.pass-text {{ color:var(--pass); font-weight:700; }}
|
||||
.fail-text {{ color:var(--fail); font-weight:700; }}
|
||||
|
||||
/* Detail sections */
|
||||
.detail-section {{ margin-top:20px; }}
|
||||
.detail-section h1.section-num {{ font-size:18px; color:var(--header-blue); border-bottom:2px solid var(--header-blue); padding-bottom:6px; margin:0 0 16px; }}
|
||||
.finding {{ border:1px solid var(--line); border-radius:10px; padding:14px 18px; margin:0 0 14px; break-inside:avoid; page-break-inside:avoid; }}
|
||||
.finding h2 {{ font-size:13px; font-weight:700; margin:0 0 8px; color:#111827; line-height:1.35; }}
|
||||
|
||||
/* Result box */
|
||||
.result-box {{ padding:8px 12px; border-radius:8px; font-size:11px; margin:8px 0; }}
|
||||
.result-box.ok {{ background:#f0fdf4; border:1px solid #bbf7d0; }}
|
||||
.result-box.fail {{ background:#fef2f2; border:1px solid #fecaca; }}
|
||||
|
||||
/* Findings file link */
|
||||
.findings-file {{ display:flex; align-items:center; gap:8px; margin:8px 0; padding:8px 12px; background:#fffbeb; border:1px solid #fde68a; border-radius:8px; }}
|
||||
.file-link {{ font-size:12px; font-weight:700; color:var(--header-blue); text-decoration:none; }}
|
||||
.file-link:hover {{ text-decoration:underline; }}
|
||||
.file-hint {{ font-size:10px; color:var(--muted); }}
|
||||
@media print {{ .file-link {{ color:var(--header-blue) !important; }} }}
|
||||
|
||||
/* Description */
|
||||
.finding-desc {{ margin:10px 0 0; }}
|
||||
.finding-desc h4 {{ font-size:12px; margin:0 0 6px; color:var(--header-blue); }}
|
||||
.finding-desc p {{ font-size:11px; line-height:1.6; text-align:justify; color:#374151; margin:0; padding:10px 14px; background:#f0f4ff; border:1px solid #dbeafe; border-radius:8px; }}
|
||||
|
||||
/* Remediation */
|
||||
.remediation {{ margin:10px 0 0; }}
|
||||
.remediation h4 {{ font-size:12px; margin:0 0 6px; color:var(--oracle-red); }}
|
||||
.remediation-content {{ font-size:11px; line-height:1.55; }}
|
||||
.rem-step {{ display:flex; gap:6px; padding:3px 0; align-items:flex-start; }}
|
||||
.rem-step .step-num {{ font-weight:700; color:var(--header-blue); min-width:20px; }}
|
||||
.rem-text {{ margin:4px 0; text-align:justify; color:#374151; }}
|
||||
.code-snippet {{ background:#1e293b; color:#e2e8f0; padding:10px 14px; border-radius:8px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; line-height:1.5; overflow-x:auto; white-space:pre-wrap; word-break:break-all; margin:6px 0; border-left:3px solid var(--header-blue); }}
|
||||
|
||||
/* Compliance bar */
|
||||
.compliance-bar {{ display:flex; align-items:center; gap:10px; margin:6px 0 8px; }}
|
||||
.bar-label {{ font-size:11px; font-weight:700; width:100px; white-space:nowrap; }}
|
||||
.bar {{ flex:1; height:10px; background:#eef2ff; border:1px solid #e5e7eb; border-radius:999px; overflow:hidden; }}
|
||||
.fill {{ height:100%; border-radius:999px; }}
|
||||
.fill.bad {{ background:var(--bad); }}
|
||||
.fill.warn {{ background:var(--warn); }}
|
||||
.fill.ok {{ background:var(--ok); }}
|
||||
|
||||
/* Print button */
|
||||
.print-btn {{ position:fixed; bottom:20px; right:20px; padding:12px 24px; background:var(--header-blue); color:#fff; border:none; border-radius:8px; font-size:14px; font-weight:700; cursor:pointer; box-shadow:0 4px 12px rgba(0,0,0,.3); z-index:999; }}
|
||||
.print-btn:hover {{ background:#1e3a6e; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<!-- COVER -->
|
||||
<div class="cover">
|
||||
<div class="cover-oracle">ORACLE</div>
|
||||
<div class="cover-rect"></div>
|
||||
<div class="cover-title">Oracle Cloud Security<br/>Assessment</div>
|
||||
<div class="cover-subtitle">{esc(tenancy_name)} – Oracle Cloud Infrastructure</div>
|
||||
<div class="cover-meta">
|
||||
<div><b>Tenancy:</b> {esc(tenancy_name)}</div>
|
||||
<div>{esc(month_year)}, Version [1.0]</div>
|
||||
{f'<div><b>Extract date:</b> {esc(extract_date)}</div>' if extract_date else ''}
|
||||
<div>Copyright © {now.year}, Oracle and/or its affiliates</div>
|
||||
<div>Confidential – Oracle Restricted</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PURPOSE + DISCLAIMER -->
|
||||
<div class="text-block">
|
||||
<h2>Purpose Statement</h2>
|
||||
<p>{esc(PURPOSE_TEXT)}</p>
|
||||
</div>
|
||||
<div class="text-block">
|
||||
<h2>Disclaimer</h2>
|
||||
<p>{esc(DISCLAIMER_TEXT)}</p>
|
||||
</div>
|
||||
|
||||
<!-- TOC -->
|
||||
<div class="toc page-break-before">
|
||||
<div class="toc-brand">ORACLE</div>
|
||||
<div class="toc-heading">Table of contents</div>
|
||||
<div class="toc-rule"></div>
|
||||
<div class="toc-lines">{toc_lines}</div>
|
||||
</div>
|
||||
|
||||
<!-- SECURITY OVERVIEW -->
|
||||
<div class="text-block page-break-before">
|
||||
<h2>Security Overview</h2>
|
||||
<p>{SECURITY_OVERVIEW}</p>
|
||||
</div>
|
||||
|
||||
<!-- CIS SECURITY ASSESSMENT SUMMARY -->
|
||||
<div class="summary-card page-break-before" id="cis-summary">
|
||||
<h1>CIS Security Assessment</h1>
|
||||
<div class="sub">CIS Oracle Cloud Infrastructure Foundations Benchmark v3.0.0</div>
|
||||
<p style="margin-bottom:12px">Number of controls per domain and non-compliant controls on tenancy <b>{esc(tenancy_name)}</b>:</p>
|
||||
<table class="domains">
|
||||
<thead><tr><th style="text-align:left">DOMAINS</th><th>TOTAL CONTROLS</th><th>FAILED</th><th>PASSED</th></tr></thead>
|
||||
<tbody>{domains_rows}</tbody>
|
||||
</table>
|
||||
<div class="totals">Total: <b>{total_all}</b> • Passed: <b class="pass">{total_passed}</b> • Failed: <b class="fail">{total_failed}</b> • Compliance: <b>{pct_global}%</b></div>
|
||||
</div>
|
||||
|
||||
<!-- CIS BENCHMARK RECOMMENDATION SUMMARY -->
|
||||
<div class="summary-card page-break-before" id="benchmark-table">
|
||||
<h1>CIS Benchmark Recommendation Summary</h1>
|
||||
<div class="sub">Status per recommendation</div>
|
||||
<table class="benchmark">
|
||||
<thead><tr><th style="width:50px">Item</th><th>Description</th><th style="width:70px;text-align:center">Status</th></tr></thead>
|
||||
<tbody>{summary_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- DETAILED SECTIONS -->
|
||||
{sections_html}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Print/PDF button -->
|
||||
<button class="print-btn no-print" onclick="window.print()">🖶 Download PDF</button>
|
||||
<script>
|
||||
// Add auth token to findings file download links
|
||||
document.addEventListener('DOMContentLoaded', function() {{
|
||||
var token = '';
|
||||
try {{ token = new URLSearchParams(window.location.search).get('token') || ''; }} catch(e) {{}}
|
||||
if (!token) try {{ token = window.parent.localStorage.getItem('t') || ''; }} catch(e) {{}}
|
||||
if (!token) try {{ token = localStorage.getItem('t') || ''; }} catch(e) {{}}
|
||||
document.querySelectorAll('.file-link').forEach(function(a) {{
|
||||
if (token && a.href.includes('/download')) {{
|
||||
a.href += (a.href.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
|
||||
}}
|
||||
}});
|
||||
}});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
def _generate_compliance_report(rid: str, user_id: str, force_rag: bool = True) -> str:
|
||||
"""Generate and cache the LAD A-Team CIS Compliance Report HTML."""
|
||||
import csv as csvmod
|
||||
import re as _re
|
||||
|
||||
with db() as c:
|
||||
report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not report:
|
||||
raise HTTPException(404, "Report not found")
|
||||
|
||||
rdir = REPORTS / rid
|
||||
csv_path = rdir / "cis_summary_report.csv"
|
||||
if not csv_path.exists():
|
||||
raise HTTPException(404, "cis_summary_report.csv not found")
|
||||
|
||||
rows = []
|
||||
with open(csv_path, "r", encoding="utf-8") as f:
|
||||
for row in csvmod.DictReader(f):
|
||||
rows.append(row)
|
||||
if not rows:
|
||||
raise HTTPException(404, "No data in summary CSV")
|
||||
|
||||
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
|
||||
file_id_map = {}
|
||||
with db() as c:
|
||||
rf = c.execute("SELECT id, file_name FROM report_files WHERE report_id=?", (rid,)).fetchall()
|
||||
for f in rf:
|
||||
file_id_map[f["file_name"]] = f["id"]
|
||||
|
||||
ALLOWED_SECTIONS = {
|
||||
"Asset Management", "Compute", "Identity and Access Management",
|
||||
"Logging and Monitoring", "Networking",
|
||||
"Storage - Block Volumes", "Storage - File Storage Service", "Storage - Object Storage",
|
||||
}
|
||||
|
||||
def norm_section(raw):
|
||||
s = _re.sub(r"\s+", " ", (raw or "").strip())
|
||||
if _re.match(r"^Logging and Monitoring\s*\d*$", s, _re.I): return "Logging and Monitoring"
|
||||
if _re.match(r"^Storage$", s, _re.I): return None
|
||||
if _re.match(r"^Storage\s*-\s*Block Volumes$", s, _re.I): return "Storage - Block Volumes"
|
||||
if _re.match(r"^Storage\s*-\s*File Storage Service$", s, _re.I): return "Storage - File Storage Service"
|
||||
if _re.match(r"^Storage\s*-\s*Object Storage$", s, _re.I): return "Storage - Object Storage"
|
||||
return s or None
|
||||
|
||||
seen = set()
|
||||
filtered = []
|
||||
for r in rows:
|
||||
sec = norm_section(r.get("Section", ""))
|
||||
if not sec or sec not in ALLOWED_SECTIONS: continue
|
||||
key = f"{sec}||{r.get('Recommendation #', '')}||{r.get('Title', '')}".lower()
|
||||
if key in seen: continue
|
||||
seen.add(key)
|
||||
r["Section"] = sec
|
||||
status_raw = (r.get("Compliant", "") or "").strip().lower()
|
||||
r["__compliant"] = status_raw in ("yes", "true", "compliant", "ok")
|
||||
pct_str = (r.get("Compliance Percentage Per Recommendation", "") or "").replace(",", ".")
|
||||
m = _re.search(r"(\d+(\.\d+)?)", pct_str)
|
||||
r["__pct"] = max(0, min(100, float(m.group(1)))) if m else None
|
||||
f_str = (r.get("Findings", "") or "").replace(",", "")
|
||||
fm = _re.search(r"\d+", f_str)
|
||||
r["__findings"] = int(fm.group(0)) if fm else None
|
||||
filtered.append(r)
|
||||
|
||||
# RAG remediations (with per-item timeout)
|
||||
for r in filtered:
|
||||
r["__rag_remediation"] = {"description": "", "remediation": ""}
|
||||
if force_rag:
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
non_compliant = [r for r in filtered if not r["__compliant"]]
|
||||
log.info(f"Compliance report: searching RAG for {len(non_compliant)} non-compliant items")
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
for r in non_compliant:
|
||||
try:
|
||||
fut = pool.submit(_search_cis_remediation,
|
||||
r.get("Title", ""), r.get("Recommendation #", ""), user_id)
|
||||
r["__rag_remediation"] = fut.result(timeout=30)
|
||||
log.info(f" RAG OK: {r.get('Recommendation #', '?')}")
|
||||
except FuturesTimeout:
|
||||
log.warning(f" RAG timeout: {r.get('Recommendation #', '?')}")
|
||||
except Exception as e:
|
||||
log.warning(f" RAG error {r.get('Recommendation #', '?')}: {e}")
|
||||
|
||||
SECTION_ORDER = [
|
||||
"Identity and Access Management", "Networking", "Compute", "Logging and Monitoring",
|
||||
"Storage - Object Storage", "Storage - Block Volumes", "Storage - File Storage Service",
|
||||
"Asset Management",
|
||||
]
|
||||
from collections import OrderedDict
|
||||
sections = OrderedDict()
|
||||
for sec in SECTION_ORDER:
|
||||
items = [r for r in filtered if r["Section"] == sec]
|
||||
if items:
|
||||
items.sort(key=lambda x: [int(p) if p.isdigit() else 0 for p in (x.get("Recommendation #", "0") or "0").split(".")])
|
||||
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)
|
||||
|
||||
# Cache to disk
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
cache_path.write_text(html, encoding="utf-8")
|
||||
log.info(f"Compliance report cached: {cache_path} ({len(html)} bytes)")
|
||||
return html
|
||||
|
||||
|
||||
@app.get("/api/reports/{rid}/compliance-report")
|
||||
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."""
|
||||
rdir = REPORTS / rid
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
|
||||
if regen == 1 and cache_path.exists():
|
||||
cache_path.unlink()
|
||||
|
||||
if cache_path.exists():
|
||||
return FileResponse(cache_path, media_type="text/html")
|
||||
|
||||
raise HTTPException(404, "Compliance report not generated yet")
|
||||
|
||||
|
||||
@app.post("/api/reports/{rid}/compliance-report/generate")
|
||||
async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(current_user)):
|
||||
"""Start generating the compliance report in background."""
|
||||
with db() as c:
|
||||
report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone()
|
||||
if not report: raise HTTPException(404, "Report not found")
|
||||
|
||||
rdir = REPORTS / rid
|
||||
csv_path = rdir / "cis_summary_report.csv"
|
||||
if not csv_path.exists():
|
||||
raise HTTPException(404, "cis_summary_report.csv not found")
|
||||
|
||||
# Delete existing cache to signal "generating"
|
||||
cache_path = rdir / "compliance_report.html"
|
||||
if cache_path.exists():
|
||||
cache_path.unlink()
|
||||
|
||||
has_adb = False
|
||||
with db() as c:
|
||||
adb_cfgs = c.execute("SELECT id FROM adb_vector_configs WHERE user_id=? AND is_active=1", (u["id"],)).fetchall()
|
||||
if adb_cfgs:
|
||||
has_adb = True
|
||||
|
||||
user_id = u["id"]
|
||||
log.info(f"Compliance report generation started for {rid} (has_adb={has_adb})")
|
||||
|
||||
def _bg_generate():
|
||||
try:
|
||||
log.info(f"Compliance report thread running for {rid}")
|
||||
_generate_compliance_report(rid, user_id, force_rag=has_adb)
|
||||
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)
|
||||
|
||||
_chat_executor.submit(_bg_generate)
|
||||
return {"status": "generating", "has_rag": has_adb}
|
||||
|
||||
|
||||
@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()}
|
||||
|
||||
@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))):
|
||||
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||
@@ -4261,11 +4993,11 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
|
||||
base_prompt = cfg_dict.get("system_prompt", "")
|
||||
cfg_dict["system_prompt"] = f"{base_prompt}\n\n{config_hint}" if base_prompt else config_hint
|
||||
|
||||
# ── Terraform agent: boost max_tokens to model limit + force reasoning_effort=high ──
|
||||
# ── Terraform agent: boost max_tokens (capped at 65K to avoid context overflow) + force reasoning_effort=high ──
|
||||
if agent_type == "terraform":
|
||||
tf_model_info = GENAI_MODELS.get(cfg_dict.get("model_id", ""), {})
|
||||
tf_model_max = tf_model_info.get("max_tokens", 32768)
|
||||
cfg_dict["max_tokens"] = tf_model_max
|
||||
cfg_dict["max_tokens"] = min(tf_model_max, 65000)
|
||||
if tf_model_info.get("reasoning") and not cfg_dict.get("reasoning_effort"):
|
||||
cfg_dict["reasoning_effort"] = "HIGH"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user