Files
mimic/backend/tests/test_export_render.py

274 lines
9.3 KiB
Python
Raw Normal View History

"""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