"""Engagement export renderers — Markdown, CSV, PDF.""" from __future__ import annotations import csv import io import re import unicodedata from datetime import date from html import escape as _html_escape from typing import TYPE_CHECKING if TYPE_CHECKING: from backend.app.models.engagement import Engagement from backend.app.models.simulation import Simulation def _export_filename(engagement: Engagement, ext: str) -> str: name = engagement.name or "" normalized = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode() slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")[:60] or "unnamed" today = date.today().strftime("%Y%m%d") return f"engagement-{engagement.id}-{slug}-{today}.{ext}" def _creator(obj: object) -> str: """Return username string from an ORM object with a created_by relationship.""" cb = getattr(obj, "created_by", None) if cb is None: return "" return getattr(cb, "username", "") or "" def _format_execution(sim: Simulation) -> str: parts = [ sim.executed_at.isoformat() if sim.executed_at else "", sim.commands or "", sim.execution_result or "", ] return "\n".join(parts) # --------------------------------------------------------------------------- # Markdown # --------------------------------------------------------------------------- _MD_HEADERS = [ "Scénario", "Test", "Source de log", "Commentaires SOC", "Exécution", "Logs remontés au SIEM", "Cyber incident", ] def render_engagement_markdown( engagement: Engagement, simulations: list[Simulation] ) -> str: lines: list[str] = [] lines.append(f"# {engagement.name}") lines.append("") if engagement.description: lines.append(engagement.description) lines.append("") lines.append(f"**Status**: {engagement.status.value}") lines.append( f"**Start date**: {engagement.start_date.isoformat() if engagement.start_date else 'N/A'}" ) lines.append( f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}" ) lines.append(f"**Created by**: {_creator(engagement)}") lines.append( f"**Created at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}" ) lines.append("") if not simulations: return "\n".join(lines) lines.append("---") lines.append("") lines.append("## Simulations") lines.append("") header_row = "| " + " | ".join(_MD_HEADERS) + " |" separator = "| " + " | ".join("---" for _ in _MD_HEADERS) + " |" lines.append(header_row) lines.append(separator) for sim in simulations: execution = _format_execution(sim).replace("\n", "
") def _cell(value: str | None) -> str: return (value or "").replace("|", "\\|").replace("\n", "
") row = "| " + " | ".join([ _cell(sim.name), _cell(sim.description), _cell(sim.log_source), _cell(sim.soc_comment), _cell(execution), _cell(sim.logs), _cell(sim.incident_number), ]) + " |" lines.append(row) lines.append("") return "\n".join(lines) # --------------------------------------------------------------------------- # CSV # --------------------------------------------------------------------------- _CSV_HEADERS = [ "Scénario", "Test", "Source de log", "Commentaires SOC", "Exécution", "Logs remontés au SIEM", "Cyber incident", ] # \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still # reaches the formula parser in some sheet versions. _CSV_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r") def _csv_safe(value: object) -> object: """Defuse spreadsheet formula injection by prefixing user-controlled cells. Excel / LibreOffice / Google Sheets interpret cells starting with =, +, -, @, \\t or \\r as formulas. Since this CSV is the engagement handoff to SOC and is explicitly opened in a spreadsheet app, an authenticated red-team user could craft a simulation field that executes on the SOC analyst's machine. Prefixing with a single apostrophe forces the spreadsheet to treat the cell as text. """ if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS: return "'" + value return value def render_engagement_csv( _engagement: Engagement, simulations: list[Simulation] ) -> str: buf = io.StringIO() writer = csv.writer(buf) writer.writerow(_CSV_HEADERS) for sim in simulations: execution = _format_execution(sim) writer.writerow([ _csv_safe(sim.name or ""), _csv_safe(sim.description or ""), _csv_safe(sim.log_source or ""), _csv_safe(sim.soc_comment or ""), _csv_safe(execution), _csv_safe(sim.logs or ""), _csv_safe(sim.incident_number or ""), ]) return buf.getvalue() # --------------------------------------------------------------------------- # HTML (internal, used by PDF renderer) # --------------------------------------------------------------------------- _CSS = """ body { font-family: sans-serif; font-size: 13px; color: #1a1a1a; margin: 40px; } h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 6px; } h2 { font-size: 17px; margin-top: 32px; color: #333; } table { border-collapse: collapse; width: 100%; margin-bottom: 12px; } th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; vertical-align: top; white-space: pre-wrap; } th { background: #e0e0e0; } .meta { color: #555; margin-bottom: 16px; } """ _HTML_HEADERS = [ "Scénario", "Test", "Source de log", "Commentaires SOC", "Exécution", "Logs remontés au SIEM", "Cyber incident", ] def _render_engagement_html( engagement: Engagement, simulations: list[Simulation] ) -> str: h = _html_escape parts: list[str] = [] parts.append("") parts.append(f"") parts.append(f"

{h(engagement.name)}

") parts.append("
") if engagement.description: parts.append(f"

{h(engagement.description)}

") parts.append(f"

Status: {h(engagement.status.value)}

") sd = engagement.start_date.isoformat() if engagement.start_date else "N/A" ed = engagement.end_date.isoformat() if engagement.end_date else "N/A" parts.append(f"

Dates: {h(sd)} → {h(ed)}

") parts.append(f"

Created by: {h(_creator(engagement))}

") ca = engagement.created_at.isoformat() if engagement.created_at else "N/A" parts.append(f"

Created at: {h(ca)}

") parts.append("
") if simulations: parts.append("

Simulations

") thead = "" + "".join(f"{h(col)}" for col in _HTML_HEADERS) + "" parts.append(f"{thead}") for sim in simulations: execution_html = h(_format_execution(sim)).replace("\n", "
") cells = [ h(sim.name or ""), h(sim.description or ""), h(sim.log_source or ""), h(sim.soc_comment or ""), execution_html, h(sim.logs or ""), h(sim.incident_number or ""), ] row = "" + "".join(f"" for c in cells) + "" parts.append(row) parts.append("
{c}
") parts.append("") return "".join(parts) # --------------------------------------------------------------------------- # PDF # --------------------------------------------------------------------------- def render_engagement_pdf( engagement: Engagement, simulations: list[Simulation] ) -> bytes: from weasyprint import HTML html = _render_engagement_html(engagement, simulations) return HTML(string=html).write_pdf()