2026-06-08 17:57:22 +02:00
|
|
|
"""Engagement export renderers — Markdown, CSV, PDF."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import csv
|
|
|
|
|
import io
|
|
|
|
|
import re
|
|
|
|
|
import unicodedata
|
|
|
|
|
from datetime import date
|
2026-06-08 18:23:39 +02:00
|
|
|
from html import escape as _html_escape
|
2026-06-08 17:57:22 +02:00
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from backend.app.models.engagement import Engagement
|
|
|
|
|
from backend.app.models.simulation import Simulation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _export_filename(engagement: Engagement, ext: str) -> str:
|
|
|
|
|
name = engagement.name or ""
|
|
|
|
|
normalized = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
|
|
|
|
|
slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")[:60] or "unnamed"
|
|
|
|
|
today = date.today().strftime("%Y%m%d")
|
|
|
|
|
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)
|
|
|
|
|
if cb is None:
|
|
|
|
|
return ""
|
|
|
|
|
return getattr(cb, "username", "") or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Markdown
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_engagement_markdown(
|
|
|
|
|
engagement: Engagement, simulations: list[Simulation]
|
|
|
|
|
) -> str:
|
|
|
|
|
lines: list[str] = []
|
|
|
|
|
|
|
|
|
|
lines.append(f"# {engagement.name}")
|
|
|
|
|
lines.append("")
|
|
|
|
|
if engagement.description:
|
|
|
|
|
lines.append(engagement.description)
|
|
|
|
|
lines.append("")
|
|
|
|
|
lines.append(f"**Status**: {engagement.status.value}")
|
|
|
|
|
lines.append(
|
|
|
|
|
f"**Start date**: {engagement.start_date.isoformat() if engagement.start_date else 'N/A'}"
|
|
|
|
|
)
|
|
|
|
|
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 at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}"
|
|
|
|
|
)
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
|
|
|
|
if not simulations:
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
lines.append("---")
|
|
|
|
|
lines.append("")
|
|
|
|
|
lines.append("## Simulations")
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
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("")
|
|
|
|
|
|
|
|
|
|
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("")
|
|
|
|
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# CSV
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_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",
|
|
|
|
|
]
|
|
|
|
|
|
2026-06-08 18:23:39 +02:00
|
|
|
# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
|
|
|
|
|
# reaches the formula parser in some sheet versions.
|
fix(security): defuse CSV formula injection in engagement export (MEDIUM)
Authenticated red-team users could craft any user-controlled string field
(name, description, commands, prerequisites, execution_result, log_source,
logs, soc_comment, incident_number, MITRE technique IDs) starting with =,
+, -, @, \t or \r. When the SOC analyst opens the exported CSV in Excel /
LibreOffice / Google Sheets — explicitly the consumption flow this sprint
optimizes for — the spreadsheet executes the field as a formula on the
SOC's machine.
Fix: new helper _csv_safe() prefixes a single apostrophe to any string
starting with a formula-trigger character, forcing the spreadsheet to
render the cell as text. Applied to every user-controlled field in
render_engagement_csv. Numeric and ISO-date fields are not wrapped.
Tests:
- test_render_engagement_csv_escapes_formula_injection_in_name
- test_render_engagement_csv_escapes_formula_injection_in_commands
- test_render_engagement_csv_does_not_alter_safe_strings
Result: 249 → 252 passing (the 1 remaining failure is pre-existing
test_index_without_built_frontend_returns_json, unrelated to this fix).
Flagged by security-guidance@claude-code-plugins automated review.
2026-06-08 18:13:16 +02:00
|
|
|
_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
|
|
|
|
|
|
2026-06-08 17:57:22 +02:00
|
|
|
|
|
|
|
|
def render_engagement_csv(
|
2026-06-08 18:23:39 +02:00
|
|
|
_engagement: Engagement, simulations: list[Simulation]
|
2026-06-08 17:57:22 +02:00
|
|
|
) -> str:
|
|
|
|
|
buf = io.StringIO()
|
|
|
|
|
writer = csv.writer(buf)
|
|
|
|
|
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 [])
|
|
|
|
|
|
|
|
|
|
writer.writerow([
|
|
|
|
|
sim.id,
|
fix(security): defuse CSV formula injection in engagement export (MEDIUM)
Authenticated red-team users could craft any user-controlled string field
(name, description, commands, prerequisites, execution_result, log_source,
logs, soc_comment, incident_number, MITRE technique IDs) starting with =,
+, -, @, \t or \r. When the SOC analyst opens the exported CSV in Excel /
LibreOffice / Google Sheets — explicitly the consumption flow this sprint
optimizes for — the spreadsheet executes the field as a formula on the
SOC's machine.
Fix: new helper _csv_safe() prefixes a single apostrophe to any string
starting with a formula-trigger character, forcing the spreadsheet to
render the cell as text. Applied to every user-controlled field in
render_engagement_csv. Numeric and ISO-date fields are not wrapped.
Tests:
- test_render_engagement_csv_escapes_formula_injection_in_name
- test_render_engagement_csv_escapes_formula_injection_in_commands
- test_render_engagement_csv_does_not_alter_safe_strings
Result: 249 → 252 passing (the 1 remaining failure is pre-existing
test_index_without_built_frontend_returns_json, unrelated to this fix).
Flagged by security-guidance@claude-code-plugins automated review.
2026-06-08 18:13:16 +02:00
|
|
|
_csv_safe(sim.name),
|
2026-06-08 17:57:22 +02:00
|
|
|
sim.status.value,
|
fix(security): defuse CSV formula injection in engagement export (MEDIUM)
Authenticated red-team users could craft any user-controlled string field
(name, description, commands, prerequisites, execution_result, log_source,
logs, soc_comment, incident_number, MITRE technique IDs) starting with =,
+, -, @, \t or \r. When the SOC analyst opens the exported CSV in Excel /
LibreOffice / Google Sheets — explicitly the consumption flow this sprint
optimizes for — the spreadsheet executes the field as a formula on the
SOC's machine.
Fix: new helper _csv_safe() prefixes a single apostrophe to any string
starting with a formula-trigger character, forcing the spreadsheet to
render the cell as text. Applied to every user-controlled field in
render_engagement_csv. Numeric and ISO-date fields are not wrapped.
Tests:
- test_render_engagement_csv_escapes_formula_injection_in_name
- test_render_engagement_csv_escapes_formula_injection_in_commands
- test_render_engagement_csv_does_not_alter_safe_strings
Result: 249 → 252 passing (the 1 remaining failure is pre-existing
test_index_without_built_frontend_returns_json, unrelated to this fix).
Flagged by security-guidance@claude-code-plugins automated review.
2026-06-08 18:13:16 +02:00
|
|
|
_csv_safe(tech_ids),
|
|
|
|
|
_csv_safe(tactic_str),
|
|
|
|
|
_csv_safe(sim.description or ""),
|
|
|
|
|
_csv_safe(sim.commands or ""),
|
|
|
|
|
_csv_safe(sim.prerequisites or ""),
|
2026-06-08 17:57:22 +02:00
|
|
|
sim.executed_at.isoformat() if sim.executed_at else "",
|
fix(security): defuse CSV formula injection in engagement export (MEDIUM)
Authenticated red-team users could craft any user-controlled string field
(name, description, commands, prerequisites, execution_result, log_source,
logs, soc_comment, incident_number, MITRE technique IDs) starting with =,
+, -, @, \t or \r. When the SOC analyst opens the exported CSV in Excel /
LibreOffice / Google Sheets — explicitly the consumption flow this sprint
optimizes for — the spreadsheet executes the field as a formula on the
SOC's machine.
Fix: new helper _csv_safe() prefixes a single apostrophe to any string
starting with a formula-trigger character, forcing the spreadsheet to
render the cell as text. Applied to every user-controlled field in
render_engagement_csv. Numeric and ISO-date fields are not wrapped.
Tests:
- test_render_engagement_csv_escapes_formula_injection_in_name
- test_render_engagement_csv_escapes_formula_injection_in_commands
- test_render_engagement_csv_does_not_alter_safe_strings
Result: 249 → 252 passing (the 1 remaining failure is pre-existing
test_index_without_built_frontend_returns_json, unrelated to this fix).
Flagged by security-guidance@claude-code-plugins automated review.
2026-06-08 18:13:16 +02:00
|
|
|
_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 ""),
|
2026-06-08 17:57:22 +02:00
|
|
|
sim.created_at.isoformat() if sim.created_at else "",
|
|
|
|
|
sim.updated_at.isoformat() if sim.updated_at else "",
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# HTML (internal, used by PDF renderer)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_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 { background: #e0e0e0; }
|
|
|
|
|
pre { background: #f5f5f5; padding: 8px; font-size: 11px; white-space: pre-wrap; word-break: break-all; }
|
|
|
|
|
.meta { color: #555; margin-bottom: 16px; }
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_engagement_html(
|
|
|
|
|
engagement: Engagement, simulations: list[Simulation]
|
|
|
|
|
) -> str:
|
|
|
|
|
h = _html_escape
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
|
|
|
|
|
parts.append("<!DOCTYPE html><html><head><meta charset='utf-8'>")
|
|
|
|
|
parts.append(f"<style>{_CSS}</style></head><body>")
|
|
|
|
|
parts.append(f"<h1>{h(engagement.name)}</h1>")
|
|
|
|
|
parts.append("<div class='meta'>")
|
|
|
|
|
if engagement.description:
|
|
|
|
|
parts.append(f"<p>{h(engagement.description)}</p>")
|
|
|
|
|
parts.append(f"<p><strong>Status:</strong> {h(engagement.status.value)}</p>")
|
|
|
|
|
sd = engagement.start_date.isoformat() if engagement.start_date else "N/A"
|
|
|
|
|
ed = engagement.end_date.isoformat() if engagement.end_date else "N/A"
|
|
|
|
|
parts.append(f"<p><strong>Dates:</strong> {h(sd)} → {h(ed)}</p>")
|
|
|
|
|
parts.append(f"<p><strong>Created by:</strong> {h(_creator(engagement))}</p>")
|
|
|
|
|
ca = engagement.created_at.isoformat() if engagement.created_at else "N/A"
|
|
|
|
|
parts.append(f"<p><strong>Created at:</strong> {h(ca)}</p>")
|
|
|
|
|
parts.append("</div>")
|
|
|
|
|
|
|
|
|
|
if simulations:
|
|
|
|
|
parts.append("<h2>Simulations</h2>")
|
|
|
|
|
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"<h3>{h(sim.name)}</h3>")
|
|
|
|
|
parts.append("<table>")
|
|
|
|
|
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>")
|
|
|
|
|
parts.append(f"<tr><th>Tactics</th><td>{tactic_str}</td></tr>")
|
|
|
|
|
parts.append(
|
|
|
|
|
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>")
|
|
|
|
|
return "".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# PDF
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_engagement_pdf(
|
|
|
|
|
engagement: Engagement, simulations: list[Simulation]
|
|
|
|
|
) -> bytes:
|
2026-06-08 18:23:39 +02:00
|
|
|
from weasyprint import HTML
|
2026-06-08 17:57:22 +02:00
|
|
|
|
|
|
|
|
html = _render_engagement_html(engagement, simulations)
|
2026-06-08 18:23:39 +02:00
|
|
|
return HTML(string=html).write_pdf()
|