diff --git a/backend/app/services/export.py b/backend/app/services/export.py
index 7e37999..b0e830d 100644
--- a/backend/app/services/export.py
+++ b/backend/app/services/export.py
@@ -30,7 +30,36 @@ def _creator(obj: object) -> str:
return getattr(cb, "username", "") or ""
-def _format_execution(sim: Simulation) -> str:
+# ---------------------------------------------------------------------------
+# CSV formula-injection defense (defined early — used by _format_execution_csv)
+# ---------------------------------------------------------------------------
+
+# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
+# reaches the formula parser in some sheet versions.
+_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
+
+
+# ---------------------------------------------------------------------------
+# Execution cell helpers
+# ---------------------------------------------------------------------------
+
+
+def _format_execution_text(sim: Simulation) -> str:
+ """Canonical 3-part execution concat for Markdown and PDF (no CSV sanitization)."""
parts = [
sim.executed_at.isoformat() if sim.executed_at else "",
sim.commands or "",
@@ -39,6 +68,17 @@ def _format_execution(sim: Simulation) -> str:
return "\n".join(parts)
+def _format_execution_csv(sim: Simulation) -> str:
+ """Execution concat for CSV: each user-controlled component is formula-defused
+ before joining so that inner lines starting with =, +, -, @ are safe."""
+ parts = [
+ sim.executed_at.isoformat() if sim.executed_at else "",
+ str(_csv_safe(sim.commands or "")),
+ str(_csv_safe(sim.execution_result or "")),
+ ]
+ return "\n".join(parts)
+
+
# ---------------------------------------------------------------------------
# Markdown
# ---------------------------------------------------------------------------
@@ -91,11 +131,16 @@ def render_engagement_markdown(
lines.append(separator)
for sim in simulations:
- execution = _format_execution(sim).replace("\n", "
")
-
def _cell(value: str | None) -> str:
- return (value or "").replace("|", "\\|").replace("\n", "
")
+ # Escape HTML (including quotes) first to prevent stored XSS in MD renderers
+ # that interpret inline HTML, then escape pipe (GFM table syntax),
+ # then fold newlines to
(our own safe markup, inserted after escape).
+ s = _html_escape(value or "")
+ s = s.replace("|", "\\|")
+ s = s.replace("\n", "
")
+ return s
+ execution = _format_execution_text(sim)
row = "| " + " | ".join([
_cell(sim.name),
_cell(sim.description),
@@ -125,24 +170,6 @@ _CSV_HEADERS = [
"Cyber incident",
]
-# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
-# reaches the formula parser in some sheet versions.
-_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]
@@ -152,13 +179,13 @@ def render_engagement_csv(
writer.writerow(_CSV_HEADERS)
for sim in simulations:
- execution = _format_execution(sim)
+ execution = _format_execution_csv(sim)
writer.writerow([
_csv_safe(sim.name or ""),
_csv_safe(sim.description or ""),
_csv_safe(sim.log_source or ""),
_csv_safe(sim.soc_comment or ""),
- _csv_safe(execution),
+ _csv_safe(execution), # belt-and-braces: outer check covers empty executed_at case
_csv_safe(sim.logs or ""),
_csv_safe(sim.incident_number or ""),
])
@@ -217,7 +244,7 @@ def _render_engagement_html(
thead = "" + "".join(f" "
parts.append(f"{h(col)} " for col in _HTML_HEADERS) + "