feat: sprint 6 — engagement export (md/csv/pdf) #9
@@ -154,6 +154,22 @@ _CSV_HEADERS = [
|
|||||||
"updated_at",
|
"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(
|
def render_engagement_csv(
|
||||||
engagement: Engagement, simulations: list[Simulation]
|
engagement: Engagement, simulations: list[Simulation]
|
||||||
@@ -169,19 +185,19 @@ def render_engagement_csv(
|
|||||||
|
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
sim.id,
|
sim.id,
|
||||||
sim.name,
|
_csv_safe(sim.name),
|
||||||
sim.status.value,
|
sim.status.value,
|
||||||
tech_ids,
|
_csv_safe(tech_ids),
|
||||||
tactic_str,
|
_csv_safe(tactic_str),
|
||||||
sim.description or "",
|
_csv_safe(sim.description or ""),
|
||||||
sim.commands or "",
|
_csv_safe(sim.commands or ""),
|
||||||
sim.prerequisites or "",
|
_csv_safe(sim.prerequisites or ""),
|
||||||
sim.executed_at.isoformat() if sim.executed_at else "",
|
sim.executed_at.isoformat() if sim.executed_at else "",
|
||||||
sim.execution_result or "",
|
_csv_safe(sim.execution_result or ""),
|
||||||
sim.log_source or "",
|
_csv_safe(sim.log_source or ""),
|
||||||
sim.logs or "",
|
_csv_safe(sim.logs or ""),
|
||||||
sim.soc_comment or "",
|
_csv_safe(sim.soc_comment or ""),
|
||||||
sim.incident_number or "",
|
_csv_safe(sim.incident_number or ""),
|
||||||
sim.created_at.isoformat() if sim.created_at else "",
|
sim.created_at.isoformat() if sim.created_at else "",
|
||||||
sim.updated_at.isoformat() if sim.updated_at else "",
|
sim.updated_at.isoformat() if sim.updated_at else "",
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -202,3 +202,48 @@ def test_render_engagement_markdown_with_mitre_bundle_not_loaded_does_not_crash(
|
|||||||
finally:
|
finally:
|
||||||
mitre_svc.mitre_loaded = original
|
mitre_svc.mitre_loaded = original
|
||||||
mitre_svc._tactics_by_technique = original_tactics
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user