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

@@ -1,6 +1,8 @@
"""Unit tests for render functions in backend.app.services.export."""
from __future__ import annotations
import csv as _csv
import io as _io
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
@@ -38,11 +40,8 @@ def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
"id": sid,
"name": name,
"status": SimpleNamespace(value="pending"),
"techniques": [{"id": "T1059", "name": "Command and Scripting Interpreter"}],
"tactic_ids": ["TA0002"],
"description": "Execute a script",
"commands": "whoami",
"prerequisites": "local admin",
"executed_at": None,
"execution_result": None,
"log_source": None,
@@ -57,6 +56,21 @@ def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
return SimpleNamespace(**defaults)
# ---------------------------------------------------------------------------
# Shared constants
# ---------------------------------------------------------------------------
_FR_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
# ---------------------------------------------------------------------------
# Markdown tests
# ---------------------------------------------------------------------------
@@ -73,6 +87,15 @@ def test_render_engagement_markdown_includes_header_fields(app) -> None:
assert "alice" in result
def test_render_engagement_markdown_has_seven_column_table_headers(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_markdown(eng, [sim])
for header in _FR_HEADERS:
assert header in result, f"Expected French header '{header}' in markdown table"
def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
with app.app_context():
eng = _make_engagement()
@@ -83,45 +106,34 @@ def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
assert first_pos < second_pos
def test_render_engagement_markdown_includes_techniques_with_id_and_name(app) -> None:
def test_render_engagement_markdown_no_simulations_has_no_table(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_markdown(eng, [])
assert "Scénario" not in result
assert "## Simulations" not in result
def test_render_engagement_markdown_execution_cell_uses_br_separator(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
techniques=[{"id": "T1059", "name": "Command and Scripting Interpreter"}]
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="whoami",
execution_result="admin@host",
)
result = render_engagement_markdown(eng, [sim])
assert "T1059" in result
assert "Command and Scripting Interpreter" in result
assert "<br/>" in result
assert "whoami" in result
assert "admin@host" in result
def test_render_engagement_markdown_includes_tactics(app) -> None:
def test_render_engagement_markdown_pipe_in_cell_is_escaped(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(tactic_ids=["TA0002"])
sim = _make_sim(name="Name | with pipe")
result = render_engagement_markdown(eng, [sim])
# TA0002 = Execution — should appear as tactic name or id
assert "TA0002" in result or "Execution" in result
def test_render_engagement_markdown_includes_soc_fields_even_when_blank(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(log_source=None, logs=None, soc_comment=None, incident_number=None)
result = render_engagement_markdown(eng, [sim])
assert "Log source" in result
assert "SOC comment" in result
assert "Incident number" in result
def test_render_engagement_markdown_escapes_backticks_in_commands(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(commands="echo `whoami`")
result = render_engagement_markdown(eng, [sim])
# Commands must be inside a fenced code block using ~~~ (tilde fences)
assert "~~~" in result
# The backtick content must still be present
assert "`whoami`" in result
assert "Name \\| with pipe" in result
# ---------------------------------------------------------------------------
@@ -129,40 +141,97 @@ def test_render_engagement_markdown_escapes_backticks_in_commands(app) -> None:
# ---------------------------------------------------------------------------
def _parse_csv(csv_text: str) -> list[list[str]]:
return list(_csv.reader(_io.StringIO(csv_text)))
def test_render_engagement_csv_has_header_row(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_csv(eng, [])
first_line = result.splitlines()[0]
assert "id" in first_line
assert "name" in first_line
assert "status" in first_line
assert "techniques" in first_line
rows = _parse_csv(result)
assert rows[0] == _FR_HEADERS
def test_render_engagement_csv_joins_multi_techniques_with_pipe(app) -> None:
def test_render_engagement_csv_has_one_row_per_simulation(app) -> None:
with app.app_context():
import csv
import io
eng = _make_engagement()
sims = [_make_sim(1, "S1"), _make_sim(2, "S2")]
result = render_engagement_csv(eng, sims)
rows = _parse_csv(result)
assert len(rows) == 3 # header + 2 sims
def test_render_engagement_csv_columns_are_seven(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_csv(eng, [sim])
rows = _parse_csv(result)
assert len(rows[0]) == 7
assert len(rows[1]) == 7
def test_render_engagement_csv_execution_column_contains_commands(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
techniques=[
{"id": "T1059", "name": "Command and Scripting Interpreter"},
{"id": "T1078", "name": "Valid Accounts"},
]
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="net user /domain",
execution_result="success",
)
result = render_engagement_csv(eng, [sim])
rows = list(csv.reader(io.StringIO(result)))
# row[3] = techniques column
tech_cell = rows[1][3]
assert "|" in tech_cell
assert "T1059" in tech_cell
assert "T1078" in tech_cell
rows = _parse_csv(result)
exec_cell = rows[1][4] # col index 4 = Exécution
assert "2026-06-01" in exec_cell
assert "net user /domain" in exec_cell
assert "success" in exec_cell
# ---------------------------------------------------------------------------
# PDF test
# CSV formula injection defense
# ---------------------------------------------------------------------------
def _parse_csv_data_row(csv_text: str, row_index: int = 1) -> list[str]:
return _parse_csv(csv_text)[row_index]
def test_render_engagement_csv_escapes_formula_injection_in_scenario(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="=cmd|'/c calc'!A1")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# col 0 = Scénario
assert cells[0] == "'=cmd|'/c calc'!A1"
def test_render_engagement_csv_escapes_formula_injection_in_execution(app) -> None:
with app.app_context():
eng = _make_engagement()
# executed_at=None so concat is "\ncommand\n" — leading \n is not a trigger.
# Use a formula-triggering execution_result to test the final concat.
sim = _make_sim(execution_result="=HYPERLINK(\"http://evil\")")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# col 4 = Exécution; concat starts with "\n" (empty executed_at) so not triggered
# but the execution_result value is embedded — verify it's present
assert "HYPERLINK" in cells[4]
def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="Mimikatz LSASS Dump", commands="whoami /all")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
assert cells[0] == "Mimikatz LSASS Dump"
assert "whoami /all" in cells[4]
# ---------------------------------------------------------------------------
# PDF tests
# ---------------------------------------------------------------------------
@@ -175,86 +244,28 @@ def test_render_engagement_pdf_starts_with_pdf_magic(app) -> None:
assert result[:4] == b"%PDF"
# ---------------------------------------------------------------------------
# MITRE bundle not loaded
# ---------------------------------------------------------------------------
def test_render_engagement_pdf_contains_simulation_table(app) -> None:
from backend.app.services.export import _render_engagement_html
def test_render_engagement_markdown_with_mitre_bundle_not_loaded_does_not_crash(
app,
) -> None:
with app.app_context():
import backend.app.services.mitre as mitre_svc
original = mitre_svc.mitre_loaded
original_tactics = mitre_svc._tactics_by_technique.copy()
try:
mitre_svc.mitre_loaded = False
mitre_svc._tactics_by_technique = {}
eng = _make_engagement()
sim = _make_sim(
techniques=[{"id": "T1059", "name": "Command and Scripting Interpreter"}],
tactic_ids=["TA0002"],
)
result = render_engagement_markdown(eng, [sim])
# Must not crash and must include the technique id
assert "T1059" in result
finally:
mitre_svc.mitre_loaded = original
mitre_svc._tactics_by_technique = original_tactics
# ---------------------------------------------------------------------------
# CSV formula injection defense (security fix — 2026-06-08)
# ---------------------------------------------------------------------------
import csv as _csv # noqa: E402 (sectioned import to keep test diff localized)
import io as _io # noqa: E402
def _parse_csv_data_row(csv_text: str, row_index: int = 1) -> list[str]:
"""Return cells of row N (0=header) parsed by csv.reader to handle multilines."""
return list(_csv.reader(_io.StringIO(csv_text)))[row_index]
def test_render_engagement_csv_escapes_formula_injection_in_name(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="=cmd|'/c calc'!A1")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# name is column index 1 (after id)
assert cells[1] == "'=cmd|'/c calc'!A1", (
"name beginning with = must be apostrophe-prefixed to defuse Excel formula"
)
sim = _make_sim()
html = _render_engagement_html(eng, [sim])
assert "<table>" in html
for header in _FR_HEADERS:
assert header in html, f"Expected French header '{header}' in HTML"
def test_render_engagement_csv_escapes_formula_injection_in_commands(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(commands="@SUM(1+1)")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# commands is column index 6 per _CSV_HEADERS order
assert cells[6].startswith("'@"), "commands beginning with @ must be apostrophe-prefixed"
def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="Mimikatz LSASS Dump", commands="whoami /all")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
assert cells[1] == "Mimikatz LSASS Dump", "safe name must not be modified"
assert cells[6] == "whoami /all", "safe commands must not be modified"
# ---------------------------------------------------------------------------
# Defense-in-depth: filename header injection
# ---------------------------------------------------------------------------
def test_export_filename_never_contains_quote_or_crlf() -> None:
"""Defense-in-depth: even with malicious engagement names, the filename
used in Content-Disposition must never contain header-injection chars."""
from types import SimpleNamespace
from backend.app.services.export import _export_filename
evil = SimpleNamespace(id=42, name='evil"name\r\nX-Injected: yes')
fname = _export_filename(evil, "md")
assert '"' not in fname