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:
@@ -22,19 +22,6 @@ def _export_filename(engagement: Engagement, ext: str) -> str:
|
|||||||
return f"engagement-{engagement.id}-{slug}-{today}.{ext}"
|
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:
|
def _creator(obj: object) -> str:
|
||||||
"""Return username string from an ORM object with a created_by relationship."""
|
"""Return username string from an ORM object with a created_by relationship."""
|
||||||
cb = getattr(obj, "created_by", None)
|
cb = getattr(obj, "created_by", None)
|
||||||
@@ -43,10 +30,29 @@ def _creator(obj: object) -> str:
|
|||||||
return getattr(cb, "username", "") or ""
|
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
|
# Markdown
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MD_HEADERS = [
|
||||||
|
"Scénario",
|
||||||
|
"Test",
|
||||||
|
"Source de log",
|
||||||
|
"Commentaires SOC",
|
||||||
|
"Exécution",
|
||||||
|
"Logs remontés au SIEM",
|
||||||
|
"Cyber incident",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def render_engagement_markdown(
|
def render_engagement_markdown(
|
||||||
engagement: Engagement, simulations: list[Simulation]
|
engagement: Engagement, simulations: list[Simulation]
|
||||||
@@ -65,9 +71,7 @@ def render_engagement_markdown(
|
|||||||
lines.append(
|
lines.append(
|
||||||
f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}"
|
f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}"
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(f"**Created by**: {_creator(engagement)}")
|
||||||
f"**Created by**: {_creator(engagement)}"
|
|
||||||
)
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f"**Created at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}"
|
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("## Simulations")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
header_row = "| " + " | ".join(_MD_HEADERS) + " |"
|
||||||
|
separator = "| " + " | ".join("---" for _ in _MD_HEADERS) + " |"
|
||||||
|
lines.append(header_row)
|
||||||
|
lines.append(separator)
|
||||||
|
|
||||||
for sim in simulations:
|
for sim in simulations:
|
||||||
enriched_techniques = _enrich_sim_techniques(sim.techniques)
|
execution = _format_execution(sim).replace("\n", "<br/>")
|
||||||
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"
|
|
||||||
|
|
||||||
lines.append(f"### {sim.name}")
|
def _cell(value: str | None) -> str:
|
||||||
lines.append("")
|
return (value or "").replace("|", "\\|").replace("\n", "<br/>")
|
||||||
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("")
|
|
||||||
|
|
||||||
lines.append("**Commands**:")
|
row = "| " + " | ".join([
|
||||||
if sim.commands:
|
_cell(sim.name),
|
||||||
safe = sim.commands.replace("~~~", "\\~\\~\\~")
|
_cell(sim.description),
|
||||||
lines.append("~~~bash")
|
_cell(sim.log_source),
|
||||||
lines.append(safe)
|
_cell(sim.soc_comment),
|
||||||
lines.append("~~~")
|
_cell(execution),
|
||||||
else:
|
_cell(sim.logs),
|
||||||
lines.append("N/A")
|
_cell(sim.incident_number),
|
||||||
lines.append("")
|
]) + " |"
|
||||||
|
lines.append(row)
|
||||||
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("")
|
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,22 +116,13 @@ def render_engagement_markdown(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_CSV_HEADERS = [
|
_CSV_HEADERS = [
|
||||||
"id",
|
"Scénario",
|
||||||
"name",
|
"Test",
|
||||||
"status",
|
"Source de log",
|
||||||
"techniques",
|
"Commentaires SOC",
|
||||||
"tactics",
|
"Exécution",
|
||||||
"description",
|
"Logs remontés au SIEM",
|
||||||
"commands",
|
"Cyber incident",
|
||||||
"prerequisites",
|
|
||||||
"executed_at",
|
|
||||||
"execution_result",
|
|
||||||
"log_source",
|
|
||||||
"logs",
|
|
||||||
"soc_comment",
|
|
||||||
"incident_number",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
|
# \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)
|
writer.writerow(_CSV_HEADERS)
|
||||||
|
|
||||||
for sim in simulations:
|
for sim in simulations:
|
||||||
enriched_techniques = _enrich_sim_techniques(sim.techniques)
|
execution = _format_execution(sim)
|
||||||
tech_ids = "|".join(t["id"] for t in enriched_techniques)
|
|
||||||
tactic_str = "|".join(sim.tactic_ids or [])
|
|
||||||
|
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
sim.id,
|
_csv_safe(sim.name or ""),
|
||||||
_csv_safe(sim.name),
|
|
||||||
sim.status.value,
|
|
||||||
_csv_safe(tech_ids),
|
|
||||||
_csv_safe(tactic_str),
|
|
||||||
_csv_safe(sim.description 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.log_source or ""),
|
||||||
_csv_safe(sim.logs or ""),
|
|
||||||
_csv_safe(sim.soc_comment or ""),
|
_csv_safe(sim.soc_comment or ""),
|
||||||
|
_csv_safe(execution),
|
||||||
|
_csv_safe(sim.logs or ""),
|
||||||
_csv_safe(sim.incident_number 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()
|
return buf.getvalue()
|
||||||
@@ -208,15 +174,22 @@ _CSS = """
|
|||||||
body { font-family: sans-serif; font-size: 13px; color: #1a1a1a; margin: 40px; }
|
body { font-family: sans-serif; font-size: 13px; color: #1a1a1a; margin: 40px; }
|
||||||
h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 6px; }
|
h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 6px; }
|
||||||
h2 { font-size: 17px; margin-top: 32px; color: #333; }
|
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; }
|
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; }
|
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; }
|
.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(
|
def _render_engagement_html(
|
||||||
engagement: Engagement, simulations: list[Simulation]
|
engagement: Engagement, simulations: list[Simulation]
|
||||||
@@ -241,50 +214,22 @@ def _render_engagement_html(
|
|||||||
|
|
||||||
if simulations:
|
if simulations:
|
||||||
parts.append("<h2>Simulations</h2>")
|
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:
|
for sim in simulations:
|
||||||
enriched_techniques = _enrich_sim_techniques(sim.techniques)
|
execution_html = h(_format_execution(sim)).replace("\n", "<br/>")
|
||||||
tech_str = h(
|
cells = [
|
||||||
" | ".join(f"{t['id']} — {t['name']}" for t in enriched_techniques)
|
h(sim.name or ""),
|
||||||
or "N/A"
|
h(sim.description or ""),
|
||||||
)
|
h(sim.log_source or ""),
|
||||||
tactic_str = h(_tactic_names(sim.tactic_ids) or "N/A")
|
h(sim.soc_comment or ""),
|
||||||
|
execution_html,
|
||||||
parts.append(f"<h3>{h(sim.name)}</h3>")
|
h(sim.logs or ""),
|
||||||
parts.append("<table>")
|
h(sim.incident_number or ""),
|
||||||
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>")
|
row = "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
|
||||||
parts.append(f"<tr><th>Tactics</th><td>{tactic_str}</td></tr>")
|
parts.append(row)
|
||||||
parts.append(
|
parts.append("</tbody></table>")
|
||||||
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>")
|
|
||||||
|
|
||||||
parts.append("</body></html>")
|
parts.append("</body></html>")
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ def test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulat
|
|||||||
assert "text/markdown" in resp.content_type
|
assert "text/markdown" in resp.content_type
|
||||||
body = resp.data.decode()
|
body = resp.data.decode()
|
||||||
assert "Op Alpha" in body
|
assert "Op Alpha" in body
|
||||||
|
# Both simulation names appear as cells in the 7-column table
|
||||||
assert "Lateral Movement" in body
|
assert "Lateral Movement" in body
|
||||||
assert "Persistence Check" in body
|
assert "Persistence Check" in body
|
||||||
|
# Table uses French column headers
|
||||||
|
assert "Scénario" in body
|
||||||
|
|
||||||
|
|
||||||
def test_export_markdown_redteam_ok(
|
def test_export_markdown_redteam_ok(
|
||||||
@@ -122,10 +125,13 @@ def test_export_csv_columns_match_contract(
|
|||||||
|
|
||||||
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
|
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
|
||||||
expected_headers = [
|
expected_headers = [
|
||||||
"id", "name", "status", "techniques", "tactics", "description",
|
"Scénario",
|
||||||
"commands", "prerequisites", "executed_at", "execution_result",
|
"Test",
|
||||||
"log_source", "logs", "soc_comment", "incident_number",
|
"Source de log",
|
||||||
"created_at", "updated_at",
|
"Commentaires SOC",
|
||||||
|
"Exécution",
|
||||||
|
"Logs remontés au SIEM",
|
||||||
|
"Cyber incident",
|
||||||
]
|
]
|
||||||
assert rows[0] == expected_headers
|
assert rows[0] == expected_headers
|
||||||
|
|
||||||
@@ -156,7 +162,7 @@ def test_export_csv_escapes_special_characters(
|
|||||||
|
|
||||||
rows = list(csv_mod.reader(io.StringIO(body)))
|
rows = list(csv_mod.reader(io.StringIO(body)))
|
||||||
assert len(rows) == 2 # header + 1 sim
|
assert len(rows) == 2 # header + 1 sim
|
||||||
name_col = rows[1][1]
|
name_col = rows[1][0] # col 0 = Scénario
|
||||||
assert "quoted" in name_col
|
assert "quoted" in name_col
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Unit tests for render functions in backend.app.services.export."""
|
"""Unit tests for render functions in backend.app.services.export."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv as _csv
|
||||||
|
import io as _io
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -38,11 +40,8 @@ def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
|
|||||||
"id": sid,
|
"id": sid,
|
||||||
"name": name,
|
"name": name,
|
||||||
"status": SimpleNamespace(value="pending"),
|
"status": SimpleNamespace(value="pending"),
|
||||||
"techniques": [{"id": "T1059", "name": "Command and Scripting Interpreter"}],
|
|
||||||
"tactic_ids": ["TA0002"],
|
|
||||||
"description": "Execute a script",
|
"description": "Execute a script",
|
||||||
"commands": "whoami",
|
"commands": "whoami",
|
||||||
"prerequisites": "local admin",
|
|
||||||
"executed_at": None,
|
"executed_at": None,
|
||||||
"execution_result": None,
|
"execution_result": None,
|
||||||
"log_source": None,
|
"log_source": None,
|
||||||
@@ -57,6 +56,21 @@ def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
|
|||||||
return SimpleNamespace(**defaults)
|
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
|
# Markdown tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -73,6 +87,15 @@ def test_render_engagement_markdown_includes_header_fields(app) -> None:
|
|||||||
assert "alice" 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:
|
def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
eng = _make_engagement()
|
eng = _make_engagement()
|
||||||
@@ -83,45 +106,34 @@ def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
|
|||||||
assert first_pos < second_pos
|
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():
|
with app.app_context():
|
||||||
eng = _make_engagement()
|
eng = _make_engagement()
|
||||||
sim = _make_sim(
|
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])
|
result = render_engagement_markdown(eng, [sim])
|
||||||
assert "T1059" in result
|
assert "<br/>" in result
|
||||||
assert "Command and Scripting Interpreter" 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():
|
with app.app_context():
|
||||||
eng = _make_engagement()
|
eng = _make_engagement()
|
||||||
sim = _make_sim(tactic_ids=["TA0002"])
|
sim = _make_sim(name="Name | with pipe")
|
||||||
result = render_engagement_markdown(eng, [sim])
|
result = render_engagement_markdown(eng, [sim])
|
||||||
# TA0002 = Execution — should appear as tactic name or id
|
assert "Name \\| with pipe" in result
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -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:
|
def test_render_engagement_csv_has_header_row(app) -> None:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
eng = _make_engagement()
|
eng = _make_engagement()
|
||||||
result = render_engagement_csv(eng, [])
|
result = render_engagement_csv(eng, [])
|
||||||
first_line = result.splitlines()[0]
|
rows = _parse_csv(result)
|
||||||
assert "id" in first_line
|
assert rows[0] == _FR_HEADERS
|
||||||
assert "name" in first_line
|
|
||||||
assert "status" in first_line
|
|
||||||
assert "techniques" in first_line
|
|
||||||
|
|
||||||
|
|
||||||
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():
|
with app.app_context():
|
||||||
import csv
|
eng = _make_engagement()
|
||||||
import io
|
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()
|
eng = _make_engagement()
|
||||||
sim = _make_sim(
|
sim = _make_sim(
|
||||||
techniques=[
|
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||||
{"id": "T1059", "name": "Command and Scripting Interpreter"},
|
commands="net user /domain",
|
||||||
{"id": "T1078", "name": "Valid Accounts"},
|
execution_result="success",
|
||||||
]
|
|
||||||
)
|
)
|
||||||
result = render_engagement_csv(eng, [sim])
|
result = render_engagement_csv(eng, [sim])
|
||||||
rows = list(csv.reader(io.StringIO(result)))
|
rows = _parse_csv(result)
|
||||||
# row[3] = techniques column
|
exec_cell = rows[1][4] # col index 4 = Exécution
|
||||||
tech_cell = rows[1][3]
|
assert "2026-06-01" in exec_cell
|
||||||
assert "|" in tech_cell
|
assert "net user /domain" in exec_cell
|
||||||
assert "T1059" in tech_cell
|
assert "success" in exec_cell
|
||||||
assert "T1078" in tech_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"
|
assert result[:4] == b"%PDF"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def test_render_engagement_pdf_contains_simulation_table(app) -> None:
|
||||||
# MITRE bundle not loaded
|
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():
|
with app.app_context():
|
||||||
eng = _make_engagement()
|
eng = _make_engagement()
|
||||||
sim = _make_sim(name="=cmd|'/c calc'!A1")
|
sim = _make_sim()
|
||||||
result = render_engagement_csv(eng, [sim])
|
html = _render_engagement_html(eng, [sim])
|
||||||
cells = _parse_csv_data_row(result)
|
assert "<table>" in html
|
||||||
# name is column index 1 (after id)
|
for header in _FR_HEADERS:
|
||||||
assert cells[1] == "'=cmd|'/c calc'!A1", (
|
assert header in html, f"Expected French header '{header}' in HTML"
|
||||||
"name beginning with = must be apostrophe-prefixed to defuse Excel formula"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_engagement_csv_escapes_formula_injection_in_commands(app) -> None:
|
# ---------------------------------------------------------------------------
|
||||||
with app.app_context():
|
# Defense-in-depth: filename header injection
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_filename_never_contains_quote_or_crlf() -> None:
|
def test_export_filename_never_contains_quote_or_crlf() -> None:
|
||||||
"""Defense-in-depth: even with malicious engagement names, the filename
|
"""Defense-in-depth: even with malicious engagement names, the filename
|
||||||
used in Content-Disposition must never contain header-injection chars."""
|
used in Content-Disposition must never contain header-injection chars."""
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from backend.app.services.export import _export_filename
|
from backend.app.services.export import _export_filename
|
||||||
|
|
||||||
evil = SimpleNamespace(id=42, name='evil"name\r\nX-Injected: yes')
|
evil = SimpleNamespace(id=42, name='evil"name\r\nX-Injected: yes')
|
||||||
fname = _export_filename(evil, "md")
|
fname = _export_filename(evil, "md")
|
||||||
assert '"' not in fname
|
assert '"' not in fname
|
||||||
|
|||||||
Reference in New Issue
Block a user