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>
This commit is contained in:
Knacky
2026-06-08 19:15:49 +02:00
parent fdab324217
commit 7335b9f2c6
3 changed files with 218 additions and 256 deletions

View File

@@ -22,19 +22,6 @@ def _export_filename(engagement: Engagement, ext: str) -> str:
return f"engagement-{engagement.id}-{slug}-{today}.{ext}"
def _tactic_names(tactic_ids: list[str]) -> str:
from backend.app.serializers import _enrich_tactics
enriched = _enrich_tactics(tactic_ids or [])
return " | ".join(e.get("name", e.get("id", "")) for e in enriched)
def _enrich_sim_techniques(techniques: list[dict]) -> list[dict]:
from backend.app.serializers import _enrich_techniques
return _enrich_techniques(techniques or [])
def _creator(obj: object) -> str:
"""Return username string from an ORM object with a created_by relationship."""
cb = getattr(obj, "created_by", None)
@@ -43,10 +30,29 @@ def _creator(obj: object) -> str:
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]
@@ -65,9 +71,7 @@ def render_engagement_markdown(
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 by**: {_creator(engagement)}")
lines.append(
f"**Created at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}"
)
@@ -81,46 +85,29 @@ def render_engagement_markdown(
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:
enriched_techniques = _enrich_sim_techniques(sim.techniques)
tech_str = " | ".join(
f"{t['id']}{t['name']}" for t in enriched_techniques
) if enriched_techniques else "N/A"
tactic_str = _tactic_names(sim.tactic_ids) or "N/A"
execution = _format_execution(sim).replace("\n", "<br/>")
lines.append(f"### {sim.name}")
lines.append("")
lines.append(f"**Status**: {sim.status.value}")
lines.append(f"**Techniques**: {tech_str}")
lines.append(f"**Tactics**: {tactic_str}")
if sim.description:
lines.append(f"**Description**: {sim.description}")
lines.append(
f"**Executed at**: {sim.executed_at.isoformat() if sim.executed_at else 'N/A'}"
)
lines.append(f"**Execution result**: {sim.execution_result or 'N/A'}")
lines.append("")
def _cell(value: str | None) -> str:
return (value or "").replace("|", "\\|").replace("\n", "<br/>")
lines.append("**Commands**:")
if sim.commands:
safe = sim.commands.replace("~~~", "\\~\\~\\~")
lines.append("~~~bash")
lines.append(safe)
lines.append("~~~")
else:
lines.append("N/A")
lines.append("")
lines.append(f"**Prerequisites**: {sim.prerequisites or 'N/A'}")
lines.append("")
lines.append("#### SOC")
lines.append(f"**Log source**: {sim.log_source or 'N/A'}")
lines.append(f"**Logs**: {sim.logs or 'N/A'}")
lines.append(f"**SOC comment**: {sim.soc_comment or 'N/A'}")
lines.append(f"**Incident number**: {sim.incident_number or 'N/A'}")
lines.append("")
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)
@@ -129,22 +116,13 @@ def render_engagement_markdown(
# ---------------------------------------------------------------------------
_CSV_HEADERS = [
"id",
"name",
"status",
"techniques",
"tactics",
"description",
"commands",
"prerequisites",
"executed_at",
"execution_result",
"log_source",
"logs",
"soc_comment",
"incident_number",
"created_at",
"updated_at",
"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
@@ -174,27 +152,15 @@ def render_engagement_csv(
writer.writerow(_CSV_HEADERS)
for sim in simulations:
enriched_techniques = _enrich_sim_techniques(sim.techniques)
tech_ids = "|".join(t["id"] for t in enriched_techniques)
tactic_str = "|".join(sim.tactic_ids or [])
execution = _format_execution(sim)
writer.writerow([
sim.id,
_csv_safe(sim.name),
sim.status.value,
_csv_safe(tech_ids),
_csv_safe(tactic_str),
_csv_safe(sim.name or ""),
_csv_safe(sim.description or ""),
_csv_safe(sim.commands or ""),
_csv_safe(sim.prerequisites or ""),
sim.executed_at.isoformat() if sim.executed_at else "",
_csv_safe(sim.execution_result or ""),
_csv_safe(sim.log_source or ""),
_csv_safe(sim.logs or ""),
_csv_safe(sim.soc_comment or ""),
_csv_safe(execution),
_csv_safe(sim.logs or ""),
_csv_safe(sim.incident_number or ""),
sim.created_at.isoformat() if sim.created_at else "",
sim.updated_at.isoformat() if sim.updated_at else "",
])
return buf.getvalue()
@@ -208,15 +174,22 @@ _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; }
h3 { font-size: 14px; margin-top: 24px; background: #f0f0f0; padding: 4px 8px; }
h3:nth-of-type(odd) { background: #e8e8e8; }
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; }
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; vertical-align: top; }
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; vertical-align: top; white-space: pre-wrap; }
th { background: #e0e0e0; }
pre { background: #f5f5f5; padding: 8px; font-size: 11px; white-space: pre-wrap; word-break: break-all; }
.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]
@@ -241,50 +214,22 @@ def _render_engagement_html(
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:
enriched_techniques = _enrich_sim_techniques(sim.techniques)
tech_str = h(
" | ".join(f"{t['id']}{t['name']}" for t in enriched_techniques)
or "N/A"
)
tactic_str = h(_tactic_names(sim.tactic_ids) or "N/A")
parts.append(f"<h3>{h(sim.name)}</h3>")
parts.append("<table>")
parts.append(f"<tr><th>Status</th><td>{h(sim.status.value)}</td></tr>")
parts.append(f"<tr><th>Techniques</th><td>{tech_str}</td></tr>")
parts.append(f"<tr><th>Tactics</th><td>{tactic_str}</td></tr>")
parts.append(
f"<tr><th>Description</th><td>{h(sim.description or '')}</td></tr>"
)
executed = sim.executed_at.isoformat() if sim.executed_at else "N/A"
parts.append(f"<tr><th>Executed at</th><td>{h(executed)}</td></tr>")
parts.append(
f"<tr><th>Execution result</th><td>{h(sim.execution_result or '')}</td></tr>"
)
parts.append("</table>")
if sim.commands:
parts.append(f"<p><strong>Commands:</strong></p><pre>{h(sim.commands)}</pre>")
if sim.prerequisites:
parts.append(
f"<p><strong>Prerequisites:</strong> {h(sim.prerequisites)}</p>"
)
parts.append("<table>")
parts.append("<tr><th colspan='2'>SOC</th></tr>")
parts.append(
f"<tr><th>Log source</th><td>{h(sim.log_source or '')}</td></tr>"
)
parts.append(f"<tr><th>Logs</th><td>{h(sim.logs or '')}</td></tr>")
parts.append(
f"<tr><th>SOC comment</th><td>{h(sim.soc_comment or '')}</td></tr>"
)
parts.append(
f"<tr><th>Incident number</th><td>{h(sim.incident_number or '')}</td></tr>"
)
parts.append("</table>")
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)