"""Engagement export renderers — Markdown, CSV, PDF.""" from __future__ import annotations import csv import io import re import unicodedata from datetime import date from html import escape as _html_escape 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", ] # \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] ) -> 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, _csv_safe(sim.name), sim.status.value, _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 "", _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 "", ]) 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("") parts.append(f"") parts.append(f"

{h(engagement.name)}

") parts.append("
") if engagement.description: parts.append(f"

{h(engagement.description)}

") parts.append(f"

Status: {h(engagement.status.value)}

") 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"

Dates: {h(sd)} → {h(ed)}

") parts.append(f"

Created by: {h(_creator(engagement))}

") ca = engagement.created_at.isoformat() if engagement.created_at else "N/A" parts.append(f"

Created at: {h(ca)}

") parts.append("
") if simulations: parts.append("

Simulations

") 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 '')}
") parts.append("") return "".join(parts) # --------------------------------------------------------------------------- # PDF # --------------------------------------------------------------------------- def render_engagement_pdf( engagement: Engagement, simulations: list[Simulation] ) -> bytes: from weasyprint import HTML html = _render_engagement_html(engagement, simulations) return HTML(string=html).write_pdf()