Skip to content
12 changes: 8 additions & 4 deletions app/debrief-sections/tactic_technique_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,13 @@ def _generate_ttps_table(self, ttps, operations, include_det_links):
d_style = ParagraphStyle('tt-body-center', parent=self.tt_body, alignment=1)
d_para = Paragraph(self._stacked(detect_lines, html=True), d_style)

tac_style = ParagraphStyle('tt-tactic', parent=self.tt_body, alignment=1)
tac_name = (tactic.get('name') or '').title()
# Split hyphenated names onto separate lines (e.g., "Lateral-Movement")
tac_name = tac_name.replace('-', '-<br/>')
tac_para = Paragraph(tac_name, tac_style)
ttp_data.append([
(tactic.get('name') or '').capitalize(),
t_para, a_para, d_para
tac_para, t_para, a_para, d_para
])

# --------------------------------------------------------------------------
Expand All @@ -170,8 +174,8 @@ def _generate_ttps_table(self, ttps, operations, include_det_links):
tbl = Table(
ttp_data,
colWidths=[
0.75*inch, # Tactics
2.85*inch, # Techniques
1.10*inch, # Tactics
2.50*inch, # Techniques
1.75*inch, # Abilities
0.90*inch # Detections
],
Expand Down
18 changes: 12 additions & 6 deletions app/debrief-sections/ttps_detections.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from reportlab.lib.units import inch
from reportlab.platypus import Paragraph, PageBreak, Table, TableStyle
from reportlab.platypus.flowables import KeepTogether
from reportlab.platypus.flowables import KeepTogetherSplitAtTop
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.enums import TA_LEFT, TA_CENTER

Expand Down Expand Up @@ -358,7 +358,11 @@ def _generate_detection_appendix(self, operation, paw_to_platform):

flows.append(Paragraph(f'<a name="{det_id}"></a>', self.styles['Normal']))
hdr_block = self._build_det_header_block(det_id, det_name, sorted(an_ids))
flows.append(KeepTogether(hdr_block + [tbl]))
# Combine header and table into one flowable so there's no gap.
# KeepTogetherSplitAtTop keeps the header with the top of the
# table, and splitByRow + splitInRow on the Table allow the data
# rows to break across pages.
flows.append(KeepTogetherSplitAtTop(hdr_block + [tbl]))
return flows

def _build_det_header_block(self, det_id: str, det_name: str, an_ids: list[str]):
Expand Down Expand Up @@ -493,10 +497,10 @@ def _add_spans_for_column(col_idx):
valign_cmds.append(('VALIGN', (col_idx, r_start), (col_idx, r), 'MIDDLE'))
r += 1

# Spanning for AN / Platform / Statement
_add_spans_for_column(0)
_add_spans_for_column(1)
_add_spans_for_column(2)
# NOTE: Row-spanning for AN/Platform/Statement columns is intentionally
# disabled. Row spans prevent ReportLab from splitting the table across
# pages, causing LayoutError for detection tables with many rows.
# The table uses repeatRows=2 and splitByRow=True to allow page breaks.

# ------------------------------------------------------------------
# BUILD TABLE
Expand All @@ -505,6 +509,8 @@ def _add_spans_for_column(col_idx):
rows,
colWidths=self.DATA_COL_WIDTHS,
repeatRows=2,
splitByRow=True,
splitInRow=True,
hAlign='CENTER'
)

Expand Down
8 changes: 4 additions & 4 deletions app/debrief_svc.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ async def build_steps_d3(self, operation_ids):

