Files
mimic/backend/tests/test_export_render.py
Knacky 3a9d9d3203 fix(security): defuse CSV formula injection in multiline exécution cell + HTML-escape Markdown table cells
Finding 1 — CSV multiline formula injection:
- Split _format_execution into _format_execution_text (MD/PDF, no sanitization) and
  _format_execution_csv (CSV, applies _csv_safe to each user-controlled component before join)
- Moved _CSV_FORMULA_TRIGGERS + _csv_safe above the format helpers (required by _format_execution_csv)
- Outer _csv_safe on the Exécution cell retained as belt-and-braces for the empty-date case
- New test: test_render_engagement_csv_defuses_formula_in_inner_execution_lines

Finding 2 — Stored XSS in Markdown table:
- _cell() in render_engagement_markdown now calls _html_escape() (quote=True, default)
  before pipe-escaping and \n→<br/> substitution — correct order preserved
- New test: test_render_engagement_markdown_escapes_html_in_table_cells

255 → 257 passed, ruff clean, mypy clean.

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

309 lines
11 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_defuses_formula_in_inner_execution_lines(app) -> None:
"""When executed_at is set, the cell starts with a safe date, but commands
line may inject formulas. Each user-controlled component must be defused."""
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="=cmd|'/c calc'!A1",
execution_result="@SUM(1)",
)
result = render_engagement_csv(eng, [sim])
cells = list(_csv.reader(_io.StringIO(result)))[1]
execution_cell = cells[4] # Exécution column
assert "'=cmd|'/c calc'!A1" in execution_cell
assert "'@SUM(1)" in execution_cell
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]
def test_render_engagement_markdown_escapes_html_in_table_cells(app) -> None:
"""User content in table cells must be HTML-escaped to prevent stored XSS
when the .md is opened in a renderer that interprets inline HTML."""
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
name="<script>alert(1)</script>",
commands='<img src=x onerror="alert(1)">',
)
result = render_engagement_markdown(eng, [sim])
assert "<script>" not in result
assert 'onerror="alert' not in result
assert "&lt;script&gt;" in result
assert "&lt;img" in result
# double-quotes in attribute values are also escaped
assert "&quot;" in result
# ---------------------------------------------------------------------------
# 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