From ce91002494302468b80ccd8ff7e2afd9c2f8c202 Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 28 Feb 2026 15:31:35 -0700 Subject: [PATCH 1/8] Improve DOCX markdown rendering for default filter (tables, code blocks, headings) Refs #691, #341 --- source/app/business/reports/reporter.py | 322 +++++++++++++++++++++++- tests/tests_rest_reports.py | 71 ++++++ 2 files changed, 392 insertions(+), 1 deletion(-) diff --git a/source/app/business/reports/reporter.py b/source/app/business/reports/reporter.py index ec633dce2..b343fca09 100644 --- a/source/app/business/reports/reporter.py +++ b/source/app/business/reports/reporter.py @@ -20,12 +20,19 @@ import logging as log import os +import re from datetime import datetime from app.models.errors import BusinessProcessingError from app.blueprints.iris_user import iris_current_user from docx_generator.docx_generator import DocxGenerator +from docx_generator.adapters.docx.docx_adapter import make_run, make_table_row, make_table_cell, make_paragraph +from docx_generator.adapters.docx.style_adapter import RenderStylesCollection +from docx_generator.adapters.docx.style_adapter import DocxStyleAdapter +from docx_generator.adapters.mistletoe.DocxRenderer import DocxRenderer from docx_generator.exceptions import rendering_error +from jinja2 import Environment +from markupsafe import Markup from app import app from app.business.cases import cases_export_to_json @@ -102,6 +109,319 @@ def _get_case_info_according_to_type(case_identifier, doc_type): return None +class IrisDocxGenerator(DocxGenerator): + _MARKDOWN_TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$') + _MARKDOWN_CODE_FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,}).*$') + _MARKDOWN_HEADING_RE = re.compile(r'^\s{0,3}(#{1,6})\s+(.*)$') + _RENDERER_STYLE_FIELDS = ( + 'header1', 'header2', 'header3', 'header4', 'header5', 'header6', + 'ul', 'ol', 'paragraph', 'inline_code', 'code', 'quote', + 'hyperlink', 'image_caption', 'strong', 'italic', 'strike', 'table' + ) + _DOCX_TABLE_INJECTION_PREFIX = '' + _DOCX_TABLE_INJECTION_SUFFIX = '' + + @staticmethod + def _builtin_heading_paragraph_style(level: int) -> str: + """Fallback paragraph style when template render styles don't define headerN.""" + safe_level = min(max(int(level), 1), 6) + return f'' + + @classmethod + def _is_markdown_table_separator(cls, line: str) -> bool: + return bool(cls._MARKDOWN_TABLE_SEPARATOR_RE.match(line or '')) + + @staticmethod + def _split_markdown_table_row(row: str): + row = row.strip() + if row.startswith('|'): + row = row[1:] + if row.endswith('|'): + row = row[:-1] + return [cell.strip() for cell in row.split('|')] + + @classmethod + def _split_markdown_and_tables(cls, markdown_string: str): + segments = [] + current_markdown = [] + lines = markdown_string.splitlines() + idx = 0 + + while idx < len(lines): + current_line = lines[idx] + code_fence_match = cls._MARKDOWN_CODE_FENCE_RE.match(current_line) + if code_fence_match: + if current_markdown: + segments.append(('markdown', '\n'.join(current_markdown))) + current_markdown = [] + + fence_token = code_fence_match.group(1) + fence_char = fence_token[0] + fence_len = len(fence_token) + + idx += 1 + code_lines = [] + closing_fence_re = re.compile(r'^\s*' + re.escape(fence_char) + '{' + str(fence_len) + r',}\s*$') + while idx < len(lines): + fence_candidate = lines[idx] + if closing_fence_re.match(fence_candidate): + idx += 1 + break + code_lines.append(fence_candidate) + idx += 1 + + segments.append(('code', code_lines)) + continue + + next_line = lines[idx + 1] if idx + 1 < len(lines) else None + is_table_start = ( + next_line is not None + and '|' in current_line + and cls._is_markdown_table_separator(next_line) + ) + + if not is_table_start: + current_markdown.append(current_line) + idx += 1 + continue + + if current_markdown: + segments.append(('markdown', '\n'.join(current_markdown))) + current_markdown = [] + + table_lines = [current_line, next_line] + idx += 2 + while idx < len(lines): + table_row = lines[idx] + if not table_row.strip() or '|' not in table_row: + break + following_line = lines[idx + 1] if idx + 1 < len(lines) else None + starts_new_table = ( + following_line is not None + and '|' in table_row + and cls._is_markdown_table_separator(following_line) + ) + if starts_new_table: + break + table_lines.append(table_row) + idx += 1 + + segments.append(('table', table_lines)) + + if current_markdown: + segments.append(('markdown', '\n'.join(current_markdown))) + + return segments + + @staticmethod + def _render_markdown_table(table_lines, style: DocxStyleAdapter = None): + header_cells = IrisDocxGenerator._split_markdown_table_row(table_lines[0]) + body_rows = [ + IrisDocxGenerator._split_markdown_table_row(row) + for row in table_lines[2:] + ] + paragraph_style = getattr(style, 'paragraph', '') or '' + table_style = getattr(style, 'table', '') or '' + + row_widths = [len(header_cells)] + [len(row) for row in body_rows] + column_count = max(max(row_widths), 1) + column_width = max(1, 9016 // column_count) + tbl_grid = '{}'.format( + ''.join(f'' for _ in range(column_count)) + ) + + def render_row(cells): + normalized_cells = cells + [''] * (column_count - len(cells)) + row_xml = ''.join( + make_table_cell(paragraph_style, make_run('', cell)) + for cell in normalized_cells + ) + return make_table_row(row_xml) + + header_xml = render_row(header_cells) + body_rows_xml = ''.join(render_row(row_cells) for row_cells in body_rows) + + return '{}{}{}'.format(table_style, tbl_grid, header_xml + body_rows_xml) + + @staticmethod + def _render_markdown_code_block(code_lines, style: DocxStyleAdapter = None): + code_paragraph_style = getattr(style, 'code', '') or '' + paragraph_style = code_paragraph_style or getattr(style, 'paragraph', '') or '' + run_style = '' if code_paragraph_style else (getattr(style, 'inline_code', '') or '') + code_text = '\n'.join(code_lines) if code_lines else '' + return make_paragraph(paragraph_style, make_run(run_style, code_text)) + + @staticmethod + def _markdown_to_styled_lines(markdown_segment: str, style: DocxStyleAdapter = None): + lines = [] + for raw_line in (markdown_segment or '').splitlines(): + if not raw_line.strip(): + continue + + heading_match = IrisDocxGenerator._MARKDOWN_HEADING_RE.match(raw_line) + if heading_match: + level = min(len(heading_match.group(1)), 6) + text = heading_match.group(2).strip() + paragraph_style = getattr(style, f'header{level}', '') or '' + if not paragraph_style: + paragraph_style = IrisDocxGenerator._builtin_heading_paragraph_style(level) + else: + text = raw_line + text = re.sub(r'^\s*[-+*]\s+', '', text) + text = re.sub(r'^\s*\d+\.\s+', '', text) + paragraph_style = getattr(style, 'paragraph', '') or '' + + # Strip common inline formatting markers while keeping content. + text = text.replace('**', '').replace('__', '') + text = text.replace('*', '').replace('_', '') + text = text.replace('`', '') + text = text.strip() + if not text: + continue + + lines.append((text, paragraph_style)) + + return lines + + @staticmethod + def _safe_style_value(value): + if value is None: + return '' + return value if isinstance(value, str) else str(value) + + @classmethod + def _normalize_style(cls, style, style_name='default'): + normalized = {field: cls._safe_style_value(getattr(style, field, '')) for field in cls._RENDERER_STYLE_FIELDS} + warnings = getattr(style, '_warnings', set()) if style is not None else set() + style_name = getattr(style, 'name', None) or style_name or 'default' + + class _SafeStyle: + def __init__(self, name, warnings_set, values): + self.name = name + self._warnings = set(warnings_set or set()) + for key, val in values.items(): + setattr(self, key, val) + + def __getattr__(self, _): + return '' + + return _SafeStyle(style_name, warnings, normalized) + + @staticmethod + def _build_fallback_style(style_name='default'): + """Provide a no-op style object so markdown rendering does not fail on missing template styles.""" + style_name = style_name or 'default' + return IrisDocxGenerator._normalize_style(None, style_name) + + @staticmethod + def _resolve_style(template_styles: RenderStylesCollection, renderer: DocxRenderer, style_name='default'): + candidates = [style_name, 'normal', 'Normal'] + + for candidate in candidates: + try: + style = template_styles.get_style(candidate) + if style is not None: + return IrisDocxGenerator._normalize_style(style, candidate) + except Exception: + continue + + for attr in ('styles', '_styles'): + styles = getattr(template_styles, attr, None) + if isinstance(styles, dict) and styles: + first_key = next(iter(styles.keys())) + try: + style = template_styles.get_style(first_key) + if style is not None: + return IrisDocxGenerator._normalize_style(style, first_key) + except Exception: + first_value = styles.get(first_key) + if first_value is not None: + return IrisDocxGenerator._normalize_style(first_value, first_key) + + for attr in ('style', '_style'): + style = getattr(renderer, attr, None) + if style is not None: + return IrisDocxGenerator._normalize_style(style, style_name) + + return IrisDocxGenerator._build_fallback_style(style_name) + + @staticmethod + def _markdown_to_styled_docx(markdown_string, renderer: DocxRenderer, + template_styles: RenderStylesCollection, style_name='default'): + """ + Render markdown in a way that stays valid when injected from a standard ``{{ ... }}`` DOCX run. + Text blocks are rendered as visible paragraph text, and Markdown tables are injected as DOCX tables. + """ + markdown_string = '' if markdown_string is None else str(markdown_string) + style = IrisDocxGenerator._resolve_style(template_styles, renderer, style_name) + if style is not None: + renderer.set_style(style) + segments = IrisDocxGenerator._split_markdown_and_tables(markdown_string) + rendered_parts = [] + has_any_output = False + + for segment_type, payload in segments: + if segment_type == 'table': + if not has_any_output: + # Anchor one visible-safe character before closing tags so XML closers are kept. + rendered_parts.append(' ') + has_any_output = True + rendered_parts.append( + '{}{}{}'.format( + IrisDocxGenerator._DOCX_TABLE_INJECTION_PREFIX, + IrisDocxGenerator._render_markdown_table(payload, style), + IrisDocxGenerator._DOCX_TABLE_INJECTION_SUFFIX + ) + ) + continue + + if segment_type == 'code': + if not has_any_output: + rendered_parts.append(' ') + has_any_output = True + rendered_parts.append( + '{}{}{}'.format( + IrisDocxGenerator._DOCX_TABLE_INJECTION_PREFIX, + IrisDocxGenerator._render_markdown_code_block(payload, style), + IrisDocxGenerator._DOCX_TABLE_INJECTION_SUFFIX + ) + ) + continue + + lines = IrisDocxGenerator._markdown_to_styled_lines(payload, style) + for line, paragraph_style in lines: + if not has_any_output: + rendered_parts.append(' ') + has_any_output = True + rendered_parts.append( + '{}{}{}'.format( + IrisDocxGenerator._DOCX_TABLE_INJECTION_PREFIX, + make_paragraph(paragraph_style, make_run('', line)), + IrisDocxGenerator._DOCX_TABLE_INJECTION_SUFFIX + ) + ) + + return_value = ''.join(rendered_parts) + return Markup(return_value) + + def _set_jinja2_custom_environment(self, base_path: str, template, jinja2_environment: Environment, + renderer: DocxRenderer, template_styles: RenderStylesCollection) -> None: + super()._set_jinja2_custom_environment(base_path, template, jinja2_environment, renderer, template_styles) + + # Override the upstream markdown filter so existing templates do not need + # to be updated/republished to benefit from robust table rendering. + def markdown(markdown_string, style_name='default'): + return self._markdown_to_styled_docx(markdown_string, renderer, template_styles, style_name) + + jinja2_environment.filters['markdown'] = markdown + + # Backward-compatible alias introduced during #691 work. + def markdown_styled(markdown_string, style_name='default'): + return markdown(markdown_string, style_name) + + jinja2_environment.filters['markdown_styled'] = markdown_styled + + class IrisMakeDocReport: """ Generates a DOCX report for the case @@ -136,7 +456,7 @@ def generate_doc_report(self, doc_type): else: image_handler = None - generator = DocxGenerator(image_handler=image_handler) + generator = IrisDocxGenerator(image_handler=image_handler) generator.generate_docx("/", os.path.join(app.config['TEMPLATES_PATH'], report.internal_reference), case_info, diff --git a/tests/tests_rest_reports.py b/tests/tests_rest_reports.py index 8b9e07794..4edbdd3ca 100644 --- a/tests/tests_rest_reports.py +++ b/tests/tests_rest_reports.py @@ -51,10 +51,81 @@ def test_generate_docx_report_should_render_variable_case_for_customer(self): case_identifier = self._subject.create_dummy_case() response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', {'cid': case_identifier, 'safe': True}) + self.assertEqual(200, response.status_code, response.text) with BytesIO(response.content) as content: document = Document(content) self.assertEqual('IrisInitialClient (legacy::use client.customer_name)', document.paragraphs[0].text) + def test_generate_docx_report_should_render_markdown_styled_variable_case_description(self): + data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', + 'report_name_format': 'report_name_format'} + report_identifier = self._subject.create_report(data, 'variable_case_description_markdown_styled.docx') + case_body = { + 'case_name': 'case name', + 'case_description': '# Heading\n\n- **bold item**\n- *italic item*\n- normal item\n\n' + '```python\n' + 'print("hello")\n' + 'for i in range(2):\n' + ' print(i)\n' + '```\n\n' + '|Table 1| Table 2 | Table 3 |\n' + '|--|--|--|\n' + '| A | 1 | 3 |\n' + '| B | 2 | 4 |\n' + '|Single column|\n' + '|--|\n' + '|Only value|\n' + '|C1|C2|C3|C4|C5|\n' + '|--|--|--|--|--|\n' + '|v1|v2|v3|v4|v5|', + 'case_customer_id': 1, + 'case_soc_id': '' + } + case_identifier = self._subject.create('/api/v2/cases', case_body).json()['case_id'] + + response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', + {'cid': case_identifier, 'safe': True}) + self.assertEqual(200, response.status_code, response.text) + with BytesIO(response.content) as content: + document = Document(content) + full_text = '\n'.join(paragraph.text for paragraph in document.paragraphs) + table_text = '\n'.join( + cell.text + for table in document.tables + for row in table.rows + for cell in row.cells + ) + self.assertIn('Heading', full_text) + heading_paragraphs = [p for p in document.paragraphs if p.text.strip() == 'Heading'] + self.assertTrue(heading_paragraphs, 'Heading paragraph missing') + self.assertTrue( + any('heading' in (p.style.name or '').lower() for p in heading_paragraphs), + f'Heading paragraph style not applied: {[p.style.name for p in heading_paragraphs]}' + ) + self.assertIn('bold item', full_text) + self.assertIn('italic item', full_text) + self.assertIn('normal item', full_text) + self.assertIn('print("hello")', full_text) + self.assertIn('for i in range(2):', full_text) + self.assertIn('Table 1', table_text) + self.assertIn('Table 2', table_text) + self.assertIn('Table 3', table_text) + self.assertIn('A', table_text) + self.assertIn('B', table_text) + self.assertIn('4', table_text) + self.assertIn('Single column', table_text) + self.assertIn('Only value', table_text) + self.assertIn('C5', table_text) + self.assertIn('v5', table_text) + self.assertEqual(3, len(document.tables)) + self.assertEqual(3, len(document.tables[0].rows[0].cells)) + self.assertEqual(1, len(document.tables[1].rows[0].cells)) + self.assertEqual(5, len(document.tables[2].rows[0].cells)) + self.assertNotIn('**', full_text) + self.assertNotIn('*italic item*', full_text) + self.assertNotIn('```', full_text) + self.assertNotIn('- ', full_text) + def test_generate_md_report_should_render_variable_case_name(self): data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', 'report_name_format': 'report_name_format'} From 693d0663bc9644f3b3b6c033862bc54e36cf8c8e Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 28 Feb 2026 15:38:15 -0700 Subject: [PATCH 2/8] Remove markdown_styled alias and enforce markdown filter usage --- source/app/business/reports/reporter.py | 6 ------ .../variable_case_description_markdown.docx | Bin 0 -> 4214 bytes tests/tests_rest_reports.py | 4 ++-- 3 files changed, 2 insertions(+), 8 deletions(-) create mode 100644 tests/data/report_templates/variable_case_description_markdown.docx diff --git a/source/app/business/reports/reporter.py b/source/app/business/reports/reporter.py index b343fca09..46e402dd9 100644 --- a/source/app/business/reports/reporter.py +++ b/source/app/business/reports/reporter.py @@ -415,12 +415,6 @@ def markdown(markdown_string, style_name='default'): jinja2_environment.filters['markdown'] = markdown - # Backward-compatible alias introduced during #691 work. - def markdown_styled(markdown_string, style_name='default'): - return markdown(markdown_string, style_name) - - jinja2_environment.filters['markdown_styled'] = markdown_styled - class IrisMakeDocReport: """ diff --git a/tests/data/report_templates/variable_case_description_markdown.docx b/tests/data/report_templates/variable_case_description_markdown.docx new file mode 100644 index 0000000000000000000000000000000000000000..2ca6e5b40aa11b2e8c0e7f6251adcddc324fe34d GIT binary patch literal 4214 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 AivR!s literal 0 HcmV?d00001 diff --git a/tests/tests_rest_reports.py b/tests/tests_rest_reports.py index 4edbdd3ca..526f0a872 100644 --- a/tests/tests_rest_reports.py +++ b/tests/tests_rest_reports.py @@ -56,10 +56,10 @@ def test_generate_docx_report_should_render_variable_case_for_customer(self): document = Document(content) self.assertEqual('IrisInitialClient (legacy::use client.customer_name)', document.paragraphs[0].text) - def test_generate_docx_report_should_render_markdown_styled_variable_case_description(self): + def test_generate_docx_report_should_render_markdown_variable_case_description(self): data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', 'report_name_format': 'report_name_format'} - report_identifier = self._subject.create_report(data, 'variable_case_description_markdown_styled.docx') + report_identifier = self._subject.create_report(data, 'variable_case_description_markdown.docx') case_body = { 'case_name': 'case name', 'case_description': '# Heading\n\n- **bold item**\n- *italic item*\n- normal item\n\n' From c830d0c5e737d64a0259c6e340950f17d5159dd0 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 1 Mar 2026 14:14:04 -0700 Subject: [PATCH 3/8] feat(reports): add task comments to investigation export (#692) --- source/app/datamgmt/reporter/report_db.py | 24 ++++++++++++++++++- .../variable_task_comments.md | 5 ++++ tests/tests_rest_reports.py | 17 +++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/data/report_templates/variable_task_comments.md diff --git a/source/app/datamgmt/reporter/report_db.py b/source/app/datamgmt/reporter/report_db.py index 5ea6897c8..adccd12a1 100644 --- a/source/app/datamgmt/reporter/report_db.py +++ b/source/app/datamgmt/reporter/report_db.py @@ -30,7 +30,7 @@ from app.models.models import CaseTasks from app.models.cases import Cases from app.models.cases import CasesEvent -from app.models.comments import Comments +from app.models.comments import Comments, TaskComments from app.models.models import EventCategory from app.models.iocs import Ioc from app.models.models import IocAssetLink @@ -328,11 +328,33 @@ def export_case_tasks_json(case_id): 'id': member.id }) task['task_assignees'] = assignee_list.get(task['id'], []) + task['comments'] = export_case_task_comments_json(task_id) task_with_assignees.append(task) return task_with_assignees +def export_case_task_comments_json(task_id): + comments = Comments.query.with_entities( + Comments.comment_id, + Comments.comment_uuid, + Comments.comment_text, + User.name.label('comment_by'), + Comments.comment_date + ).filter( + TaskComments.comment_task_id == task_id + ).join( + TaskComments, + Comments.comment_id == TaskComments.comment_id + ).join( + Comments.user + ).order_by( + Comments.comment_date.asc() + ).all() + + return [row._asdict() for row in comments] + + def export_case_assets_json(case_id): ret = [] diff --git a/tests/data/report_templates/variable_task_comments.md b/tests/data/report_templates/variable_task_comments.md new file mode 100644 index 000000000..2163cd4bb --- /dev/null +++ b/tests/data/report_templates/variable_task_comments.md @@ -0,0 +1,5 @@ +{% for task in tasks %} +{% for comment in task.comments %} +{{ comment.comment_text }} +{% endfor %} +{% endfor %} diff --git a/tests/tests_rest_reports.py b/tests/tests_rest_reports.py index 526f0a872..7d57c36f7 100644 --- a/tests/tests_rest_reports.py +++ b/tests/tests_rest_reports.py @@ -152,3 +152,20 @@ def test_generate_md_activities_report_should_render_variable_case_for_customer_ response = self._subject.get(f'/case/report/generate-activities/{report_identifier}', {'cid': case_identifier, 'safe': True}) self.assertEqual('IrisInitialClient (legacy::use client.customer_name)', response.text) + + def test_generate_md_report_should_render_task_comments(self): + data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', + 'report_name_format': 'report_name_format'} + report_identifier = self._subject.create_report(data, 'variable_task_comments.md') + case_identifier = self._subject.create_dummy_case() + + task_data = {'task_assignees_id': [], 'task_description': '', 'task_status_id': 1, 'task_tags': '', + 'task_title': 'dummy title', 'custom_attributes': {}} + task_response = self._subject.create(f'/api/v2/cases/{case_identifier}/tasks', task_data).json() + task_identifier = task_response['id'] + comment_text = 'task comment for report export' + self._subject.create(f'/api/v2/tasks/{task_identifier}/comments', {'comment_text': comment_text}) + + response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', + {'cid': case_identifier, 'safe': True}) + self.assertIn(comment_text, response.text) From ac0ea18eda4cb472e5c056cb501171ed043ebfda Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 1 Mar 2026 14:23:04 -0700 Subject: [PATCH 4/8] feat(reports): add asset comments to investigation export --- source/app/datamgmt/reporter/report_db.py | 25 ++++++++++++++++++- .../variable_asset_comments.md | 5 ++++ tests/tests_rest_reports.py | 16 ++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/data/report_templates/variable_asset_comments.md diff --git a/source/app/datamgmt/reporter/report_db.py b/source/app/datamgmt/reporter/report_db.py index adccd12a1..015a8fd4e 100644 --- a/source/app/datamgmt/reporter/report_db.py +++ b/source/app/datamgmt/reporter/report_db.py @@ -30,7 +30,7 @@ from app.models.models import CaseTasks from app.models.cases import Cases from app.models.cases import CasesEvent -from app.models.comments import Comments, TaskComments +from app.models.comments import Comments, TaskComments, AssetComments from app.models.models import EventCategory from app.models.iocs import Ioc from app.models.models import IocAssetLink @@ -401,6 +401,8 @@ def export_case_assets_json(case_id): else: row['asset_ioc'] = [] + row['comments'] = export_case_asset_comments_json(row['asset_id']) + if row['asset_compromise_status_id'] is None: row['asset_compromise_status_id'] = CompromiseStatus.unknown.value status_text = CompromiseStatus.unknown.name.replace('_', ' ').title() @@ -414,6 +416,27 @@ def export_case_assets_json(case_id): return ret +def export_case_asset_comments_json(asset_id): + comments = Comments.query.with_entities( + Comments.comment_id, + Comments.comment_uuid, + Comments.comment_text, + User.name.label('comment_by'), + Comments.comment_date + ).filter( + AssetComments.comment_asset_id == asset_id + ).join( + AssetComments, + Comments.comment_id == AssetComments.comment_id + ).join( + Comments.user + ).order_by( + Comments.comment_date.asc() + ).all() + + return [row._asdict() for row in comments] + + def export_case_comments_json(case_id): comments = Comments.query.with_entities( Comments.comment_id, diff --git a/tests/data/report_templates/variable_asset_comments.md b/tests/data/report_templates/variable_asset_comments.md new file mode 100644 index 000000000..e057bf13c --- /dev/null +++ b/tests/data/report_templates/variable_asset_comments.md @@ -0,0 +1,5 @@ +{% for asset in assets %} +{% for comment in asset.comments %} +{{ comment.comment_text }} +{% endfor %} +{% endfor %} diff --git a/tests/tests_rest_reports.py b/tests/tests_rest_reports.py index 7d57c36f7..bede37762 100644 --- a/tests/tests_rest_reports.py +++ b/tests/tests_rest_reports.py @@ -169,3 +169,19 @@ def test_generate_md_report_should_render_task_comments(self): response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', {'cid': case_identifier, 'safe': True}) self.assertIn(comment_text, response.text) + + def test_generate_md_report_should_render_asset_comments(self): + data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', + 'report_name_format': 'report_name_format'} + report_identifier = self._subject.create_report(data, 'variable_asset_comments.md') + case_identifier = self._subject.create_dummy_case() + + asset_data = {'asset_type_id': 1, 'asset_name': 'asset with comments'} + asset_response = self._subject.create(f'/api/v2/cases/{case_identifier}/assets', asset_data).json() + asset_identifier = asset_response['asset_id'] + comment_text = 'asset comment for report export' + self._subject.create(f'/api/v2/assets/{asset_identifier}/comments', {'comment_text': comment_text}) + + response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', + {'cid': case_identifier, 'safe': True}) + self.assertIn(comment_text, response.text) From 13a2a4bb62a7e463f28f98e96204e4fbcfb053b6 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 1 Mar 2026 14:45:26 -0700 Subject: [PATCH 5/8] feat(reports): add IOC comments to investigation export (#692) --- source/app/business/iocs.py | 31 +++++++++++++++++-- .../report_templates/variable_ioc_comments.md | 5 +++ tests/tests_rest_reports.py | 16 ++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/data/report_templates/variable_ioc_comments.md diff --git a/source/app/business/iocs.py b/source/app/business/iocs.py index 7247a991d..a109f7407 100644 --- a/source/app/business/iocs.py +++ b/source/app/business/iocs.py @@ -31,6 +31,8 @@ from app.iris_engine.utils.tracker import track_activity from app.models.errors import BusinessProcessingError from app.models.errors import ObjectNotFoundError +from app.models.authorization import User +from app.models.comments import Comments, IocComments from app.datamgmt.case.case_iocs_db import get_ioc from app.util import add_obj_history_entry from app.datamgmt.case.case_iocs_db import get_filtered_iocs @@ -106,8 +108,33 @@ def iocs_delete(ioc: Ioc): def iocs_exports_to_json(case_id): iocs = get_iocs(case_id) - - return IocSchema().dump(iocs, many=True) + serialized_iocs = IocSchema().dump(iocs, many=True) + + for ioc in serialized_iocs: + ioc['comments'] = _ioc_comments_export_to_json(ioc['ioc_id']) + + return serialized_iocs + + +def _ioc_comments_export_to_json(ioc_id): + comments = Comments.query.with_entities( + Comments.comment_id, + Comments.comment_uuid, + Comments.comment_text, + User.name.label('comment_by'), + Comments.comment_date + ).filter( + IocComments.comment_ioc_id == ioc_id + ).join( + IocComments, + Comments.comment_id == IocComments.comment_id + ).join( + Comments.user + ).order_by( + Comments.comment_date.asc() + ).all() + + return [row._asdict() for row in comments] def iocs_build_filter_query(ioc_id: int = None, diff --git a/tests/data/report_templates/variable_ioc_comments.md b/tests/data/report_templates/variable_ioc_comments.md new file mode 100644 index 000000000..f0e211cf8 --- /dev/null +++ b/tests/data/report_templates/variable_ioc_comments.md @@ -0,0 +1,5 @@ +{% for ioc in iocs %} +{% for comment in ioc.comments %} +{{ comment.comment_text }} +{% endfor %} +{% endfor %} diff --git a/tests/tests_rest_reports.py b/tests/tests_rest_reports.py index bede37762..7ab3dfb71 100644 --- a/tests/tests_rest_reports.py +++ b/tests/tests_rest_reports.py @@ -185,3 +185,19 @@ def test_generate_md_report_should_render_asset_comments(self): response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', {'cid': case_identifier, 'safe': True}) self.assertIn(comment_text, response.text) + + def test_generate_md_report_should_render_ioc_comments(self): + data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', + 'report_name_format': 'report_name_format'} + report_identifier = self._subject.create_report(data, 'variable_ioc_comments.md') + case_identifier = self._subject.create_dummy_case() + + ioc_data = {'ioc_type_id': 1, 'ioc_tlp_id': 2, 'ioc_value': '8.8.8.8', 'ioc_description': '', 'ioc_tags': ''} + ioc_response = self._subject.create(f'/api/v2/cases/{case_identifier}/iocs', ioc_data).json() + ioc_identifier = ioc_response['ioc_id'] + comment_text = 'ioc comment for report export' + self._subject.create(f'/api/v2/iocs/{ioc_identifier}/comments', {'comment_text': comment_text}) + + response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', + {'cid': case_identifier, 'safe': True}) + self.assertIn(comment_text, response.text) From fd0bfb8398397c4016cc1bf0916cf6f9b2e50544 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 1 Mar 2026 14:53:11 -0700 Subject: [PATCH 6/8] feat(reports): add event comments to investigation export (#692) --- source/app/datamgmt/reporter/report_db.py | 24 ++++++++++++++++++- .../variable_event_comments.md | 5 ++++ tests/tests_rest_reports.py | 18 ++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/data/report_templates/variable_event_comments.md diff --git a/source/app/datamgmt/reporter/report_db.py b/source/app/datamgmt/reporter/report_db.py index 015a8fd4e..da7d393b5 100644 --- a/source/app/datamgmt/reporter/report_db.py +++ b/source/app/datamgmt/reporter/report_db.py @@ -30,7 +30,7 @@ from app.models.models import CaseTasks from app.models.cases import Cases from app.models.cases import CasesEvent -from app.models.comments import Comments, TaskComments, AssetComments +from app.models.comments import Comments, TaskComments, AssetComments, EventComments from app.models.models import EventCategory from app.models.iocs import Ioc from app.models.models import IocAssetLink @@ -272,6 +272,7 @@ def export_case_tm_json(case_id): ).all() ras['iocs'] = [ioc._asdict() for ioc in iocs_list] + ras['comments'] = export_case_event_comments_json(row.event_id) tim.append(ras) @@ -355,6 +356,27 @@ def export_case_task_comments_json(task_id): return [row._asdict() for row in comments] +def export_case_event_comments_json(event_id): + comments = Comments.query.with_entities( + Comments.comment_id, + Comments.comment_uuid, + Comments.comment_text, + User.name.label('comment_by'), + Comments.comment_date + ).filter( + EventComments.comment_event_id == event_id + ).join( + EventComments, + Comments.comment_id == EventComments.comment_id + ).join( + Comments.user + ).order_by( + Comments.comment_date.asc() + ).all() + + return [row._asdict() for row in comments] + + def export_case_assets_json(case_id): ret = [] diff --git a/tests/data/report_templates/variable_event_comments.md b/tests/data/report_templates/variable_event_comments.md new file mode 100644 index 000000000..76ceaf091 --- /dev/null +++ b/tests/data/report_templates/variable_event_comments.md @@ -0,0 +1,5 @@ +{% for event in timeline %} +{% for comment in event.comments %} +{{ comment.comment_text }} +{% endfor %} +{% endfor %} diff --git a/tests/tests_rest_reports.py b/tests/tests_rest_reports.py index 7ab3dfb71..9c21b4641 100644 --- a/tests/tests_rest_reports.py +++ b/tests/tests_rest_reports.py @@ -201,3 +201,21 @@ def test_generate_md_report_should_render_ioc_comments(self): response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', {'cid': case_identifier, 'safe': True}) self.assertIn(comment_text, response.text) + + def test_generate_md_report_should_render_event_comments(self): + data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', + 'report_name_format': 'report_name_format'} + report_identifier = self._subject.create_report(data, 'variable_event_comments.md') + case_identifier = self._subject.create_dummy_case() + + event_data = {'event_title': 'title', 'event_category_id': 1, + 'event_date': '2025-03-26T00:00:00.000', 'event_tz': '+00:00', + 'event_assets': [], 'event_iocs': []} + event_response = self._subject.create(f'/api/v2/cases/{case_identifier}/events', event_data).json() + event_identifier = event_response['event_id'] + comment_text = 'event comment for report export' + self._subject.create(f'/api/v2/events/{event_identifier}/comments', {'comment_text': comment_text}) + + response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', + {'cid': case_identifier, 'safe': True}) + self.assertIn(comment_text, response.text) From 4412711516eeede8d63cdbc5782a1b0687870b47 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 1 Mar 2026 14:58:33 -0700 Subject: [PATCH 7/8] feat(reports): add note comments to investigation export (#692) --- source/app/datamgmt/reporter/report_db.py | 31 ++++++++++++++----- .../variable_note_comments.md | 5 +++ tests/tests_rest_reports.py | 19 ++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 tests/data/report_templates/variable_note_comments.md diff --git a/source/app/datamgmt/reporter/report_db.py b/source/app/datamgmt/reporter/report_db.py index da7d393b5..2a305b2ed 100644 --- a/source/app/datamgmt/reporter/report_db.py +++ b/source/app/datamgmt/reporter/report_db.py @@ -21,7 +21,6 @@ from sqlalchemy import desc from app.datamgmt.case.case_notes_db import get_notes_from_group -from app.datamgmt.case.case_notes_db import get_case_note_comments from app.models.assets import CompromiseStatus, AssetsType, CaseAssets, AnalysisStatus from app.models.models import TaskAssignee from app.models.models import CaseEventsAssets @@ -30,7 +29,7 @@ from app.models.models import CaseTasks from app.models.cases import Cases from app.models.cases import CasesEvent -from app.models.comments import Comments, TaskComments, AssetComments, EventComments +from app.models.comments import Comments, TaskComments, AssetComments, EventComments, NotesComments from app.models.models import EventCategory from app.models.iocs import Ioc from app.models.models import IocAssetLink @@ -41,7 +40,6 @@ from app.models.iocs import Tlp from app.models.authorization import User from app.schema.marshables import CaseDetailsSchema -from app.schema.marshables import CommentSchema from app.schema.marshables import CaseNoteSchema @@ -185,16 +183,14 @@ def export_case_notes_json(case_id): Notes.note_case_id == case_id ).all() - # Initialize the schemas + # Initialize the schema note_schema = CaseNoteSchema() - comments_schema = CommentSchema(many=True) # Serialize the notes and their comments serialized_notes = [] for note in notes: - note_comments = get_case_note_comments(note.note_id) serialized_note = note_schema.dump(note) - serialized_note['comments'] = comments_schema.dump(note_comments) + serialized_note['comments'] = export_case_note_comments_json(note.note_id) serialized_note['note_content'] = process_md_images_links_for_report(serialized_note['note_content']) serialized_notes.append(serialized_note) @@ -202,6 +198,27 @@ def export_case_notes_json(case_id): return serialized_notes +def export_case_note_comments_json(note_id): + comments = Comments.query.with_entities( + Comments.comment_id, + Comments.comment_uuid, + Comments.comment_text, + User.name.label('comment_by'), + Comments.comment_date + ).filter( + NotesComments.comment_note_id == note_id + ).join( + NotesComments, + Comments.comment_id == NotesComments.comment_id + ).join( + Comments.user + ).order_by( + Comments.comment_date.asc() + ).all() + + return [row._asdict() for row in comments] + + def export_case_tm_json(case_id): timeline = CasesEvent.query.with_entities( CasesEvent.event_id, diff --git a/tests/data/report_templates/variable_note_comments.md b/tests/data/report_templates/variable_note_comments.md new file mode 100644 index 000000000..9c9895117 --- /dev/null +++ b/tests/data/report_templates/variable_note_comments.md @@ -0,0 +1,5 @@ +{% for note in notes %} +{% for comment in note.comments %} +{{ comment.comment_text }} +{% endfor %} +{% endfor %} diff --git a/tests/tests_rest_reports.py b/tests/tests_rest_reports.py index 9c21b4641..39c7a1d3c 100644 --- a/tests/tests_rest_reports.py +++ b/tests/tests_rest_reports.py @@ -219,3 +219,22 @@ def test_generate_md_report_should_render_event_comments(self): response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', {'cid': case_identifier, 'safe': True}) self.assertIn(comment_text, response.text) + + def test_generate_md_report_should_render_note_comments(self): + data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', + 'report_name_format': 'report_name_format'} + report_identifier = self._subject.create_report(data, 'variable_note_comments.md') + case_identifier = self._subject.create_dummy_case() + + directory_response = self._subject.create(f'/api/v2/cases/{case_identifier}/notes-directories', + {'name': 'directory_name'}).json() + directory_identifier = directory_response['id'] + note_response = self._subject.create(f'/api/v2/cases/{case_identifier}/notes', + {'directory_id': directory_identifier}).json() + note_identifier = note_response['note_id'] + comment_text = 'note comment for report export' + self._subject.create(f'/api/v2/notes/{note_identifier}/comments', {'comment_text': comment_text}) + + response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', + {'cid': case_identifier, 'safe': True}) + self.assertIn(comment_text, response.text) From ac36ccae1b95ec3f2eba0e8eb0eac6245b7195bd Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 1 Mar 2026 15:03:35 -0700 Subject: [PATCH 8/8] feat(reports): add evidence comments to investigation export (#692) --- source/app/datamgmt/reporter/report_db.py | 30 +++++++++++++++++-- .../variable_evidence_comments.md | 5 ++++ tests/tests_rest_reports.py | 16 ++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/data/report_templates/variable_evidence_comments.md diff --git a/source/app/datamgmt/reporter/report_db.py b/source/app/datamgmt/reporter/report_db.py index 2a305b2ed..5bb51eda9 100644 --- a/source/app/datamgmt/reporter/report_db.py +++ b/source/app/datamgmt/reporter/report_db.py @@ -29,7 +29,7 @@ from app.models.models import CaseTasks from app.models.cases import Cases from app.models.cases import CasesEvent -from app.models.comments import Comments, TaskComments, AssetComments, EventComments, NotesComments +from app.models.comments import Comments, TaskComments, AssetComments, EventComments, NotesComments, EvidencesComments from app.models.models import EventCategory from app.models.iocs import Ioc from app.models.models import IocAssetLink @@ -171,12 +171,38 @@ def export_case_evidences_json(case_id): ).all() if evidences: + serialized_evidences = [] + for row in evidences: + serialized_evidence = row._asdict() + serialized_evidence['comments'] = export_case_evidence_comments_json(serialized_evidence['id']) + serialized_evidences.append(serialized_evidence) - return [row._asdict() for row in evidences] + return serialized_evidences return [] +def export_case_evidence_comments_json(evidence_id): + comments = Comments.query.with_entities( + Comments.comment_id, + Comments.comment_uuid, + Comments.comment_text, + User.name.label('comment_by'), + Comments.comment_date + ).filter( + EvidencesComments.comment_evidence_id == evidence_id + ).join( + EvidencesComments, + Comments.comment_id == EvidencesComments.comment_id + ).join( + Comments.user + ).order_by( + Comments.comment_date.asc() + ).all() + + return [row._asdict() for row in comments] + + def export_case_notes_json(case_id): # Fetch all notes associated with the case notes = Notes.query.filter( diff --git a/tests/data/report_templates/variable_evidence_comments.md b/tests/data/report_templates/variable_evidence_comments.md new file mode 100644 index 000000000..e212394bf --- /dev/null +++ b/tests/data/report_templates/variable_evidence_comments.md @@ -0,0 +1,5 @@ +{% for evidence in evidences %} +{% for comment in evidence.comments %} +{{ comment.comment_text }} +{% endfor %} +{% endfor %} diff --git a/tests/tests_rest_reports.py b/tests/tests_rest_reports.py index 39c7a1d3c..4255f4845 100644 --- a/tests/tests_rest_reports.py +++ b/tests/tests_rest_reports.py @@ -238,3 +238,19 @@ def test_generate_md_report_should_render_note_comments(self): response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', {'cid': case_identifier, 'safe': True}) self.assertIn(comment_text, response.text) + + def test_generate_md_report_should_render_evidence_comments(self): + data = {'report_name': 'name', 'report_type': 1, 'report_language': 1, 'report_description': 'description', + 'report_name_format': 'report_name_format'} + report_identifier = self._subject.create_report(data, 'variable_evidence_comments.md') + case_identifier = self._subject.create_dummy_case() + + evidence_response = self._subject.create(f'/api/v2/cases/{case_identifier}/evidences', + {'filename': 'filename'}).json() + evidence_identifier = evidence_response['id'] + comment_text = 'evidence comment for report export' + self._subject.create(f'/api/v2/evidences/{evidence_identifier}/comments', {'comment_text': comment_text}) + + response = self._subject.get(f'/case/report/generate-investigation/{report_identifier}', + {'cid': case_identifier, 'safe': True}) + self.assertIn(comment_text, response.text)