diff --git a/app/debrief-sections/tactic_technique_table.py b/app/debrief-sections/tactic_technique_table.py index 1f3cc81..638bb4a 100644 --- a/app/debrief-sections/tactic_technique_table.py +++ b/app/debrief-sections/tactic_technique_table.py @@ -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('-', '-
') + 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 ]) # -------------------------------------------------------------------------- @@ -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 ], diff --git a/app/debrief-sections/ttps_detections.py b/app/debrief-sections/ttps_detections.py index 694e283..8ff4ab9 100644 --- a/app/debrief-sections/ttps_detections.py +++ b/app/debrief-sections/ttps_detections.py @@ -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 @@ -358,7 +358,11 @@ def _generate_detection_appendix(self, operation, paw_to_platform): flows.append(Paragraph(f'', 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]): @@ -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 @@ -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' ) diff --git a/app/debrief_svc.py b/app/debrief_svc.py index daf4c17..c956782 100644 --- a/app/debrief_svc.py +++ b/app/debrief_svc.py @@ -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')) @@ -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')) diff --git a/app/objects/c_story.py b/app/objects/c_story.py index 0109405..3da6381 100644 --- a/app/objects/c_story.py +++ b/app/objects/c_story.py @@ -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): @@ -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): diff --git a/tests/test_pdf_large_table.py b/tests/test_pdf_large_table.py new file mode 100644 index 0000000..eaecf71 --- /dev/null +++ b/tests/test_pdf_large_table.py @@ -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)) + )