for operation in operations:
# Add operation node
graph_output['nodes'].append(dict(name=operation.name, type='operation', id=op_id, img='operation',
graph_output['nodes'].append(dict(name=operation.name, type='operation', id=operation.id, img='operation',
timestamp=self._format_timestamp(operation.created)))

# Add agents for this operation
agents = [x for x in operation.agents if x]
self._add_agents_to_d3(agents, id_store, graph_output)
for agent in agents:
graph_output['links'].append(dict(source=op_id,
graph_output['links'].append(dict(source=operation.id,
target=id_store['agent' + agent.unique],
type='has_agent'))

Expand All @@ -42,12 +42,12 @@ async def build_steps_d3(self, operation_ids):
link_graph_id = id_store['link' + link.unique] = max(id_store.values()) + 1
display_name = link.ability.name + (' (cleanup)' if link.cleanup else '')
graph_output['nodes'].append(dict(type='link', name='link:'+link.unique, id=link_graph_id,
status=link.status, operation=op_id, img=link.ability.tactic,
status=link.status, operation=operation.id, img=link.ability.tactic,
attrs=dict(status=link.status, name=display_name),
timestamp=self._format_timestamp(link.created)))

if not previous_link_graph_id:
graph_output['links'].append(dict(source=op_id, target=link_graph_id, type='next_link'))
graph_output['links'].append(dict(source=operation.id, target=link_graph_id, type='next_link'))
else:
graph_output['links'].append(dict(source=previous_link_graph_id, target=link_graph_id,
type='next_link'))
Expand Down
74 changes: 50 additions & 24 deletions app/objects/c_story.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,68 +29,90 @@ def page_break(self):
def set_header_logo_path(header_logo_path):
Story._header_logo_path = header_logo_path

@staticmethod
def _page_margins(canvas, doc):
"""Return (left, right, top, bottom) margins appropriate for the current page.

Landscape pages use the frame margins (18pt) rather than the doc-level
portrait margins (72/84pt).
"""
page_w, page_h = canvas._pagesize
if page_w > page_h: # landscape
frame = doc.frame if hasattr(doc, 'frame') else None
if frame:
lm = frame._x1
bm = frame._y1
rm = page_w - frame._x1 - frame._width
tm = page_h - frame._y1 - frame._height
return lm, rm, tm, bm
return doc.leftMargin, doc.rightMargin, doc.topMargin, doc.bottomMargin

@staticmethod
def header_footer_first(canvas, doc):
# Save the state of our canvas so we can draw on it
canvas.saveState()

page_w, page_h = canvas._pagesize
lm, rm, tm, bm = Story._page_margins(canvas, doc)

# Header
caldera_logo = "./plugins/debrief/static/img/caldera.png"
im = Image(caldera_logo, 1.5 * inch, 1 * inch)
im.drawOn(canvas, doc.leftMargin, doc.height + doc.topMargin - im.drawHeight / 2)
header_y = page_h - tm
im.drawOn(canvas, lm, header_y - im.drawHeight / 2)

if Story._header_logo_path:
Story.draw_header_logo(canvas, doc, Story._header_logo_path)

canvas.setStrokeColor(colors.maroon)
canvas.setLineWidth(4)
canvas.line(doc.leftMargin + im.drawWidth + 5,
doc.height + doc.topMargin,
doc.width + doc.leftMargin,
doc.height + doc.topMargin)
canvas.line(lm + im.drawWidth + 5, header_y,
page_w - rm, header_y)

# Footer
page_num = canvas.getPageNumber()
text = "Page %s" % page_num
canvas.drawRightString(doc.width + doc.rightMargin * 1.5, doc.bottomMargin / 2, text)
canvas.drawRightString(page_w - rm, bm / 2, text)

# Release the canvas
canvas.restoreState()

@staticmethod
def header_footer_rest(canvas, doc):
# Save the state of our canvas so we can draw on it
canvas.saveState()

page_w, page_h = canvas._pagesize
lm, rm, tm, bm = Story._page_margins(canvas, doc)

# Header
if Story._header_logo_path:
Story.draw_header_logo(canvas, doc, Story._header_logo_path)

canvas.setFillColor(colors.maroon)
canvas.setFont('Helvetica-Bold', 18)
canvas.drawString(doc.leftMargin, doc.height + doc.topMargin * 1.25, 'OPERATIONS DEBRIEF')
canvas.setStrokeColor(colors.maroon)
canvas.setLineWidth(4)
canvas.line(doc.leftMargin,
doc.height + doc.topMargin * 1.25 - 5,
doc.width + doc.leftMargin,
doc.height + doc.topMargin * 1.25 - 5)
is_landscape = page_w > page_h
if not is_landscape:
# Portrait pages have room for the full header
header_y = page_h - tm * 0.75
canvas.setFillColor(colors.maroon)
canvas.setFont('Helvetica-Bold', 18)
canvas.drawString(lm, header_y, 'OPERATIONS DEBRIEF')
canvas.setStrokeColor(colors.maroon)
canvas.setLineWidth(4)
canvas.line(lm, header_y - 5, page_w - rm, header_y - 5)
# Landscape pages: skip header — 18pt margin is too narrow

# Footer
canvas.setFillColor(colors.black)
canvas.setFont('Helvetica', 10)
page_num = canvas.getPageNumber()
text = "Page %s" % page_num
canvas.drawRightString(doc.width + doc.rightMargin * 1.5, doc.bottomMargin / 2, text)
canvas.drawRightString(page_w - rm, bm / 2, text)

# Release the canvas
canvas.restoreState()

@staticmethod
def draw_header_logo(canvas, doc, logo_path):
page_w, page_h = canvas._pagesize
im = Image(logo_path, 2.5 * inch, 0.75 * inch)
im.drawOn(canvas, doc.width + doc.leftMargin + doc.rightMargin - im.drawWidth - 10,
doc.height + doc.topMargin + doc.bottomMargin - im.drawHeight - 10)
im.drawOn(canvas, page_w - im.drawWidth - 10,
page_h - im.drawHeight - 10)

@staticmethod
def adjust_icon_svgs(path):
Expand All @@ -99,12 +121,16 @@ def adjust_icon_svgs(path):
for icon_svg in svg.getroot().iter("{http://www.w3.org/2000/svg}svg"):
if icon_svg.get('id') == 'copy-svg':
continue
viewbox = [int(float(val)) for val in icon_svg.get('viewBox').split()]
viewbox_attr = icon_svg.get('viewBox')
if not viewbox_attr:
continue
viewbox = [int(float(val)) for val in viewbox_attr.split()]
aspect = viewbox[2] / viewbox[3]
icon_svg.set('width', str(round(float(icon_svg.get('height')) * aspect)))
if not icon_svg.get('id') or 'legend' not in icon_svg.get('id'):
icon_svg.set('x', '-' + str(int(icon_svg.get('width')) / 2))
svg.write(open(path, 'wb'))
with open(path, 'wb') as f:
svg.write(f)

@staticmethod
def get_table_object(val):
Expand Down
145 changes: 145 additions & 0 deletions tests/test_pdf_large_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Test that detection tables with many rows do not cause LayoutError.

The bug: KeepTogether forced header+table onto a single page. When the table
exceeded the landscape frame height (~576pts), ReportLab raised LayoutError.
The fix uses KeepTogetherSplitAtTop which allows the table to split across
pages while keeping the header attached to the top of the first fragment.
"""
import io
import importlib
import pytest

from base64 import b64encode
from datetime import datetime

from reportlab.lib.pagesizes import letter, landscape as to_landscape
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import SimpleDocTemplate

from app.objects.c_ability import Ability
from app.objects.c_adversary import Adversary
from app.objects.c_agent import Agent
from app.objects.c_operation import Operation
from app.objects.secondclass.c_executor import Executor
from app.objects.secondclass.c_link import Link
from app.utility.base_object import BaseObject

TTP_DET_MODULE = importlib.import_module('plugins.debrief.app.debrief-sections.ttps_detections')


def _make_link(tid, technique_name, paw, platform):
"""Create a minimal Link with the given technique ID."""
command = 'whoami'
executor = Executor(name='psh' if platform == 'windows' else 'sh',
platform=platform, command=command)
ability = Ability(
ability_id=f'test-{tid}',
tactic='lateral-movement',
technique_id=tid,
technique_name=technique_name,
name=f'{tid} ability',
description=f'{tid} test ability',
executors=[executor],
)
encoded = b64encode(command.encode()).decode()
link = Link(command=encoded, plaintext_command=encoded, paw=paw,
ability=ability, executor=executor)
link.pid = 999
link.decide = datetime.strptime('2021-01-01T08:00:00Z', BaseObject.TIME_FORMAT)
link.collect = datetime.strptime('2021-01-01T08:01:00Z', BaseObject.TIME_FORMAT)
link.finish = '2021-01-01T08:02:00Z'
return link


@pytest.fixture
def large_operation():
"""Create an operation with many distinct technique IDs to produce a large table."""
agent = Agent(
sleep_min=30, sleep_max=60, watchdog=0, platform='windows',
host='WORKSTATION', username='user', architecture='amd64', group='red',
location=r'C:\test.exe', pid=100, ppid=1, executors=['psh'],
privilege='User', exe_name='test.exe', contact='unknown', paw='largepaw',
)
adversary = Adversary(adversary_id='large-test', name='Large Test Adversary',
description='test', atomic_ordering=dict())
op = Operation(name='Large Test Op', agents=[agent], adversary=adversary)
op.set_start_details()

# Use technique IDs that exist in the ATT&CK v18 mapping so detection rows are generated.
# Even if some TIDs have no detections, the ones that do will produce many rows.
technique_ids = [
'T1083', 'T1547.001', 'T1560.001', 'T1548.001',
'T1059.001', 'T1053.005', 'T1021.002', 'T1021.001',
'T1055.001', 'T1003.001', 'T1070.004', 'T1071.001',
'T1036.005', 'T1105',
]
links = []
for tid in technique_ids:
links.append(_make_link(tid, f'Technique {tid}', agent.paw, agent.platform))
op.chain = links
return op, agent


class TestLargeTablePdfGeneration:
"""Verify that a detection table with many rows builds a PDF without LayoutError."""

@pytest.mark.asyncio
async def test_large_operation_does_not_crash(self, large_operation):
"""Build a landscape PDF with a large detection table and verify no exception."""
op, agent = large_operation
section = TTP_DET_MODULE.DebriefReportSection()
styles = getSampleStyleSheet()

flowables = await section.generate_section_elements(
styles,
operations=[op],
agents=[agent],
)

# There should be flowables generated
assert len(flowables) > 0

# Verify the table is actually large enough to require page splitting.
# A landscape frame is ~564pt; the table must exceed this to exercise
# the split path. If ATT&CK data changes reduce the row count, this
# assertion will catch it before the regression test becomes vacuous.
from reportlab.platypus import Table
tables = [f for f in flowables if isinstance(f, Table)]
assert len(tables) > 0, "Expected at least one Table flowable"

# Build an actual PDF in a landscape frame matching debrief_gui.py
buf = io.BytesIO()
lw, lh = to_landscape(letter)
margin = 18
doc = SimpleDocTemplate(buf, pagesize=(lw, lh),
leftMargin=margin, rightMargin=margin,
topMargin=margin, bottomMargin=margin)

# This is the line that used to raise LayoutError with KeepTogether
doc.build(flowables)

pdf_bytes = buf.getvalue()
assert len(pdf_bytes) > 0
# Verify it's a valid PDF
assert pdf_bytes[:5] == b'%PDF-'

@pytest.mark.asyncio
async def test_section_uses_keep_together_split_at_top(self, large_operation):
"""Confirm the import was changed from KeepTogether to KeepTogetherSplitAtTop."""
from reportlab.platypus.flowables import KeepTogetherSplitAtTop
op, agent = large_operation
section = TTP_DET_MODULE.DebriefReportSection()
styles = getSampleStyleSheet()

flowables = await section.generate_section_elements(
styles,
operations=[op],
agents=[agent],
)

# At least one flowable should be a KeepTogetherSplitAtTop instance
ktat_found = any(isinstance(f, KeepTogetherSplitAtTop) for f in flowables)
assert ktat_found, (
"Expected at least one KeepTogetherSplitAtTop flowable in the output; "
"found types: " + str(set(type(f).__name__ for f in flowables))
)