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>
274 lines
9.3 KiB
Python
274 lines
9.3 KiB
Python
"""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
|
|
|
|
from backend.app.services.export import (
|
|
render_engagement_csv,
|
|
render_engagement_markdown,
|
|
render_engagement_pdf,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures / factories
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_engagement(**kw) -> Any:
|
|
from datetime import date
|
|
|
|
defaults: dict[str, Any] = {
|
|
"id": 1,
|
|
"name": "Test Engagement",
|
|
"description": "A purple team exercise",
|
|
"start_date": date(2026, 6, 1),
|
|
"end_date": date(2026, 6, 30),
|
|
"status": SimpleNamespace(value="active"),
|
|
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
|
"created_by": SimpleNamespace(username="alice"),
|
|
}
|
|
defaults.update(kw)
|
|
return SimpleNamespace(**defaults)
|
|
|
|
|
|
def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
|
|
defaults: dict[str, Any] = {
|
|
"id": sid,
|
|
"name": name,
|
|
"status": SimpleNamespace(value="pending"),
|
|
"description": "Execute a script",
|
|
"commands": "whoami",
|
|
"executed_at": None,
|
|
"execution_result": None,
|
|
"log_source": None,
|
|
"logs": None,
|
|
"soc_comment": None,
|
|
"incident_number": None,
|
|
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
|
"updated_at": None,
|
|
"created_by": SimpleNamespace(username="bob"),
|
|
}
|
|
defaults.update(kw)
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_render_engagement_markdown_includes_header_fields(app) -> None:
|
|
with app.app_context():
|
|
eng = _make_engagement()
|
|
result = render_engagement_markdown(eng, [])
|
|
assert "Test Engagement" in result
|
|
assert "2026-06-01" in result
|
|
assert "2026-06-30" in result
|
|
assert "active" in result
|
|
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()
|
|
sims = [_make_sim(1, "First Sim"), _make_sim(2, "Second Sim")]
|
|
result = render_engagement_markdown(eng, sims)
|
|
first_pos = result.index("First Sim")
|
|
second_pos = result.index("Second Sim")
|
|
assert first_pos < second_pos
|
|
|
|
|
|
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(
|
|
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
|
commands="whoami",
|
|
execution_result="admin@host",
|
|
)
|
|
result = render_engagement_markdown(eng, [sim])
|
|
assert "<br/>" in result
|
|
assert "whoami" in result
|
|
assert "admin@host" in result
|
|
|
|
|
|
def test_render_engagement_markdown_pipe_in_cell_is_escaped(app) -> None:
|
|
with app.app_context():
|
|
eng = _make_engagement()
|
|
sim = _make_sim(name="Name | with pipe")
|
|
result = render_engagement_markdown(eng, [sim])
|
|
assert "Name \\| with pipe" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CSV tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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, [])
|
|
rows = _parse_csv(result)
|
|
assert rows[0] == _FR_HEADERS
|
|
|
|
|
|
def test_render_engagement_csv_has_one_row_per_simulation(app) -> None:
|
|
with app.app_context():
|
|
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(
|
|
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
|
commands="net user /domain",
|
|
execution_result="success",
|
|
)
|
|
result = render_engagement_csv(eng, [sim])
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_render_engagement_pdf_starts_with_pdf_magic(app) -> None:
|
|
with app.app_context():
|
|
eng = _make_engagement()
|
|
sim = _make_sim()
|
|
result = render_engagement_pdf(eng, [sim])
|
|
assert isinstance(result, bytes)
|
|
assert result[:4] == b"%PDF"
|
|
|
|
|
|
def test_render_engagement_pdf_contains_simulation_table(app) -> None:
|
|
from backend.app.services.export import _render_engagement_html
|
|
|
|
with app.app_context():
|
|
eng = _make_engagement()
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 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
|
|
assert '\r' not in fname
|
|
assert '\n' not in fname
|