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/source/app/business/reports/reporter.py b/source/app/business/reports/reporter.py index ec633dce2..46e402dd9 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,313 @@ 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 + + class IrisMakeDocReport: """ Generates a DOCX report for the case @@ -136,7 +450,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/source/app/datamgmt/reporter/report_db.py b/source/app/datamgmt/reporter/report_db.py index 5ea6897c8..5bb51eda9 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 +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 @@ -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 @@ -173,28 +171,52 @@ 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( 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 +224,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, @@ -272,6 +315,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) @@ -328,11 +372,54 @@ 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_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 = [] @@ -379,6 +466,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() @@ -392,6 +481,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/data/report_templates/variable_case_description_markdown.docx b/tests/data/report_templates/variable_case_description_markdown.docx new file mode 100644 index 000000000..2ca6e5b40 Binary files /dev/null and b/tests/data/report_templates/variable_case_description_markdown.docx differ 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/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/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/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/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 8b9e07794..4255f4845 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_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.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'} @@ -81,3 +152,105 @@ 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) + + 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) + + 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) + + 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) + + 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) + + 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)