diff --git a/backend/app/services/export.py b/backend/app/services/export.py index 368f72b..7e37999 100644 --- a/backend/app/services/export.py +++ b/backend/app/services/export.py @@ -22,19 +22,6 @@ def _export_filename(engagement: Engagement, ext: str) -> str: 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: """Return username string from an ORM object with a created_by relationship.""" cb = getattr(obj, "created_by", None) @@ -43,10 +30,29 @@ def _creator(obj: object) -> str: 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 # --------------------------------------------------------------------------- +_MD_HEADERS = [ + "Scénario", + "Test", + "Source de log", + "Commentaires SOC", + "Exécution", + "Logs remontés au SIEM", + "Cyber incident", +] + def render_engagement_markdown( engagement: Engagement, simulations: list[Simulation] @@ -65,9 +71,7 @@ def render_engagement_markdown( lines.append( f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}" ) - lines.append( - f"**Created by**: {_creator(engagement)}" - ) + lines.append(f"**Created by**: {_creator(engagement)}") lines.append( 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("") + header_row = "| " + " | ".join(_MD_HEADERS) + " |" + separator = "| " + " | ".join("---" for _ in _MD_HEADERS) + " |" + lines.append(header_row) + lines.append(separator) + for sim in simulations: - enriched_techniques = _enrich_sim_techniques(sim.techniques) - 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" + execution = _format_execution(sim).replace("\n", "
") - lines.append(f"### {sim.name}") - lines.append("") - 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("") + def _cell(value: str | None) -> str: + return (value or "").replace("|", "\\|").replace("\n", "
") - lines.append("**Commands**:") - if sim.commands: - safe = sim.commands.replace("~~~", "\\~\\~\\~") - lines.append("~~~bash") - lines.append(safe) - lines.append("~~~") - else: - lines.append("N/A") - lines.append("") - - 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("") + row = "| " + " | ".join([ + _cell(sim.name), + _cell(sim.description), + _cell(sim.log_source), + _cell(sim.soc_comment), + _cell(execution), + _cell(sim.logs), + _cell(sim.incident_number), + ]) + " |" + lines.append(row) + lines.append("") return "\n".join(lines) @@ -129,22 +116,13 @@ def render_engagement_markdown( # --------------------------------------------------------------------------- _CSV_HEADERS = [ - "id", - "name", - "status", - "techniques", - "tactics", - "description", - "commands", - "prerequisites", - "executed_at", - "execution_result", - "log_source", - "logs", - "soc_comment", - "incident_number", - "created_at", - "updated_at", + "Scénario", + "Test", + "Source de log", + "Commentaires SOC", + "Exécution", + "Logs remontés au SIEM", + "Cyber incident", ] # \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) for sim in simulations: - enriched_techniques = _enrich_sim_techniques(sim.techniques) - tech_ids = "|".join(t["id"] for t in enriched_techniques) - tactic_str = "|".join(sim.tactic_ids or []) - + execution = _format_execution(sim) writer.writerow([ - sim.id, - _csv_safe(sim.name), - sim.status.value, - _csv_safe(tech_ids), - _csv_safe(tactic_str), + _csv_safe(sim.name 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.logs or ""), _csv_safe(sim.soc_comment or ""), + _csv_safe(execution), + _csv_safe(sim.logs 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() @@ -208,15 +174,22 @@ _CSS = """ body { font-family: sans-serif; font-size: 13px; color: #1a1a1a; margin: 40px; } h1 { font-size: 22px; border-bottom: 2px solid #333; padding-bottom: 6px; } 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; } -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; } -pre { background: #f5f5f5; padding: 8px; font-size: 11px; white-space: pre-wrap; word-break: break-all; } .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( engagement: Engagement, simulations: list[Simulation] @@ -241,50 +214,22 @@ def _render_engagement_html( if simulations: parts.append("

Simulations

") + thead = "" + "".join(f"{h(col)}" for col in _HTML_HEADERS) + "" + parts.append(f"{thead}") for sim in simulations: - enriched_techniques = _enrich_sim_techniques(sim.techniques) - tech_str = h( - " | ".join(f"{t['id']} — {t['name']}" for t in enriched_techniques) - or "N/A" - ) - tactic_str = h(_tactic_names(sim.tactic_ids) or "N/A") - - parts.append(f"

{h(sim.name)}

") - parts.append("
") - parts.append(f"") - parts.append(f"") - parts.append(f"") - parts.append( - f"" - ) - executed = sim.executed_at.isoformat() if sim.executed_at else "N/A" - parts.append(f"") - parts.append( - f"" - ) - parts.append("
Status{h(sim.status.value)}
Techniques{tech_str}
Tactics{tactic_str}
Description{h(sim.description or '')}
Executed at{h(executed)}
Execution result{h(sim.execution_result or '')}
") - - if sim.commands: - parts.append(f"

Commands:

{h(sim.commands)}
") - - if sim.prerequisites: - parts.append( - f"

Prerequisites: {h(sim.prerequisites)}

" - ) - - parts.append("") - parts.append("") - parts.append( - f"" - ) - parts.append(f"") - parts.append( - f"" - ) - parts.append( - f"" - ) - parts.append("
SOC
Log source{h(sim.log_source or '')}
Logs{h(sim.logs or '')}
SOC comment{h(sim.soc_comment or '')}
Incident number{h(sim.incident_number or '')}
") + execution_html = h(_format_execution(sim)).replace("\n", "
") + cells = [ + h(sim.name or ""), + h(sim.description or ""), + h(sim.log_source or ""), + h(sim.soc_comment or ""), + execution_html, + h(sim.logs or ""), + h(sim.incident_number or ""), + ] + row = "" + "".join(f"{c}" for c in cells) + "" + parts.append(row) + parts.append("") parts.append("") return "".join(parts) diff --git a/backend/tests/test_export_engagement.py b/backend/tests/test_export_engagement.py index 915e896..0850d42 100644 --- a/backend/tests/test_export_engagement.py +++ b/backend/tests/test_export_engagement.py @@ -60,8 +60,11 @@ def test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulat assert "text/markdown" in resp.content_type body = resp.data.decode() assert "Op Alpha" in body + # Both simulation names appear as cells in the 7-column table assert "Lateral Movement" in body assert "Persistence Check" in body + # Table uses French column headers + assert "Scénario" in body 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()))) expected_headers = [ - "id", "name", "status", "techniques", "tactics", "description", - "commands", "prerequisites", "executed_at", "execution_result", - "log_source", "logs", "soc_comment", "incident_number", - "created_at", "updated_at", + "Scénario", + "Test", + "Source de log", + "Commentaires SOC", + "Exécution", + "Logs remontés au SIEM", + "Cyber incident", ] assert rows[0] == expected_headers @@ -156,7 +162,7 @@ def test_export_csv_escapes_special_characters( rows = list(csv_mod.reader(io.StringIO(body))) 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 diff --git a/backend/tests/test_export_render.py b/backend/tests/test_export_render.py index 6fe752c..e101eab 100644 --- a/backend/tests/test_export_render.py +++ b/backend/tests/test_export_render.py @@ -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 "
" 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 "" 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