Files
mimic/backend/app/services/export.py
Knacky 7335b9f2c6 refactor(export): switch render output to fixed 7-column schema (Scénario, Test, ...)
All three renderers (MD, CSV, PDF) now emit a uniform 7-column table with
French headers matching the RT↔SOC handoff contract locked in SPEC.md fdab324.

Helpers added:
- _format_execution(sim): canonical 3-part concat (executed_at / commands / execution_result)
- _MD_HEADERS / _HTML_HEADERS / _CSV_HEADERS unified to the same 7 FR strings

Helpers removed (no longer called):
- _tactic_names() — MITRE tactics dropped from export
- _enrich_sim_techniques() — MITRE techniques dropped from export

Fields dropped from export: status, techniques, tactic_ids, prerequisites, id,
created_at, updated_at (intentional — focused RT↔SOC handoff, see SPEC §Export).

_csv_safe() still applied to all 7 user-controlled cells including Exécution concat.

Tests updated: 255 passed, ruff clean, mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:49 +02:00

250 lines
8.0 KiB
Python

"""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", "<br/>")
def _cell(value: str | None) -> str:
return (value or "").replace("|", "\\|").replace("\n", "<br/>")
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("<!DOCTYPE html><html><head><meta charset='utf-8'>")
parts.append(f"<style>{_CSS}</style></head><body>")
parts.append(f"<h1>{h(engagement.name)}</h1>")
parts.append("<div class='meta'>")
if engagement.description:
parts.append(f"<p>{h(engagement.description)}</p>")
parts.append(f"<p><strong>Status:</strong> {h(engagement.status.value)}</p>")
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"<p><strong>Dates:</strong> {h(sd)}{h(ed)}</p>")
parts.append(f"<p><strong>Created by:</strong> {h(_creator(engagement))}</p>")
ca = engagement.created_at.isoformat() if engagement.created_at else "N/A"
parts.append(f"<p><strong>Created at:</strong> {h(ca)}</p>")
parts.append("</div>")
if simulations:
parts.append("<h2>Simulations</h2>")
thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in _HTML_HEADERS) + "</tr></thead>"
parts.append(f"<table>{thead}<tbody>")
for sim in simulations:
execution_html = h(_format_execution(sim)).replace("\n", "<br/>")
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 = "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
parts.append(row)
parts.append("</tbody></table>")
parts.append("</body></html>")
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()