diff --git a/backend/app/services/export.py b/backend/app/services/export.py index ef15f88..d4f4b22 100644 --- a/backend/app/services/export.py +++ b/backend/app/services/export.py @@ -154,6 +154,22 @@ _CSV_HEADERS = [ "updated_at", ] +_CSV_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r") + + +def _csv_safe(value: object) -> object: + """Defuse spreadsheet formula injection by prefixing user-controlled cells. + + Excel / LibreOffice / Google Sheets interpret cells starting with =, +, -, @, + \\t or \\r as formulas. Since this CSV is the engagement handoff to SOC and is + explicitly opened in a spreadsheet app, an authenticated red-team user could + craft a simulation field that executes on the SOC analyst's machine. Prefixing + with a single apostrophe forces the spreadsheet to treat the cell as text. + """ + if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS: + return "'" + value + return value + def render_engagement_csv( engagement: Engagement, simulations: list[Simulation] @@ -169,19 +185,19 @@ def render_engagement_csv( writer.writerow([ sim.id, - sim.name, + _csv_safe(sim.name), sim.status.value, - tech_ids, - tactic_str, - sim.description or "", - sim.commands or "", - sim.prerequisites or "", + _csv_safe(tech_ids), + _csv_safe(tactic_str), + _csv_safe(sim.description or ""), + _csv_safe(sim.commands or ""), + _csv_safe(sim.prerequisites or ""), sim.executed_at.isoformat() if sim.executed_at else "", - sim.execution_result or "", - sim.log_source or "", - sim.logs or "", - sim.soc_comment or "", - sim.incident_number or "", + _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(sim.incident_number or ""), sim.created_at.isoformat() if sim.created_at else "", sim.updated_at.isoformat() if sim.updated_at else "", ]) diff --git a/backend/tests/test_export_render.py b/backend/tests/test_export_render.py index 1194c92..faf3e06 100644 --- a/backend/tests/test_export_render.py +++ b/backend/tests/test_export_render.py @@ -202,3 +202,48 @@ def test_render_engagement_markdown_with_mitre_bundle_not_loaded_does_not_crash( 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" + ) + + +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"