diff --git a/backend/app/main.py b/backend/app/main.py
index 9ddbce5..2704344 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -38,19 +38,17 @@
from app.services.attack_graph import build_attack_graph
app = FastAPI(
- title="OpenEASM V7.5",
- description="OpenEASM V7.5 : EASM défensif avec avertissement juridique bloquant, scoring exécutif, rapports et détection service/version/CVE non exploitante.",
- version="v7.5",
+ title="OpenEASM Beta",
+ description="OpenEASM Beta : EASM défensif avec avertissement juridique bloquant, Graph Explorer, Nmap service/version/CVE non exploitant et rapports professionnels.",
+ version="beta-1.0",
)
-
@app.on_event("startup")
def startup_event():
init_db_with_retry()
-
@app.exception_handler(Exception)
async def openeasm_unhandled_exception_handler(request: Request, exc: Exception):
return JSONResponse(
@@ -68,7 +66,7 @@ async def openeasm_unhandled_exception_handler(request: Request, exc: Exception)
class AuditRequest(BaseModel):
domain: str = Field(..., description="Nom de domaine à auditer, exemple : example.com")
accepted_terms: bool = Field(False, description="L'utilisateur confirme être autorisé ou rester dans le cadre défensif autorisé.")
- terms_token: str | None = Field(None, description="Jeton d'acceptation juridique V7 délivré par /api/legal/accept-terms.")
+ terms_token: str | None = Field(None, description="Jeton d'acceptation juridique délivré par /api/legal/accept-terms.")
class LegalAcceptRequest(BaseModel):
accepted: bool = Field(False, description="Confirme que l'utilisateur a lu et accepté les conditions.")
@@ -80,7 +78,7 @@ class DomainVerificationRequest(BaseModel):
@app.get("/api/health")
async def health():
- return {"status": "ok", "service": "openeasm-v7.5"}
+ return {"status": "ok", "service": "openeasm-beta", "version": "beta-1.0"}
@app.get("/api/legal/terms")
async def api_legal_terms():
@@ -117,7 +115,7 @@ async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_d
if not legal_status.get("accepted"):
raise HTTPException(
status_code=403,
- detail="Acceptation juridique V7 obligatoire avant d'utiliser OpenEASM.",
+ detail="Acceptation juridique obligatoire avant d'utiliser OpenEASM.",
)
client_host = request.client.host if request.client else "unknown"
@@ -182,7 +180,7 @@ async def create_audit(payload: AuditRequest, request: Request, db=Depends(get_d
"id": audit_id,
"domain": domain,
"created_at": created_at,
- "mode": "public_defensive_v7_service_version_cve",
+ "mode": "public_defensive_beta_service_version_cve",
"verification": verification_status,
"domain_profile": domain_profile,
"dns": dns_result,
diff --git a/backend/app/reports/excel_report.py b/backend/app/reports/excel_report.py
index 7012e7e..e9f6032 100644
--- a/backend/app/reports/excel_report.py
+++ b/backend/app/reports/excel_report.py
@@ -1,518 +1,400 @@
from __future__ import annotations
-from pathlib import Path
from datetime import datetime
+from pathlib import Path
import json
+
from openpyxl import Workbook
-from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
+from openpyxl.chart import BarChart, PieChart, Reference
+from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.table import Table, TableStyleInfo
REPORT_DIR = Path("/app/reports")
REPORT_DIR.mkdir(parents=True, exist_ok=True)
-BLACK = "F8F4E8"
-ROW_DARK = "FFF8E8"
-CHARCOAL = "FFFFFF"
-RED = "E50914"
-GOLD = "D4AF37"
-GOLD_LIGHT = "8A5D00"
-WHITE = "17120D"
-MUTED = "5E5750"
-BORDER = "8A6F1D"
+BG = "F6F1E6"
+CARD = "FFFDF7"
+CARD_ALT = "FFF4D6"
+INK = "17120D"
+MUTED = "61584D"
+RED_DARK = "65000B"
+RED = "B00020"
+GOLD = "B8871B"
+GOLD_LIGHT = "FFE2A0"
+GREEN = "1F8F5F"
+ORANGE = "D06B00"
+BORDER = "D7BB74"
+WHITE = "FFFFFF"
SEVERITY_FILL = {
- "critical": "7F0008",
- "high": "E50914",
- "medium": "D4AF37",
- "low": "8A6F1D",
- "info": "555555",
+ "critical": "65000B",
+ "high": "B00020",
+ "medium": "B8871B",
+ "low": "7B5500",
+ "info": "61584D",
}
+
def generate_excel_report(audit: dict) -> str:
wb = Workbook()
ws = wb.active
- ws.title = "Executive Summary"
- _executive_summary(ws, audit)
- _sheet_executive_risk(wb, audit)
+ ws.title = "Synthese Direction"
+ _sheet_summary(ws, audit)
_sheet_action_plan(wb, audit)
_sheet_findings(wb, audit)
- _sheet_subdomains(wb, audit)
- _sheet_ip_inventory(wb, audit)
- _sheet_web_targets(wb, audit)
- _sheet_headers(wb, audit)
- _sheet_tls_score(wb, audit)
- _sheet_dns(wb, audit)
- _sheet_mail(wb, audit)
- _sheet_cti(wb, audit)
- _sheet_passive_cves(wb, audit)
- _sheet_service_scan(wb, audit)
- _sheet_patching_sla(wb, audit)
- _sheet_passive_sources(wb, audit)
- _sheet_guards(wb, audit)
- _sheet_raw_summary(wb, audit)
-
- filename = f"open_easm_v7_{audit['domain'].replace('.', '_')}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.xlsx"
+ _sheet_exposure(wb, audit)
+ _sheet_nmap(wb, audit)
+ _sheet_tls_mail_dns(wb, audit)
+ _sheet_cti_sla(wb, audit)
+ _sheet_graph(wb, audit)
+ _sheet_limits(wb, audit)
+ _sheet_raw(wb, audit)
+
+ filename = f"open_easm_beta_{audit['domain'].replace('.', '_')}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.xlsx"
path = REPORT_DIR / filename
wb.save(path)
return filename
-def _executive_summary(ws, audit):
- _paint_background(ws, rows=34, cols=10)
- ws.merge_cells("A1:J2")
- c = ws["A1"]
- c.value = "OPENEASM V7 - RAPPORT D'EXPOSITION EXTERNE"
- c.font = Font(bold=True, size=18, color=GOLD_LIGHT)
- c.fill = PatternFill("solid", fgColor=BLACK)
- c.alignment = Alignment(horizontal="center", vertical="center")
-
- score = audit.get("score", {})
- tls = audit.get("tls_score", {})
- profile = audit.get("domain_profile", {})
- ip = audit.get("ip_inventory", {})
- sub = audit.get("subdomains", {})
-
- rows = [
- ("Domaine audité", audit.get("domain")),
- ("Date audit", audit.get("created_at")),
- ("Mode", audit.get("mode")),
- ("Profil détecté", profile.get("label")),
- ("Score global", f"{score.get('score')} / {score.get('max_score')}"),
- ("Niveau global", score.get("level")),
- ("Score TLS/SSL", f"{tls.get('global_score')} / 100 ({tls.get('global_level')})"),
- ("IP publiques", ip.get("public_ip_count", 0)),
- ("IP coeur exposition", ip.get("core_public_ip_count", 0)),
- ("IP prestataires tiers", ip.get("third_party_provider_ip_count", 0)),
- ("Sous-domaines publics", sub.get("count", 0)),
- ("CVE potentielles passives", audit.get("passive_cves", {}).get("count", 0)),
- ("Ports ouverts Nmap", audit.get("service_scan", {}).get("count_open_ports", 0)),
- ("CVE service/version", audit.get("service_scan", {}).get("count_cves", 0)),
- ("Durée scan service/version", f"{audit.get('service_scan', {}).get('elapsed_seconds', 0)} s"),
- ]
-
- row = 4
- for k, v in rows:
- ws.cell(row, 1, k)
- ws.cell(row, 2, v)
- ws.cell(row, 1).font = Font(bold=True, color=GOLD_LIGHT)
- ws.cell(row, 2).font = Font(color=WHITE)
- ws.cell(row, 1).fill = PatternFill("solid", fgColor=CHARCOAL)
- ws.cell(row, 2).fill = PatternFill("solid", fgColor=BLACK)
- row += 1
-
- ws.merge_cells("D4:J14")
- ws["D4"] = _conclusion(audit)
- ws["D4"].font = Font(color=WHITE, size=12)
- ws["D4"].alignment = Alignment(wrap_text=True, vertical="top")
- ws["D4"].fill = PatternFill("solid", fgColor=CHARCOAL)
-
- row = 18
- ws.cell(row, 1, "Répartition des constats")
- ws.cell(row, 1).font = Font(bold=True, color=GOLD_LIGHT, size=14)
- row += 2
- ws.cell(row, 1, "Criticité")
- ws.cell(row, 2, "Nombre")
- _header_row(ws, row, 2)
- for label, key in [("Critique","critical"),("Élevée","high"),("Moyenne","medium"),("Faible","low"),("Information","info")]:
- row += 1
- ws.cell(row, 1, label)
- ws.cell(row, 2, score.get("by_severity", {}).get(key, 0))
- ws.cell(row, 1).fill = PatternFill("solid", fgColor=SEVERITY_FILL.get(key, "555555"))
- ws.cell(row, 1).font = Font(bold=True, color=BLACK if key == "medium" else WHITE)
- ws.cell(row, 2).fill = PatternFill("solid", fgColor=CHARCOAL)
- ws.cell(row, 2).font = Font(color=WHITE, bold=True)
-
- ws.merge_cells("D18:J25")
- ws["D18"] = (
- "Portée : audit défensif V7. Les constats sont localisés pour permettre une correction opérationnelle. "
- "La détection service/version utilise Nmap -sV --version-light sans script exploit, brute, dos ou intrusive."
- )
- ws["D18"].font = Font(color=MUTED)
- ws["D18"].alignment = Alignment(wrap_text=True, vertical="top")
- ws["D18"].fill = PatternFill("solid", fgColor=BLACK)
-
- _format_sheet(ws, freeze=None)
+def _sheet_summary(ws, audit: dict):
+ _paint(ws, 42, 12)
+ ws.merge_cells("A1:L3")
+ ws["A1"] = "OPENEASM BETA — RAPPORT D'EXPOSITION EXTERNE"
+ ws["A1"].font = Font(bold=True, size=19, color=GOLD_LIGHT)
+ ws["A1"].alignment = Alignment(horizontal="center", vertical="center")
+ ws["A1"].fill = PatternFill("solid", fgColor=RED_DARK)
-def _sheet_executive_risk(wb, audit):
- ws = wb.create_sheet("Executive Risk")
+ score = audit.get("score", {}) or {}
risk = audit.get("executive_risk", {}) or {}
-
- rows = [
- ["Score exécutif", f"{risk.get('overall_score', 'N/A')} / {risk.get('max_score', 100)}"],
- ["Niveau de risque", risk.get("risk_level", "N/A")],
- ["Posture", risk.get("posture", "N/A")],
- ["Profil", risk.get("profile", "N/A")],
- ["Synthèse direction", risk.get("board_summary", "")],
- ["Méthode", risk.get("method", "")],
+ scan = audit.get("service_scan", {}) or {}
+ ip = audit.get("ip_inventory", {}) or {}
+ sub = audit.get("subdomains", {}) or {}
+ profile = audit.get("domain_profile", {}) or {}
+
+ kpis = [
+ ("Domaine", audit.get("domain")),
+ ("Score global", f"{score.get('score', 'N/A')} / {score.get('max_score', 1000)}"),
+ ("Niveau", score.get("level")),
+ ("Risque exécutif", f"{risk.get('overall_score', 'N/A')} / {risk.get('max_score', 100)}"),
+ ("Posture", risk.get("posture")),
+ ("Profil", profile.get("label")),
+ ("IP publiques", ip.get("public_ip_count", 0)),
+ ("Sous-domaines", sub.get("count", 0)),
+ ("Ports Nmap", scan.get("count_open_ports", 0)),
+ ("CVE service/version", scan.get("count_cves", 0)),
+ ("TLS", f"{audit.get('tls_score', {}).get('global_score', 'N/A')} / 100"),
+ ("Date audit", audit.get("created_at")),
]
- _table_sheet(ws, "EXECUTIVE RISK OVERVIEW", ["Indicateur", "Valeur"], rows)
-
- start = len(rows) + 8
- ws.cell(start, 1, "Piliers de risque")
- ws.cell(start, 1).font = Font(bold=True, color=GOLD_LIGHT, size=14)
-
- headers = ["Pilier", "Score", "Niveau", "Risque", "Constats", "Critique/Élevé", "Recommandation"]
- for col, h in enumerate(headers, 1):
- ws.cell(start + 1, col, h)
- _header_row(ws, start + 1, len(headers))
-
- for idx, p in enumerate(risk.get("pillars", []) or [], start + 2):
- values = [p.get("label"), p.get("score"), p.get("level"), p.get("risk"), p.get("findings_count"), p.get("critical_high_count"), p.get("recommendation")]
- for col, val in enumerate(values, 1):
- ws.cell(idx, col, _excel_value(val))
- ws.cell(idx, col).font = Font(color=WHITE)
- ws.cell(idx, col).fill = PatternFill("solid", fgColor=BLACK if idx % 2 else ROW_DARK)
-
- _format_sheet(ws)
+ row, col = 5, 1
+ for label, value in kpis:
+ _metric_card(ws, row, col, label, value)
+ col += 3
+ if col > 10:
+ col = 1
+ row += 4
+
+ ws.merge_cells("A17:L23")
+ ws["A17"] = risk.get("board_summary") or _conclusion(audit)
+ ws["A17"].font = Font(size=12, color=INK)
+ ws["A17"].alignment = Alignment(wrap_text=True, vertical="top")
+ ws["A17"].fill = PatternFill("solid", fgColor=CARD)
+
+ sev = score.get("by_severity", {}) or {}
+ start = 26
+ ws.cell(start, 1, "Répartition des constats")
+ ws.cell(start, 1).font = Font(bold=True, color=RED_DARK, size=14)
+ ws.cell(start + 1, 1, "Criticité")
+ ws.cell(start + 1, 2, "Nombre")
+ _header_row(ws, start + 1, 2)
+ for idx, key in enumerate(["critical", "high", "medium", "low", "info"], start + 2):
+ ws.cell(idx, 1, key)
+ ws.cell(idx, 2, sev.get(key, 0))
+ ws.cell(idx, 1).fill = PatternFill("solid", fgColor=SEVERITY_FILL.get(key, MUTED))
+ ws.cell(idx, 1).font = Font(bold=True, color=WHITE)
+ ws.cell(idx, 2).fill = PatternFill("solid", fgColor=CARD)
+
+ chart = BarChart()
+ chart.title = "Constats par criticité"
+ chart.y_axis.title = "Nombre"
+ chart.x_axis.title = "Criticité"
+ data = Reference(ws, min_col=2, min_row=start + 1, max_row=start + 6)
+ cats = Reference(ws, min_col=1, min_row=start + 2, max_row=start + 6)
+ chart.add_data(data, titles_from_data=True)
+ chart.set_categories(cats)
+ chart.height = 7
+ chart.width = 14
+ ws.add_chart(chart, "D26")
+
+ ws.merge_cells("A36:L40")
+ ws["A36"] = "Portée Beta : rapport orienté décision et correction opérationnelle. La détection Nmap reste non exploitante. Les versions non exposées ne génèrent pas de CVE inventée."
+ ws["A36"].font = Font(color=MUTED, italic=True)
+ ws["A36"].alignment = Alignment(wrap_text=True, vertical="top")
+ ws["A36"].fill = PatternFill("solid", fgColor=CARD_ALT)
+
+ _format(ws, freeze=None)
def _sheet_action_plan(wb, audit):
ws = wb.create_sheet("Plan Action")
- headers = ["Priorité", "Sévérité", "Catégorie", "Lieu / Source", "Constat", "Action recommandée", "SLA cible", "Applicabilité"]
rows = []
for f in sorted(audit.get("findings", []), key=lambda x: _severity_order(x.get("severity", "info"))):
- loc = f.get("location", {})
sev = f.get("severity", "info")
rows.append([
- _priority_label(sev),
- sev,
- f.get("category", ""),
- _loc(loc),
- f.get("title", ""),
- f.get("recommendation", ""),
- _sla_for(sev),
- ", ".join(f.get("applies_to", [])),
+ _priority_label(sev), sev, f.get("category", ""), _loc(f.get("location", {})),
+ f.get("title", ""), f.get("recommendation", ""), _sla_for(sev), "À qualifier",
])
- _table_sheet(ws, "PLAN D'ACTION PRIORISÉ", headers, rows or [["-", "-", "-", "-", "Aucun constat", "-", "-", "-"]])
+ _table_sheet(ws, "PLAN D'ACTION PRIORISÉ", ["Priorité", "Sévérité", "Catégorie", "Lieu / Source", "Constat", "Action recommandée", "SLA cible", "Statut"], rows or [["Info", "info", "Aucun", "N/A", "Aucun constat", "Maintenir la surveillance", "Suivi", "N/A"]])
+
def _sheet_findings(wb, audit):
ws = wb.create_sheet("Constats")
- headers = ["Sévérité", "Catégorie", "Lieu / Source", "Hostname", "Contrôle", "Record", "Titre", "Description", "Recommandation", "Applicabilité"]
rows = []
for f in sorted(audit.get("findings", []), key=lambda x: _severity_order(x.get("severity", "info"))):
- loc = f.get("location", {})
+ loc = f.get("location", {}) or {}
rows.append([
- f.get("severity"),
- f.get("category"),
- _loc(loc),
- loc.get("hostname"),
- loc.get("control"),
- loc.get("record"),
- f.get("title"),
- f.get("description"),
- f.get("recommendation"),
- ", ".join(f.get("applies_to", [])),
+ f.get("severity"), f.get("category"), _loc(loc), loc.get("hostname"), loc.get("control"),
+ f.get("title"), f.get("description"), f.get("recommendation"), ", ".join(f.get("applies_to", [])),
])
- _table_sheet(ws, "CONSTATS PRIORISÉS AVEC LOCALISATION", headers, rows)
-
-def _sheet_subdomains(wb, audit):
- ws = wb.create_sheet("Sous-domaines")
- sub = audit.get("subdomains", {})
- entries = audit.get("ip_inventory", {}).get("entries", [])
- ip_map = {}
- for e in entries:
- if e.get("source") == "subdomain":
- ip_map[e.get("hostname")] = ", ".join(e.get("ips", []))
- rows = []
- for name in sub.get("subdomains", []):
- rows.append([name, ip_map.get(name, ""), sub.get("source"), sub.get("error") or ""])
- _table_sheet(ws, "SOUS-DOMAINES PUBLICS", ["Sous-domaine", "IP résolues", "Source globale", "Erreur source éventuelle"], rows or [["Aucun", "", sub.get("source"), sub.get("error")]])
-
-def _sheet_ip_inventory(wb, audit):
- ws = wb.create_sheet("Inventaire IP")
- headers = ["IP", "Publique", "Périmètre", "Sources", "Hostnames", "Résolution/CNAME"]
- rows = []
- for item in audit.get("ip_inventory", {}).get("unique_ips", []):
- rows.append([
- item.get("ip"),
- item.get("is_public"),
- item.get("scope"),
- ", ".join(item.get("sources", [])),
- "\n".join(item.get("hostnames", [])),
- "\n".join(item.get("resolved_names", [])),
- ])
- _table_sheet(ws, "INVENTAIRE IP COMPLET", headers, rows)
-
-def _sheet_web_targets(wb, audit):
- ws = wb.create_sheet("Cibles Web")
- headers = ["Hostname", "Joignable", "Schéma", "IP publiques", "IP bloquées", "HTTP", "HTTPS", "URL finale HTTPS"]
- rows = []
- for t in audit.get("web", {}).get("targets", []):
- http = t.get("http") or {}
- https = t.get("https") or {}
- guard = t.get("guard") or {}
- rows.append([
- t.get("hostname"),
- t.get("reachable"),
- t.get("best_scheme"),
- ", ".join(guard.get("public_ips", [])),
- ", ".join(guard.get("blocked_ips", [])),
- f"reachable={http.get('reachable')} status={http.get('status_code')} url={http.get('final_url')}",
- f"reachable={https.get('reachable')} status={https.get('status_code')} url={https.get('final_url')}",
- https.get("final_url"),
- ])
- _table_sheet(ws, "CIBLES WEB", headers, rows)
+ _table_sheet(ws, "CONSTATS LOCALISÉS", ["Sévérité", "Catégorie", "Lieu / Source", "Hostname", "Contrôle", "Titre", "Description", "Recommandation", "Applicabilité"], rows)
-def _sheet_headers(wb, audit):
- ws = wb.create_sheet("Headers HTTP")
- headers = ["Hostname", "Schéma", "Header", "Présent", "Valeur"]
- rows = []
- for t in audit.get("web", {}).get("targets", []):
- for scheme in ("http", "https"):
- data = t.get(scheme) or {}
- sec = data.get("security_headers") or t.get("security_headers") or {}
- if isinstance(sec, dict):
- for h, v in sec.items():
- if isinstance(v, dict):
- rows.append([t.get("hostname"), scheme, h, v.get("present"), v.get("value")])
- raw = data.get("headers") or {}
- if isinstance(raw, dict):
- for h, v in raw.items():
- rows.append([t.get("hostname"), scheme, h, True, v])
- _table_sheet(ws, "HEADERS HTTP ET SÉCURITÉ", headers, rows)
-
-def _sheet_tls_score(wb, audit):
- ws = wb.create_sheet("TLS SSL")
- headers = ["Hostname", "Score", "Niveau", "TLS disponible", "Version", "Expiration jours", "Issuer", "Contrôles"]
- rows = []
- for t in audit.get("tls_score", {}).get("targets", []):
- rows.append([t.get("hostname"), t.get("score"), t.get("level"), t.get("tls_available"), t.get("tls_version"), t.get("days_remaining"), _excel_value(t.get("issuer")), "\n".join(t.get("checks", []))])
- _table_sheet(ws, "TLS / SSL AVANCÉ", headers, rows)
-
-def _sheet_dns(wb, audit):
- ws = wb.create_sheet("DNS")
- rows = []
- for typ, data in audit.get("dns", {}).get("records", {}).items():
- rows.append([typ, data.get("status"), "\n".join(data.get("values", [])), data.get("error")])
- _table_sheet(ws, "DNS", ["Type", "Statut", "Valeurs", "Erreur"], rows)
-
-def _sheet_mail(wb, audit):
- ws = wb.create_sheet("Messagerie")
- rows = [
- ["MX", "\n".join(audit.get("mail", {}).get("mx", {}).get("values", []))],
- ["DMARC", "\n".join(audit.get("mail", {}).get("dmarc_records", []))],
- ["SPF", "\n".join(audit.get("dns", {}).get("spf_records", []))],
- ]
- _table_sheet(ws, "MESSAGERIE", ["Contrôle", "Valeur"], rows)
-
-def _sheet_cti(wb, audit):
- ws = wb.create_sheet("CTI")
- headers = ["IP", "Périmètre", "Hostnames", "Zone", "Statut", "Valeurs / Erreur"]
- rows = []
- for ipr in audit.get("cti", {}).get("ip_reputation_all", audit.get("cti", {}).get("ip_reputation", [])):
- for check in ipr.get("checks", []):
- rows.append([
- ipr.get("ip"), ipr.get("scope"), ", ".join(ipr.get("hostnames", [])),
- check.get("zone"), check.get("status"),
- ", ".join(check.get("values", [])) if check.get("values") else check.get("error") or check.get("detail") or ""
- ])
- _table_sheet(ws, "CTI / RÉPUTATION", headers, rows)
-def _sheet_passive_cves(wb, audit):
- ws = wb.create_sheet("CVE Passives")
- headers = ["Hostname", "Schéma", "Type", "Technologie", "CVE", "Sévérité", "Confiance", "Description", "Preuve", "Recommandation"]
+def _sheet_exposure(wb, audit):
+ ws = wb.create_sheet("Exposition")
rows = []
- for item in audit.get("passive_cves", {}).get("items", []):
- rows.append([item.get("hostname"), item.get("scheme"), item.get("type"), item.get("technology"), item.get("cve"), item.get("severity"), item.get("confidence"), item.get("description"), item.get("evidence"), item.get("recommendation")])
- _table_sheet(ws, "CVE POTENTIELLES PASSIVES", headers, rows or [["Aucun", "", "", "", "", "", "", "Aucune CVE passive détectée via headers HTTP.", "", "Confirmer par audit autorisé si nécessaire."]])
-
-def _sheet_service_scan(wb, audit):
+ for item in audit.get("ip_inventory", {}).get("unique_ips", audit.get("ip_inventory", {}).get("display_ips", [])):
+ rows.append([item.get("ip"), item.get("is_public"), item.get("scope"), ", ".join(item.get("sources", [])), "\n".join(item.get("hostnames", [])[:12])])
+ _table_sheet(ws, "INVENTAIRE IP PUBLIC", ["IP", "Publique", "Périmètre", "Sources", "Hostnames"], rows)
+
+ start = ws.max_row + 3
+ ws.cell(start, 1, "Sous-domaines publics")
+ ws.cell(start, 1).font = Font(bold=True, size=14, color=RED_DARK)
+ sub_rows = [["Sous-domaine", "Source"]]
+ sub = audit.get("subdomains", {}) or {}
+ for name in sub.get("subdomains", [])[:250]:
+ sub_rows.append([name, sub.get("source", "passif")])
+ _write_table(ws, start + 2, ["Sous-domaine", "Source"], sub_rows[1:] or [["Aucun", sub.get("source", "passif")]])
+ _format(ws)
+
+
+def _sheet_nmap(wb, audit):
ws = wb.create_sheet("Nmap Services")
scan = audit.get("service_scan", {}) or {}
- headers = ["Hostname", "Port", "Proto", "Service", "Produit", "Version", "CPE", "CVE", "Sévérité", "CVSS", "Confiance", "Preuve"]
rows = []
for port in scan.get("open_ports", []):
cves = port.get("cves", []) or []
if cves:
for cve in cves:
rows.append([
- port.get("hostname"), port.get("port"), port.get("protocol"), port.get("name"),
- port.get("product"), port.get("version"), "\n".join(port.get("cpe", [])),
- cve.get("cve"), cve.get("severity"), cve.get("cvss"), cve.get("confidence"), cve.get("evidence"),
+ port.get("hostname"), port.get("port"), port.get("protocol"), port.get("name"), port.get("product"),
+ port.get("version") or "Version non exposée", "\n".join(port.get("cpe", [])), cve.get("cve"),
+ cve.get("severity"), cve.get("cvss"), cve.get("confidence"), cve.get("evidence"),
])
else:
rows.append([
- port.get("hostname"), port.get("port"), port.get("protocol"), port.get("name"),
- port.get("product"), port.get("version"), "\n".join(port.get("cpe", [])),
- "", "", "", "", port.get("evidence"),
+ port.get("hostname"), port.get("port"), port.get("protocol"), port.get("name"), port.get("product"),
+ port.get("version") or "Version non exposée", "\n".join(port.get("cpe", [])), "", "", "", "", port.get("evidence"),
])
+ _table_sheet(ws, "NMAP SERVICE / VERSION / CVE — NON EXPLOITANT", ["Hostname", "Port", "Proto", "Service", "Produit", "Version", "CPE", "CVE", "Sévérité", "CVSS", "Confiance", "Preuve"], rows or [["Aucun", "", "", "", "", "", "", "", "", "", "", scan.get("note", "Aucun port détecté")]])
+
+ start = ws.max_row + 3
+ meta = [
+ ["Mode", scan.get("mode")],
+ ["Politique", scan.get("command_policy")],
+ ["Durée", f"{scan.get('elapsed_seconds', 0)} s"],
+ ["Note", scan.get("note")],
+ ["Limite", "Aucune exploitation, aucun bruteforce, aucun DoS, aucun script NSE intrusif."],
+ ]
+ _write_table(ws, start, ["Métadonnée", "Valeur"], meta)
+ _format(ws)
+
- if not rows:
- rows.append(["Aucun", "", "", "", "", "", "", "", "", "", "", scan.get("note", "Aucun port ouvert détecté ou Nmap indisponible.")])
- _table_sheet(ws, "NMAP SERVICE / VERSION / CVE - NON EXPLOITANT", headers, rows)
-
- meta_row = len(rows) + 8
- ws.cell(meta_row, 1, "Mode")
- ws.cell(meta_row, 2, scan.get("mode"))
- ws.cell(meta_row + 1, 1, "Politique commande")
- ws.cell(meta_row + 1, 2, scan.get("command_policy"))
- ws.cell(meta_row + 2, 1, "Temps écoulé")
- ws.cell(meta_row + 2, 2, f"{scan.get('elapsed_seconds', 0)} s")
- ws.cell(meta_row + 3, 1, "Note")
- ws.cell(meta_row + 3, 2, scan.get("note"))
-
-
-def _sheet_patching_sla(wb, audit):
- ws = wb.create_sheet("SLA")
- headers = ["ID", "Sévérité", "Catégorie", "Lieu / Source", "Constat", "Détecté le", "SLA jours", "Échéance", "Statut"]
- finding_by_title = {f.get("title"): f for f in audit.get("findings", [])}
+def _sheet_tls_mail_dns(wb, audit):
+ ws = wb.create_sheet("DNS Mail TLS")
rows = []
- for item in audit.get("patching_sla", {}).get("items", []):
- f = finding_by_title.get(item.get("title"), {})
- rows.append([item.get("id"), item.get("severity"), item.get("category"), _loc(f.get("location", {})), item.get("title"), item.get("detected_at"), item.get("sla_days"), item.get("due_at"), item.get("status")])
- _table_sheet(ws, "SLA PATCHING / TRAITEMENT", headers, rows)
+ for typ, data in (audit.get("dns", {}).get("records", {}) or {}).items():
+ rows.append(["DNS", typ, data.get("status"), "\n".join(data.get("values", [])), data.get("error")])
+ rows.append(["Mail", "MX", "", "\n".join(audit.get("mail", {}).get("mx", {}).get("values", [])), ""])
+ rows.append(["Mail", "DMARC", "", "\n".join(audit.get("mail", {}).get("dmarc_records", [])), ""])
+ for t in audit.get("tls_score", {}).get("targets", []):
+ rows.append(["TLS", t.get("hostname"), t.get("level"), f"{t.get('score')} / 100 | {t.get('tls_version')} | expiration {t.get('days_remaining')} j", "\n".join(t.get("checks", []))])
+ _table_sheet(ws, "DNS / MESSAGERIE / TLS", ["Famille", "Contrôle", "Statut", "Valeur", "Note"], rows)
+
-def _sheet_passive_sources(wb, audit):
- ws = wb.create_sheet("Sources Passives")
- sub = audit.get("subdomains", {})
+def _sheet_cti_sla(wb, audit):
+ ws = wb.create_sheet("CTI et SLA")
rows = []
- sources = sub.get("sources", {})
- if isinstance(sources, dict):
- for name, data in sources.items():
- rows.append([name, data.get("count"), data.get("error")])
- else:
- rows.append([sub.get("source"), sub.get("count"), sub.get("error")])
- _table_sheet(ws, "SOURCES PASSIVES SOUS-DOMAINES", ["Source", "Nombre", "Erreur"], rows)
-
-def _sheet_guards(wb, audit):
- ws = wb.create_sheet("Garde-fous")
- rows = [[k, str(v)] for k, v in audit.get("safety", {}).items()]
- rows.append(["Note", "Les contrôles HTTP/TLS/Nmap sont bloqués lorsqu'une cible ne résout pas vers une IP publique ou résout vers une IP privée/réservée. Nmap V7 est limité à -sV --version-light sans exploitation."])
- _table_sheet(ws, "GARDE-FOUS", ["Contrôle", "Valeur"], rows)
-
-def _sheet_raw_summary(wb, audit):
- ws = wb.create_sheet("Résumé Brut")
+ for ipr in audit.get("cti", {}).get("ip_reputation_all", audit.get("cti", {}).get("ip_reputation", [])):
+ for check in ipr.get("checks", []):
+ rows.append([ipr.get("ip"), ipr.get("scope"), ", ".join(ipr.get("hostnames", [])), check.get("zone"), check.get("status"), ", ".join(check.get("values", [])) if check.get("values") else check.get("error") or check.get("detail") or ""])
+ _table_sheet(ws, "CTI / RÉPUTATION", ["IP", "Périmètre", "Hostnames", "Zone", "Statut", "Détail"], rows)
+
+ start = ws.max_row + 3
+ sla_rows = []
+ for item in audit.get("patching_sla", {}).get("items", []):
+ sla_rows.append([item.get("id"), item.get("severity"), item.get("category"), item.get("title"), item.get("sla_days"), item.get("due_at"), item.get("status")])
+ _write_table(ws, start, ["ID", "Sévérité", "Catégorie", "Constat", "SLA jours", "Échéance", "Statut"], sla_rows or [["N/A", "info", "N/A", "Aucun", "", "", "N/A"]])
+ _format(ws)
+
+
+def _sheet_graph(wb, audit):
+ ws = wb.create_sheet("Graph Explorer")
+ graph = audit.get("attack_graph", {}) or {}
+ metrics = graph.get("metrics", {}) or {}
+ rows = [["Nœuds", metrics.get("nodes", 0)], ["Relations", metrics.get("edges", 0)], ["Commentaire", "Cartographie relationnelle disponible dans l'onglet Graph Explorer de l'application."]]
+ _table_sheet(ws, "GRAPH EXPLORER — SYNTHÈSE", ["Indicateur", "Valeur"], rows)
+
+
+def _sheet_limits(wb, audit):
+ ws = wb.create_sheet("Portee Limites")
+ rows = [
+ ["Nature", "Audit public défensif d'exposition externe."],
+ ["Nmap", "Service/version/port uniquement. Aucun exploit, bruteforce, DoS ou script intrusif."],
+ ["CVE", "Corrélation par version exposée. Une version masquée ne doit pas générer de faux positif."],
+ ["Backports", "Une version apparente peut être corrigée par backport de sécurité côté distribution."],
+ ["Responsabilité", "L'utilisateur doit disposer d'un droit, d'une autorisation explicite ou d'un motif légitime."],
+ ]
+ _table_sheet(ws, "PORTÉE, LIMITES ET RESPONSABILITÉ", ["Point", "Détail"], rows)
+
+
+def _sheet_raw(wb, audit):
+ ws = wb.create_sheet("Resume JSON")
rows = [
["id", audit.get("id")],
["domain", audit.get("domain")],
["created_at", audit.get("created_at")],
- ["score", json.dumps(audit.get("score", {}), ensure_ascii=False)],
- ["domain_profile", json.dumps(audit.get("domain_profile", {}), ensure_ascii=False)],
- ["subdomains_summary", json.dumps({k:v for k,v in audit.get("subdomains", {}).items() if k != "subdomains"}, ensure_ascii=False)],
- ["ip_inventory_summary", json.dumps({k:v for k,v in audit.get("ip_inventory", {}).items() if k not in ("entries","unique_ips")}, ensure_ascii=False)],
+ ["mode", audit.get("mode")],
+ ["score", json.dumps(audit.get("score", {}), ensure_ascii=False, default=str)],
+ ["executive_risk", json.dumps(audit.get("executive_risk", {}), ensure_ascii=False, default=str)[:32000]],
]
- _table_sheet(ws, "RÉSUMÉ BRUT JSON", ["Clé", "Valeur"], rows)
+ _table_sheet(ws, "RÉSUMÉ JSON", ["Clé", "Valeur"], rows)
-def _excel_value(value):
- """Convert complex Python objects into Excel-compatible strings."""
- if value is None:
- return ""
- if isinstance(value, (str, int, float, bool)):
- return value
- if isinstance(value, (dict, list, tuple, set)):
- try:
- return json.dumps(value, ensure_ascii=False, default=str)
- except Exception:
- return str(value)
- return str(value)
+def _metric_card(ws, row, col, label, value):
+ ws.merge_cells(start_row=row, start_column=col, end_row=row, end_column=col + 1)
+ ws.merge_cells(start_row=row + 1, start_column=col, end_row=row + 2, end_column=col + 1)
+ ws.cell(row, col, label)
+ ws.cell(row + 1, col, _excel_value(value))
+ ws.cell(row, col).font = Font(bold=True, color=GOLD, size=9)
+ ws.cell(row + 1, col).font = Font(bold=True, color=INK, size=13)
+ ws.cell(row, col).fill = PatternFill("solid", fgColor=CARD_ALT)
+ ws.cell(row + 1, col).fill = PatternFill("solid", fgColor=CARD)
+ ws.cell(row, col).alignment = Alignment(horizontal="center")
+ ws.cell(row + 1, col).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+
def _table_sheet(ws, title, headers, rows):
- _paint_background(ws, rows=max(len(rows) + 8, 20), cols=max(len(headers), 8))
+ _paint(ws, rows=max(len(rows) + 10, 24), cols=max(len(headers), 8))
ws.merge_cells(start_row=1, start_column=1, end_row=2, end_column=max(len(headers), 8))
ws["A1"] = title
ws["A1"].font = Font(bold=True, size=16, color=GOLD_LIGHT)
ws["A1"].alignment = Alignment(horizontal="center", vertical="center")
- ws["A1"].fill = PatternFill("solid", fgColor=BLACK)
+ ws["A1"].fill = PatternFill("solid", fgColor=RED_DARK)
+ _write_table(ws, 4, headers, rows)
+ _format(ws)
- start_row = 4
- for col, header in enumerate(headers, 1):
- cell = ws.cell(start_row, col, header)
- cell.font = Font(bold=True, color=GOLD_LIGHT)
- cell.fill = PatternFill("solid", fgColor=CHARCOAL)
- cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+def _write_table(ws, start_row, headers, rows):
+ for col, h in enumerate(headers, 1):
+ ws.cell(start_row, col, h)
+ _header_row(ws, start_row, len(headers))
for r_idx, row in enumerate(rows, start_row + 1):
for c_idx, value in enumerate(row, 1):
cell = ws.cell(r_idx, c_idx, _excel_value(value))
- cell.font = Font(color=WHITE)
- cell.fill = PatternFill("solid", fgColor=BLACK if r_idx % 2 else ROW_DARK)
+ cell.font = Font(color=INK)
+ cell.fill = PatternFill("solid", fgColor=CARD if r_idx % 2 else ROW_FILL())
cell.alignment = Alignment(wrap_text=True, vertical="top")
- if c_idx <= 2 and str(value).lower() in SEVERITY_FILL:
- sev = str(value).lower()
- cell.fill = PatternFill("solid", fgColor=SEVERITY_FILL[sev])
- cell.font = Font(bold=True, color=BLACK if sev == "medium" else WHITE)
-
+ if str(value).lower() in SEVERITY_FILL:
+ cell.fill = PatternFill("solid", fgColor=SEVERITY_FILL[str(value).lower()])
+ cell.font = Font(bold=True, color=WHITE)
if rows:
- end_row = start_row + len(rows)
- end_col = len(headers)
- ref = f"A{start_row}:{get_column_letter(end_col)}{end_row}"
try:
- tab = Table(displayName=_safe_table_name(title), ref=ref)
+ ref = f"A{start_row}:{get_column_letter(len(headers))}{start_row + len(rows)}"
+ tab = Table(displayName=_safe_table_name(ws.title + str(start_row)), ref=ref)
tab.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showFirstColumn=False, showLastColumn=False, showRowStripes=False, showColumnStripes=False)
ws.add_table(tab)
except Exception:
pass
- ws.freeze_panes = f"A{start_row+1}"
- _format_sheet(ws)
-def _paint_background(ws, rows=40, cols=10):
+def ROW_FILL():
+ return CARD_ALT
+
+
+def _paint(ws, rows=40, cols=10):
for row in range(1, rows + 1):
for col in range(1, cols + 1):
- ws.cell(row, col).fill = PatternFill("solid", fgColor=BLACK)
+ ws.cell(row, col).fill = PatternFill("solid", fgColor=BG)
+
def _header_row(ws, row, max_col):
for col in range(1, max_col + 1):
- cell = ws.cell(row=row, column=col)
- cell.font = Font(bold=True, color=GOLD_LIGHT)
- cell.fill = PatternFill("solid", fgColor=CHARCOAL)
- cell.alignment = Alignment(horizontal="center")
-
-def _format_sheet(ws, freeze="A5"):
- border = Border(
- left=Side(style="thin", color=BORDER),
- right=Side(style="thin", color=BORDER),
- top=Side(style="thin", color=BORDER),
- bottom=Side(style="thin", color=BORDER),
- )
+ c = ws.cell(row=row, column=col)
+ c.font = Font(bold=True, color=RED_DARK)
+ c.fill = PatternFill("solid", fgColor=CARD_ALT)
+ c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+
+
+def _format(ws, freeze="A5"):
+ border = Border(left=Side(style="thin", color=BORDER), right=Side(style="thin", color=BORDER), top=Side(style="thin", color=BORDER), bottom=Side(style="thin", color=BORDER))
for row in ws.iter_rows():
max_height = 18
for cell in row:
cell.border = border
if cell.value is not None:
cell.alignment = Alignment(wrap_text=True, vertical="top")
- max_height = max(max_height, min(90, 15 + len(str(cell.value if cell.value is not None else '')) // 55 * 12))
+ max_height = max(max_height, min(95, 16 + len(str(cell.value)) // 55 * 11))
ws.row_dimensions[row[0].row].height = max_height
for column_cells in ws.columns:
length = 0
col = get_column_letter(column_cells[0].column)
for cell in column_cells:
if cell.value is not None:
- length = max(length, min(len(str(cell.value if cell.value is not None else '')), 58))
- ws.column_dimensions[col].width = max(14, min(46, length + 2))
+ length = max(length, min(len(str(cell.value)), 62))
+ ws.column_dimensions[col].width = max(13, min(48, length + 2))
if freeze:
ws.freeze_panes = freeze
ws.sheet_view.showGridLines = False
+
+def _excel_value(value):
+ if value is None:
+ return ""
+ if isinstance(value, (str, int, float, bool)):
+ return value
+ return json.dumps(value, ensure_ascii=False, default=str)
+
+
def _safe_table_name(title):
- safe = "".join(ch for ch in title.title() if ch.isalnum())[:24]
+ safe = "".join(ch for ch in str(title).title() if ch.isalnum())[:24]
return safe or "OpenEasmTable"
+
def _loc(loc):
if not isinstance(loc, dict):
return ""
return loc.get("display") or loc.get("path") or loc.get("record") or loc.get("hostname") or loc.get("control") or ""
+
def _conclusion(audit):
return (
f"Le domaine {audit.get('domain')} obtient un score de {audit.get('score', {}).get('score')} / 1000. "
f"Profil : {audit.get('domain_profile', {}).get('label', 'N/A')}. "
- f"IP publiques : {audit.get('ip_inventory', {}).get('public_ip_count', 0)}. "
- f"Sous-domaines : {audit.get('subdomains', {}).get('count', 0)}. "
- f"Score TLS : {audit.get('tls_score', {}).get('global_score', 0)} / 100. "
- f"Ports ouverts Nmap : {audit.get('service_scan', {}).get('count_open_ports', 0)}. "
- f"CVE service/version : {audit.get('service_scan', {}).get('count_cves', 0)}. "
- "La détection V7 reste non exploitante et les constats sont localisés pour faciliter le plan d'action."
+ f"Surface observée : {audit.get('ip_inventory', {}).get('public_ip_count', 0)} IP publiques, "
+ f"{audit.get('subdomains', {}).get('count', 0)} sous-domaines, "
+ f"{audit.get('service_scan', {}).get('count_open_ports', 0)} ports ouverts et "
+ f"{audit.get('service_scan', {}).get('count_cves', 0)} CVE service/version."
)
+
def _severity_order(sev):
- return {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}.get(sev, 5)
+ return {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}.get(str(sev).lower(), 5)
+
def _priority_label(sev):
- return {"critical": "P1 immédiat", "high": "P2 prioritaire", "medium": "P3 planifié", "low": "P4 amélioration", "info": "Information"}.get(sev, "Information")
+ return {"critical": "P1 immédiat", "high": "P2 prioritaire", "medium": "P3 planifié", "low": "P4 amélioration", "info": "Information"}.get(str(sev).lower(), "Information")
+
def _sla_for(sev):
- return {"critical": "< 5 jours", "high": "< 15 jours", "medium": "< 30 jours", "low": "< 90 jours", "info": "Suivi"}.get(sev, "Suivi")
+ return {"critical": "< 5 jours", "high": "< 15 jours", "medium": "< 30 jours", "low": "< 90 jours", "info": "Suivi"}.get(str(sev).lower(), "Suivi")
diff --git a/backend/app/reports/json_report.py b/backend/app/reports/json_report.py
index 03018b7..849cd4f 100644
--- a/backend/app/reports/json_report.py
+++ b/backend/app/reports/json_report.py
@@ -1,17 +1,45 @@
from __future__ import annotations
import json
-from datetime import datetime
+from copy import deepcopy
+from datetime import datetime, timezone
from pathlib import Path
REPORT_DIR = Path("/app/reports")
REPORT_DIR.mkdir(parents=True, exist_ok=True)
+
def generate_json_report(audit: dict) -> str:
- filename = f"open_easm_v7_{audit['domain'].replace('.', '_')}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
+ """Export JSON complet enrichi avec des métadonnées de rapport Beta.
+
+ Le JSON conserve l'audit complet pour rester compatible avec les usages existants,
+ mais ajoute un bloc `report_metadata` exploitable par des outils tiers.
+ """
+ filename = f"open_easm_beta_{audit['domain'].replace('.', '_')}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
path = REPORT_DIR / filename
+ payload = deepcopy(audit)
+ payload["report_metadata"] = {
+ "product": "OpenEASM",
+ "edition": "Beta",
+ "report_profile": "professional_audit",
+ "generated_at_utc": datetime.now(timezone.utc).isoformat(),
+ "report_scope": "External Attack Surface Management - audit defensif public",
+ "non_exploit_policy": {
+ "exploitation": False,
+ "bruteforce": False,
+ "dos": False,
+ "intrusive_nse": False,
+ "description": "Les CVE sont corrélées à partir des versions exposées. Aucune validation par exploitation n'est réalisée.",
+ },
+ "limitations": [
+ "Une version masquée ne permet pas une corrélation CVE fiable.",
+ "Les résultats dépendent des informations publiquement exposées au moment de l'audit.",
+ "Les correctifs backportés par les distributions Linux peuvent rendre une version apparente non vulnérable.",
+ ],
+ }
+
with path.open("w", encoding="utf-8") as f:
- json.dump(audit, f, ensure_ascii=False, indent=2, default=str)
+ json.dump(payload, f, ensure_ascii=False, indent=2, default=str)
return filename
diff --git a/backend/app/reports/pdf_report.py b/backend/app/reports/pdf_report.py
index 78dc2c6..4e8b541 100644
--- a/backend/app/reports/pdf_report.py
+++ b/backend/app/reports/pdf_report.py
@@ -1,17 +1,19 @@
from __future__ import annotations
-from pathlib import Path
from datetime import datetime
from html import escape
+from pathlib import Path
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
+from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
Flowable,
Image as RLImage,
+ KeepTogether,
LongTable,
PageBreak,
Paragraph,
@@ -20,111 +22,161 @@
Table,
TableStyle,
)
-from reportlab.pdfgen.canvas import Canvas
REPORT_DIR = Path("/app/reports")
REPORT_DIR.mkdir(parents=True, exist_ok=True)
-PAGE_BG = colors.HexColor("#F8F4E8")
+PAGE_BG = colors.HexColor("#F6F1E6")
INK = colors.HexColor("#17120D")
-MUTED = colors.HexColor("#5E5750")
-RED = colors.HexColor("#E50914")
-RED_DARK = colors.HexColor("#7F0008")
+MUTED = colors.HexColor("#61584D")
+RED = colors.HexColor("#B00020")
+RED_DARK = colors.HexColor("#65000B")
+RED_SOFT = colors.HexColor("#F6D7D9")
GOLD = colors.HexColor("#B8871B")
-GOLD_LIGHT = colors.HexColor("#FFE29A")
+GOLD_DARK = colors.HexColor("#7B5500")
+GOLD_LIGHT = colors.HexColor("#FFE2A0")
CARD = colors.HexColor("#FFFDF7")
-CARD_ALT = colors.HexColor("#FFF7E0")
-BORDER = colors.HexColor("#D9BE7E")
-ROW_ALT = colors.HexColor("#FFF9EA")
+CARD_ALT = colors.HexColor("#FFF4D6")
+BORDER = colors.HexColor("#D7BB74")
+ROW_ALT = colors.HexColor("#FFF8E7")
WHITE = colors.white
+GREEN = colors.HexColor("#1F8F5F")
+ORANGE = colors.HexColor("#D06B00")
+
+
+class ScoreGauge(Flowable):
+ def __init__(self, value: float, max_value: float = 1000, width: float = 5.7 * cm, height: float = 0.72 * cm):
+ super().__init__()
+ self.value = max(0, min(float(value or 0), float(max_value or 1000)))
+ self.max_value = float(max_value or 1000)
+ self.width = width
+ self.height = height
+
+ def draw(self):
+ pct = self.value / self.max_value if self.max_value else 0
+ c = self.canv
+ c.setFillColor(colors.HexColor("#E7D8B2"))
+ c.roundRect(0, 0, self.width, self.height, 5, fill=1, stroke=0)
+ fill_color = GREEN if pct >= 0.75 else ORANGE if pct >= 0.5 else RED
+ c.setFillColor(fill_color)
+ c.roundRect(0, 0, max(0.18 * cm, self.width * pct), self.height, 5, fill=1, stroke=0)
+ c.setStrokeColor(BORDER)
+ c.roundRect(0, 0, self.width, self.height, 5, fill=0, stroke=1)
+
+
+class SeverityLegend(Flowable):
+ def __init__(self, counts: dict, width: float = 25.5 * cm, height: float = 0.95 * cm):
+ super().__init__()
+ self.counts = counts or {}
+ self.width = width
+ self.height = height
+
+ def draw(self):
+ palette = [
+ ("critical", RED_DARK),
+ ("high", RED),
+ ("medium", GOLD),
+ ("low", GOLD_DARK),
+ ("info", MUTED),
+ ]
+ total = sum(int(self.counts.get(k, 0) or 0) for k, _ in palette) or 1
+ x = 0
+ c = self.canv
+ for key, color in palette:
+ count = int(self.counts.get(key, 0) or 0)
+ w = max(0.7 * cm, self.width * count / total) if count else 0.55 * cm
+ c.setFillColor(color)
+ c.roundRect(x, 0.22 * cm, w, 0.38 * cm, 3, fill=1, stroke=0)
+ c.setFillColor(INK)
+ c.setFont("Helvetica", 6.7)
+ c.drawString(x, 0, f"{key}: {count}")
+ x += w + 0.28 * cm
def generate_pdf_report(audit: dict) -> str:
- filename = f"open_easm_v7_5_{audit['domain'].replace('.', '_')}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.pdf"
+ filename = f"open_easm_beta_{audit['domain'].replace('.', '_')}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.pdf"
path = REPORT_DIR / filename
doc = SimpleDocTemplate(
str(path),
pagesize=landscape(A4),
- rightMargin=1.15 * cm,
- leftMargin=1.15 * cm,
+ rightMargin=1.1 * cm,
+ leftMargin=1.1 * cm,
topMargin=1.35 * cm,
- bottomMargin=1.15 * cm,
- title=f"OpenEASM V7.5 - {audit['domain']}",
+ bottomMargin=1.05 * cm,
+ title=f"OpenEASM Beta - {audit['domain']}",
+ author="OpenEASM",
+ subject="External Attack Surface Management defensive audit",
)
styles = _styles()
story = []
- domain = audit.get("domain", "N/A")
- score = audit.get("score", {}) or {}
risk = audit.get("executive_risk", {}) or {}
+ score = audit.get("score", {}) or {}
scan = audit.get("service_scan", {}) or {}
graph = audit.get("attack_graph", {}) or {}
- story.append(_hero_block(audit, styles))
- story.append(Spacer(1, 0.28 * cm))
-
- kpis = [
- ("Score global", f"{score.get('score', 'N/A')} / {score.get('max_score', 1000)}", score.get("level", "N/A")),
- ("Risque executif", f"{risk.get('overall_score', 'N/A')} / {risk.get('max_score', 100)}", risk.get("risk_level", "N/A")),
- ("Surface exposee", f"{audit.get('ip_inventory', {}).get('public_ip_count', 0)} IP", f"{audit.get('subdomains', {}).get('count', 0)} sous-domaines"),
- ("Nmap", f"{scan.get('count_open_ports', 0)} ports", f"{scan.get('count_cves', 0)} CVE"),
- ]
- story.append(_kpi_cards(kpis, styles))
+ story.append(_cover_block(audit, styles))
story.append(Spacer(1, 0.30 * cm))
-
- story.append(Paragraph("Synthese direction", styles["Section"]))
- story.append(Paragraph(str(risk.get("board_summary", _conclusion(audit))), styles["Body"]))
+ story.append(_kpi_cards(audit, styles))
story.append(Spacer(1, 0.18 * cm))
-
- story.append(_risk_overview(risk, styles))
+ story.append(_score_panel(audit, styles))
story.append(Spacer(1, 0.25 * cm))
- story.append(Paragraph("Plan d'action priorise", styles["Section"]))
- story.append(_actions_table(audit, styles))
+ story.append(Paragraph("Synthèse exécutive", styles["Section"]))
+ story.append(_callout(str(risk.get("board_summary") or _conclusion(audit)), styles, tone="neutral"))
+ story.append(Spacer(1, 0.20 * cm))
+
+ story.append(Paragraph("Plan d'action priorisé", styles["Section"]))
+ story.append(_actions_table(audit, styles, limit=12))
story.append(PageBreak())
- story.append(Paragraph("Constats priorises avec localisation", styles["Section"]))
- story.append(_findings_table(audit, styles))
+ story.append(Paragraph("Vue de risque par pilier", styles["Section"]))
+ story.append(_risk_overview(risk, styles))
+ story.append(Spacer(1, 0.22 * cm))
+ story.append(SeverityLegend(score.get("by_severity", {})))
+ story.append(Spacer(1, 0.28 * cm))
+ story.append(Paragraph("Constats prioritaires localisés", styles["Section"]))
+ story.append(_findings_table(audit, styles, limit=26))
story.append(PageBreak())
story.append(Paragraph("Cartographie de l'exposition", styles["Section"]))
graph_rows = [
["Indicateur", "Valeur", "Lecture"],
- ["Noeuds Graph Explorer", str((graph.get("metrics") or {}).get("nodes", 0)), "Domaines, IP, services, CVE et constats"],
- ["Relations", str((graph.get("metrics") or {}).get("edges", 0)), "Liens DNS, exposition web, ports, CVE"],
- ["IP publiques", str(audit.get("ip_inventory", {}).get("public_ip_count", 0)), "Surface reseau publique observee"],
- ["Sous-domaines", str(audit.get("subdomains", {}).get("count", 0)), "Decouverte passive"],
+ ["Noeuds Graph Explorer", str((graph.get("metrics") or {}).get("nodes", 0)), "Domaines, sous-domaines, IP, services, CVE et constats"],
+ ["Relations", str((graph.get("metrics") or {}).get("edges", 0)), "Liens DNS, exposition web, ports, CVE et constats"],
+ ["IP publiques", str(audit.get("ip_inventory", {}).get("public_ip_count", 0)), "Surface réseau publique observée"],
+ ["Sous-domaines", str(audit.get("subdomains", {}).get("count", 0)), "Découverte passive"],
+ ["Ports Nmap", str(scan.get("count_open_ports", 0)), "Service/version/port, non exploitant"],
]
- story.append(_table(graph_rows, [5.5 * cm, 4 * cm, 16 * cm], styles=styles))
+ story.append(_table(graph_rows, [5.2 * cm, 3.7 * cm, 16.6 * cm], styles=styles))
story.append(Spacer(1, 0.25 * cm))
- story.append(Paragraph("Sous-domaines publics", styles["SectionSmall"]))
- story.append(_subdomains_table(audit, styles))
- story.append(Spacer(1, 0.22 * cm))
-
- story.append(Paragraph("Inventaire IP", styles["SectionSmall"]))
- story.append(_ip_table(audit, styles))
+ cols = [[Paragraph("Sous-domaines publics", styles["SectionSmall"]), _subdomains_table(audit, styles, limit=38)], [Paragraph("Inventaire IP", styles["SectionSmall"]), _ip_table(audit, styles, limit=38)]]
+ story.append(Table([cols], colWidths=[12.8 * cm, 12.8 * cm], style=[("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 8)]))
story.append(PageBreak())
- story.append(Paragraph("Nmap service / version / CVE - non exploitant", styles["Section"]))
- story.append(Paragraph(
- "Controle limite a l'identification des ports ouverts, services et versions. La correlation CVE est realisee cote OpenEASM a partir des versions detectees. Aucun exploit, bruteforce, DoS ou script intrusif n'est execute.",
- styles["Body"],
+ story.append(Paragraph("Nmap service / version / CVE", styles["Section"]))
+ story.append(_callout(
+ "Contrôle non exploitant : identification des ports ouverts, services et versions. La corrélation CVE est effectuée côté OpenEASM à partir des versions détectées. Aucun exploit, bruteforce, DoS ou script intrusif n'est exécuté.",
+ styles,
+ tone="safe",
))
- story.append(Spacer(1, 0.18 * cm))
- story.append(_nmap_table(audit, styles))
+ story.append(Spacer(1, 0.16 * cm))
+ story.append(_nmap_table(audit, styles, limit=60))
story.append(PageBreak())
- story.append(Paragraph("Portee, limites et responsabilite", styles["Section"]))
+ story.append(Paragraph("Annexes : portée, limites et responsabilité", styles["Section"]))
limits = [
- ["Point", "Detail"],
- ["Nature de l'audit", "Audit public defensif d'exposition externe."],
+ ["Point", "Détail"],
+ ["Nature de l'audit", "Audit public défensif d'exposition externe. Les résultats sont issus d'informations visibles publiquement."],
["Nmap", str(scan.get("note", "Service/version/port uniquement, sans exploitation."))],
- ["CVE", "Une CVE n'est affichee que si la version detectee permet une correlation raisonnable. Une version masquee ne doit pas generer de faux positif."],
- ["Responsabilite", "L'utilisateur doit disposer d'un droit, d'une autorisation explicite ou d'un motif legitime de securite informatique."],
+ ["CVE", "Une CVE n'est affichée que si la version détectée permet une corrélation raisonnable. Une version masquée ne doit pas générer de faux positif."],
+ ["Versions masquées", "OpenEASM indique 'version non exposée' et recommande une vérification interne via inventaire serveur, EDR, gestion de parc ou paquet système."],
+ ["Backports", "Les distributions Linux peuvent intégrer des correctifs de sécurité sans changer le numéro de version amont."],
+ ["Responsabilité", "L'utilisateur doit disposer d'un droit, d'une autorisation explicite ou d'un motif légitime de sécurité informatique."],
]
- story.append(_table(limits, [5.0 * cm, 20.5 * cm], styles=styles))
+ story.append(_table(limits, [5.2 * cm, 20.3 * cm], styles=styles))
doc.build(story, onFirstPage=_decorate_page, onLaterPages=_decorate_page)
return filename
@@ -132,39 +184,40 @@ def generate_pdf_report(audit: dict) -> str:
def _styles():
base = getSampleStyleSheet()
- base.add(ParagraphStyle("HeroTitle", parent=base["Title"], textColor=RED_DARK, fontSize=28, leading=32, alignment=TA_LEFT, spaceAfter=3))
- base.add(ParagraphStyle("HeroSubtitle", parent=base["BodyText"], textColor=MUTED, fontSize=10.5, leading=14, spaceAfter=4))
- base.add(ParagraphStyle("Eyebrow", parent=base["BodyText"], textColor=GOLD, fontSize=7.5, leading=9, fontName="Helvetica-Bold", spaceAfter=2))
- base.add(ParagraphStyle("Section", parent=base["Heading2"], textColor=RED_DARK, fontSize=15.5, leading=18, spaceBefore=5, spaceAfter=7))
- base.add(ParagraphStyle("SectionSmall", parent=base["Heading3"], textColor=GOLD, fontSize=11.5, leading=14, spaceBefore=4, spaceAfter=5))
- base.add(ParagraphStyle("Body", parent=base["BodyText"], textColor=INK, fontSize=8.8, leading=12.2))
- base.add(ParagraphStyle("Cell", parent=base["BodyText"], textColor=INK, fontSize=6.9, leading=8.4))
- base.add(ParagraphStyle("CellSmall", parent=base["BodyText"], textColor=INK, fontSize=6.2, leading=7.4))
- base.add(ParagraphStyle("HeaderCell", parent=base["BodyText"], textColor=RED_DARK, fontSize=6.9, leading=8.2, fontName="Helvetica-Bold"))
- base.add(ParagraphStyle("KpiLabel", parent=base["BodyText"], textColor=MUTED, fontSize=7.2, leading=9, fontName="Helvetica-Bold", alignment=TA_CENTER))
- base.add(ParagraphStyle("KpiValue", parent=base["BodyText"], textColor=RED_DARK, fontSize=15, leading=17, fontName="Helvetica-Bold", alignment=TA_CENTER))
- base.add(ParagraphStyle("KpiNote", parent=base["BodyText"], textColor=GOLD, fontSize=7.1, leading=9, alignment=TA_CENTER))
+ base.add(ParagraphStyle("CoverTitle", parent=base["Title"], textColor=RED_DARK, fontSize=29, leading=33, alignment=TA_LEFT, spaceAfter=3))
+ base.add(ParagraphStyle("CoverSubtitle", parent=base["BodyText"], textColor=MUTED, fontSize=10.3, leading=14, spaceAfter=4))
+ base.add(ParagraphStyle("Eyebrow", parent=base["BodyText"], textColor=GOLD_DARK, fontSize=7.5, leading=9, fontName="Helvetica-Bold", spaceAfter=2))
+ base.add(ParagraphStyle("Section", parent=base["Heading2"], textColor=RED_DARK, fontSize=15.2, leading=18, spaceBefore=5, spaceAfter=7))
+ base.add(ParagraphStyle("SectionSmall", parent=base["Heading3"], textColor=GOLD_DARK, fontSize=11.0, leading=13, spaceBefore=3, spaceAfter=5))
+ base.add(ParagraphStyle("Body", parent=base["BodyText"], textColor=INK, fontSize=8.5, leading=12))
+ base.add(ParagraphStyle("Cell", parent=base["BodyText"], textColor=INK, fontSize=6.8, leading=8.3))
+ base.add(ParagraphStyle("CellSmall", parent=base["BodyText"], textColor=INK, fontSize=6.15, leading=7.35))
+ base.add(ParagraphStyle("HeaderCell", parent=base["BodyText"], textColor=RED_DARK, fontSize=6.8, leading=8.2, fontName="Helvetica-Bold"))
return base
-def _hero_block(audit: dict, styles):
+def _cover_block(audit: dict, styles):
domain = audit.get("domain", "N/A")
- date = audit.get("created_at", datetime.utcnow().isoformat())
+ created = audit.get("created_at", datetime.utcnow().isoformat())
logo_path = Path(__file__).resolve().parents[1] / "static" / "assets" / "cyborg.png"
- logo_block = [Paragraph("OPENEASM V7.5", styles["HeroTitle"])]
+ left = []
if logo_path.exists():
- logo_block.insert(0, RLImage(str(logo_path), width=1.8 * cm, height=1.9 * cm))
- data = [[
- logo_block,
- Paragraph("Rapport d'exposition externe - audit defensif service/version/CVE non exploitant", styles["HeroSubtitle"]),
- Paragraph(f"Domaine audite : {escape(domain)}
Generation : {escape(str(date))}
Livrable : PDF executif et technique", styles["HeroSubtitle"]),
- ]]
- t = Table(data, colWidths=[7.2 * cm, 10 * cm, 8.6 * cm])
+ left.append(RLImage(str(logo_path), width=2.05 * cm, height=1.55 * cm))
+ left.extend([
+ Paragraph("OPEN EASM BETA", styles["CoverTitle"]),
+ Paragraph("Rapport professionnel d'exposition externe", styles["CoverSubtitle"]),
+ ])
+ right = Paragraph(
+ f"Domaine : {escape(domain)}
Génération : {escape(str(created))}
Mode : audit défensif public, service/version/CVE non exploitant
Livrables : PDF, Excel, JSON",
+ styles["CoverSubtitle"],
+ )
+ t = Table([[left, right]], colWidths=[15.8 * cm, 9.8 * cm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), CARD),
- ("BOX", (0, 0), (-1, -1), 0.8, BORDER),
- ("LEFTPADDING", (0, 0), (-1, -1), 12),
- ("RIGHTPADDING", (0, 0), (-1, -1), 12),
+ ("BOX", (0, 0), (-1, -1), 0.9, BORDER),
+ ("LINEBEFORE", (1, 0), (1, 0), 0.7, BORDER),
+ ("LEFTPADDING", (0, 0), (-1, -1), 13),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 13),
("TOPPADDING", (0, 0), (-1, -1), 12),
("BOTTOMPADDING", (0, 0), (-1, -1), 12),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
@@ -172,89 +225,130 @@ def _hero_block(audit: dict, styles):
return t
-def _kpi_cards(items, styles):
+def _kpi_cards(audit: dict, styles):
+ score = audit.get("score", {}) or {}
+ risk = audit.get("executive_risk", {}) or {}
+ scan = audit.get("service_scan", {}) or {}
+ items = [
+ ("Score global", f"{score.get('score', 'N/A')} / {score.get('max_score', 1000)}", score.get("level", "N/A")),
+ ("Risque exécutif", f"{risk.get('overall_score', 'N/A')} / {risk.get('max_score', 100)}", risk.get("risk_level", "N/A")),
+ ("Surface publique", f"{audit.get('ip_inventory', {}).get('public_ip_count', 0)} IP", f"{audit.get('subdomains', {}).get('count', 0)} sous-domaines"),
+ ("Nmap", f"{scan.get('count_open_ports', 0)} ports", f"{scan.get('count_cves', 0)} CVE corrélées"),
+ ]
row = []
for label, value, note in items:
- row.append(Paragraph(f"{escape(str(label))}
{escape(str(value))}
{escape(str(note))}", styles["Body"]))
- table = Table([row], colWidths=[6.3 * cm] * 4)
- table.setStyle(TableStyle([
+ row.append(Paragraph(f"{escape(str(label))}
{escape(str(value))}
{escape(str(note))}", styles["Body"]))
+ t = Table([row], colWidths=[6.25 * cm] * 4)
+ t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), CARD),
("BOX", (0, 0), (-1, -1), 0.7, BORDER),
- ("INNERGRID", (0, 0), (-1, -1), 0.4, BORDER),
+ ("INNERGRID", (0, 0), (-1, -1), 0.35, BORDER),
("LEFTPADDING", (0, 0), (-1, -1), 9),
("RIGHTPADDING", (0, 0), (-1, -1), 9),
- ("TOPPADDING", (0, 0), (-1, -1), 10),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
+ ("TOPPADDING", (0, 0), (-1, -1), 8),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
- return table
+ return t
+
+
+def _score_panel(audit: dict, styles):
+ score = audit.get("score", {}) or {}
+ risk = audit.get("executive_risk", {}) or {}
+ left = [Paragraph("Lecture rapide", styles["SectionSmall"]), Paragraph(_conclusion(audit), styles["Body"])]
+ right = [Paragraph("Score global", styles["SectionSmall"]), ScoreGauge(score.get("score", 0), score.get("max_score", 1000)), Spacer(1, 0.12 * cm), Paragraph(f"Niveau : {escape(str(score.get('level', 'N/A')))} | Posture : {escape(str(risk.get('posture', 'N/A')))}", styles["Body"])]
+ t = Table([[left, right]], colWidths=[13.0 * cm, 12.5 * cm])
+ t.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, -1), CARD),
+ ("BOX", (0, 0), (-1, -1), 0.6, BORDER),
+ ("LEFTPADDING", (0, 0), (-1, -1), 9),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 9),
+ ("TOPPADDING", (0, 0), (-1, -1), 7),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ]))
+ return t
def _risk_overview(risk: dict, styles):
- rows = [["Pilier", "Score", "Niveau", "Risque", "Constats"]]
+ rows = [["Pilier", "Score", "Niveau", "Risque", "Constats", "Recommandation"]]
for p in risk.get("pillars", []) or []:
score = p.get("score", "N/A")
- rows.append([p.get("label", ""), f"{score} / 100" if score != "N/A" else "N/A", p.get("level", ""), p.get("risk", ""), str(p.get("findings_count", 0))])
+ rows.append([p.get("label", ""), f"{score} / 100" if score != "N/A" else "N/A", p.get("level", ""), p.get("risk", ""), str(p.get("findings_count", 0)), p.get("recommendation", "")])
if len(rows) == 1:
- rows.append(["Scoring", "N/A", "Indisponible", "N/A", "0"])
- return _table(rows, [5.2 * cm, 3 * cm, 5 * cm, 6 * cm, 3 * cm], styles=styles)
+ rows.append(["Scoring", "N/A", "Indisponible", "N/A", "0", "Relancer un audit complet."])
+ return _table(rows, [4.4 * cm, 2.6 * cm, 3.7 * cm, 4.1 * cm, 2.2 * cm, 8.5 * cm], styles=styles)
-def _actions_table(audit: dict, styles):
- rows = [["Priorite", "Severite", "Categorie", "Lieu / source", "Action recommandee", "SLA"]]
- findings = sorted(audit.get("findings", []), key=lambda x: _sev_order(x.get("severity", "info")))[:18]
+def _actions_table(audit: dict, styles, limit: int = 16):
+ rows = [["Priorité", "Sévérité", "Catégorie", "Lieu / source", "Action recommandée", "SLA"]]
+ findings = sorted(audit.get("findings", []), key=lambda x: _sev_order(x.get("severity", "info")))[:limit]
for f in findings:
sev = f.get("severity", "info")
rows.append([_priority_label(sev), sev, f.get("category", ""), _loc(f.get("location", {})), f.get("recommendation") or f.get("title", ""), _sla_for(sev)])
if len(rows) == 1:
rows.append(["Info", "info", "Aucun", "N/A", "Aucun constat prioritaire.", "Suivi"])
- return _table(rows, [2.3 * cm, 2.2 * cm, 3.2 * cm, 5.4 * cm, 9.8 * cm, 2.3 * cm], small=True, styles=styles)
+ return _table(rows, [2.5 * cm, 2.2 * cm, 3.1 * cm, 5.3 * cm, 10.1 * cm, 2.3 * cm], small=True, styles=styles)
-def _findings_table(audit: dict, styles):
- rows = [["Severite", "Categorie", "Lieu / source", "Description", "Recommandation"]]
- for f in sorted(audit.get("findings", []), key=lambda x: _sev_order(x.get("severity", "info")))[:28]:
+def _findings_table(audit: dict, styles, limit: int = 30):
+ rows = [["Sévérité", "Catégorie", "Lieu / source", "Description", "Recommandation"]]
+ for f in sorted(audit.get("findings", []), key=lambda x: _sev_order(x.get("severity", "info")))[:limit]:
rows.append([f.get("severity", ""), f.get("category", ""), _loc(f.get("location", {})), f.get("description", ""), f.get("recommendation", "")])
if len(rows) == 1:
rows.append(["info", "Aucun", "N/A", "Aucun constat notable.", "Maintenir la surveillance."])
- return _table(rows, [2.1 * cm, 3.1 * cm, 5.4 * cm, 8 * cm, 8 * cm], small=True, styles=styles)
+ return _table(rows, [2.1 * cm, 3.1 * cm, 5.2 * cm, 8.0 * cm, 8.2 * cm], small=True, styles=styles)
-def _subdomains_table(audit: dict, styles):
+def _subdomains_table(audit: dict, styles, limit: int = 40):
sub = audit.get("subdomains", {}) or {}
- rows = [["Sous-domaine", "Source", "Note"]]
- for name in (sub.get("subdomains") or [])[:80]:
- rows.append([name, sub.get("source", "passif"), ""])
- if sub.get("error"):
- rows.append(["Source limitee", "crt.sh / passif", str(sub.get("error"))[:320]])
+ rows = [["Sous-domaine", "Source"]]
+ for name in (sub.get("subdomains") or [])[:limit]:
+ rows.append([name, sub.get("source", "passif")])
if len(rows) == 1:
- rows.append(["Aucun", sub.get("source", "passif"), "Aucun sous-domaine affiche."])
- return _table(rows, [8 * cm, 6 * cm, 11.5 * cm], small=True, styles=styles)
+ rows.append(["Aucun", sub.get("source", "passif")])
+ return _table(rows, [8.8 * cm, 3.4 * cm], small=True, styles=styles)
-def _ip_table(audit: dict, styles):
- rows = [["IP", "Perimetre", "Sources", "Hostnames"]]
+def _ip_table(audit: dict, styles, limit: int = 40):
+ rows = [["IP", "Périmètre", "Hostnames"]]
inv = audit.get("ip_inventory", {}) or {}
- for item in (inv.get("unique_ips") or inv.get("display_ips") or [])[:65]:
- rows.append([item.get("ip", ""), item.get("scope", ""), ", ".join(item.get("sources", [])), ", ".join(item.get("hostnames", [])[:6])])
+ for item in (inv.get("unique_ips") or inv.get("display_ips") or [])[:limit]:
+ rows.append([item.get("ip", ""), item.get("scope", ""), ", ".join(item.get("hostnames", [])[:4])])
if len(rows) == 1:
- rows.append(["Aucune", "N/A", "N/A", "N/A"])
- return _table(rows, [4.2 * cm, 3.6 * cm, 4.5 * cm, 13.2 * cm], small=True, styles=styles)
+ rows.append(["Aucune", "N/A", "N/A"])
+ return _table(rows, [3.2 * cm, 2.7 * cm, 6.6 * cm], small=True, styles=styles)
-def _nmap_table(audit: dict, styles):
+def _nmap_table(audit: dict, styles, limit: int = 70):
scan = audit.get("service_scan", {}) or {}
- rows = [["Hote", "Port", "Service", "Produit", "Version", "CVE", "Severite"]]
- for port in scan.get("open_ports", [])[:80]:
+ rows = [["Hôte", "Port", "Service", "Produit", "Version", "CVE", "Sévérité"]]
+ for port in scan.get("open_ports", [])[:limit]:
cves = port.get("cves", []) or []
if cves:
for cve in cves:
rows.append([port.get("hostname", ""), f"{port.get('port', '')}/{port.get('protocol', 'tcp')}", port.get("name", ""), port.get("product", ""), port.get("version", ""), cve.get("cve", ""), cve.get("severity", "")])
else:
- rows.append([port.get("hostname", ""), f"{port.get('port', '')}/{port.get('protocol', 'tcp')}", port.get("name", ""), port.get("product", ""), port.get("version") or "Version non exposee", "", ""])
+ rows.append([port.get("hostname", ""), f"{port.get('port', '')}/{port.get('protocol', 'tcp')}", port.get("name", ""), port.get("product", ""), port.get("version") or "Version non exposée", "", ""])
if len(rows) == 1:
- rows.append(["Aucun", "", "", "", "", "", scan.get("note", "Aucun port ouvert detecte ou Nmap indisponible.")])
- return _table(rows, [4.5 * cm, 2.4 * cm, 3 * cm, 4.4 * cm, 3.3 * cm, 3.4 * cm, 2.3 * cm], small=True, styles=styles)
+ rows.append(["Aucun", "", "", "", "", "", scan.get("note", "Aucun port ouvert détecté ou Nmap indisponible.")])
+ return _table(rows, [4.4 * cm, 2.2 * cm, 2.9 * cm, 4.2 * cm, 3.5 * cm, 3.4 * cm, 2.5 * cm], small=True, styles=styles)
+
+
+def _callout(text: str, styles, tone: str = "neutral"):
+ bg = colors.HexColor("#ECFFF5") if tone == "safe" else CARD_ALT
+ bar = GREEN if tone == "safe" else GOLD
+ table = Table([[Paragraph(escape(text), styles["Body"])]], colWidths=[25.5 * cm])
+ table.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, -1), bg),
+ ("BOX", (0, 0), (-1, -1), 0.6, BORDER),
+ ("LINEBEFORE", (0, 0), (0, 0), 4.0, bar),
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 9),
+ ("TOPPADDING", (0, 0), (-1, -1), 7),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 7),
+ ]))
+ return table
def _table(data, col_widths, small=False, header=True, styles=None):
@@ -273,10 +367,10 @@ def _table(data, col_widths, small=False, header=True, styles=None):
("BACKGROUND", (0, 0), (-1, -1), CARD),
("GRID", (0, 0), (-1, -1), 0.28, BORDER),
("VALIGN", (0, 0), (-1, -1), "TOP"),
- ("LEFTPADDING", (0, 0), (-1, -1), 4.5),
- ("RIGHTPADDING", (0, 0), (-1, -1), 4.5),
- ("TOPPADDING", (0, 0), (-1, -1), 4),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
+ ("LEFTPADDING", (0, 0), (-1, -1), 4.3),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4.3),
+ ("TOPPADDING", (0, 0), (-1, -1), 3.8),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3.8),
]
if header:
ts += [("BACKGROUND", (0, 0), (-1, 0), CARD_ALT), ("LINEBELOW", (0, 0), (-1, 0), 0.7, GOLD)]
@@ -295,28 +389,28 @@ def _decorate_page(canvas: Canvas, doc):
canvas.setFillColor(RED_DARK)
canvas.rect(0, height - 1.0 * cm, width, 1.0 * cm, fill=1, stroke=0)
canvas.setFillColor(GOLD)
- canvas.rect(0, height - 1.04 * cm, width, 0.035 * cm, fill=1, stroke=0)
+ canvas.rect(0, height - 1.04 * cm, width, 0.04 * cm, fill=1, stroke=0)
canvas.setFont("Helvetica-Bold", 8)
canvas.setFillColor(GOLD_LIGHT)
- canvas.drawString(1.15 * cm, height - 0.66 * cm, "OPENEASM V7.5")
+ canvas.drawString(1.1 * cm, height - 0.66 * cm, "OPENEASM BETA")
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(colors.HexColor("#F8E9BE"))
- canvas.drawRightString(width - 1.15 * cm, height - 0.66 * cm, "Audit defensif externe - non exploitant")
+ canvas.drawRightString(width - 1.1 * cm, height - 0.66 * cm, "Audit externe défensif - service/version/CVE non exploitant")
canvas.setFillColor(MUTED)
canvas.setFont("Helvetica", 7)
- canvas.drawRightString(width - 1.15 * cm, 0.62 * cm, f"Page {doc.page}")
+ canvas.drawRightString(width - 1.1 * cm, 0.55 * cm, f"Page {doc.page}")
canvas.restoreState()
def _conclusion(audit):
return (
f"Le domaine {audit.get('domain')} obtient un score de {audit.get('score', {}).get('score')} / 1000. "
- f"Le profil detecte est {audit.get('domain_profile', {}).get('label', 'N/A')}. "
- f"L'audit recense {audit.get('ip_inventory', {}).get('public_ip_count', 0)} IP publiques, "
+ f"Profil : {audit.get('domain_profile', {}).get('label', 'N/A')}. "
+ f"Surface observée : {audit.get('ip_inventory', {}).get('public_ip_count', 0)} IP publiques, "
f"{audit.get('subdomains', {}).get('count', 0)} sous-domaines, "
- f"{audit.get('passive_cves', {}).get('count', 0)} CVE potentielles passives et "
- f"{audit.get('service_scan', {}).get('count_cves', 0)} CVE issues de la correlation service/version. "
- "Les constats sont localises pour faciliter la correction operationnelle."
+ f"{audit.get('service_scan', {}).get('count_open_ports', 0)} ports ouverts Nmap et "
+ f"{audit.get('service_scan', {}).get('count_cves', 0)} CVE corrélées par service/version. "
+ "Les résultats sont priorisés pour faciliter la décision et la correction opérationnelle."
)
@@ -331,8 +425,8 @@ def _sev_order(sev):
def _priority_label(sev):
- return {"critical": "P1", "high": "P2", "medium": "P3", "low": "P4", "info": "Info"}.get(str(sev).lower(), "Info")
+ return {"critical": "P1 immédiat", "high": "P2 prioritaire", "medium": "P3 planifié", "low": "P4 amélioration", "info": "Information"}.get(str(sev).lower(), "Information")
def _sla_for(sev):
- return {"critical": "< 5 j", "high": "< 15 j", "medium": "< 30 j", "low": "< 90 j", "info": "Suivi"}.get(str(sev).lower(), "Suivi")
+ return {"critical": "< 5 jours", "high": "< 15 jours", "medium": "< 30 jours", "low": "< 90 jours", "info": "Suivi"}.get(str(sev).lower(), "Suivi")