From 41736bee360d6c8f10ee879d3d737fda9f620777 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 21 May 2026 10:32:48 +0200 Subject: [PATCH 1/4] Beta reporting: enrich JSON export metadata --- backend/app/reports/json_report.py | 34 +++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) 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 From a32d6affb232a43ca23d110b7683d71b1dcc2a4e Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 21 May 2026 10:53:24 +0200 Subject: [PATCH 2/4] Beta reporting: redesign PDF audit report --- backend/app/reports/pdf_report.py | 372 +++++++++++++++++++----------- 1 file changed, 233 insertions(+), 139 deletions(-) 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") From 1387bddf597d24d2cb6a8c69feb8667658ca72c6 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 21 May 2026 10:55:06 +0200 Subject: [PATCH 3/4] Beta reporting: redesign Excel workbook --- backend/app/reports/excel_report.py | 656 ++++++++++++---------------- 1 file changed, 269 insertions(+), 387 deletions(-) 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") From 945effc0505c9832c2d78d7c05697f1646fba21e Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Terrazzoni Date: Thu, 21 May 2026 10:56:24 +0200 Subject: [PATCH 4/4] Beta: update application version metadata --- backend/app/main.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) 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,