diff --git a/CHANGELOG.md b/CHANGELOG.md index 475d19d7a..3afe04e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [7.2.5] - 27 July 2026 +## [7.2.5] - 31 July 2026 ### Changed @@ -19,9 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * This fixed a typo in the path, but had no effect on functionality * Fixed activity-log sanitization confirmation and scoped its CSRF header to its own request * Fixed expired API and service token feedback so the appropriate token row and empty state are updated in the UI +* Fixed PowerPoint report generation when rich-text table cells contain blockquotes or preformatted text ### Security +* Hardened Jinja2 report rendering against sandbox escapes while preserving user-authored report templates and previews + * Operation-log values are treated as literal report data, including values containing captured Jinja2 payloads + * Lazy rich-text rendering now rejects templates that were not compiled by Ghostwriter's sandboxed environment + * Report template objects, Python callables, and document-export objects no longer expose unsafe attributes or call paths to Jinja2 * Hardened user-controlled values rendered in JavaScript contexts to prevent stored cross-site scripting * Autocomplete data is now serialized as inert JSON instead of being interpolated into JavaScript source * Tag autocomplete suggestions are scoped to objects the current user can access diff --git a/VERSION b/VERSION index 59a108f1f..29c948dbc 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ v7.2.5 -27 July 2026 +31 July 2026 diff --git a/config/settings/base.py b/config/settings/base.py index a5c4bdfd1..5d21c0026 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -13,7 +13,7 @@ __version__ = "7.2.5" VERSION = __version__ -RELEASE_DATE = "27 July 2026" +RELEASE_DATE = "31 July 2026" ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent APPS_DIR = ROOT_DIR / "ghostwriter" diff --git a/ghostwriter/api/tests/test_views.py b/ghostwriter/api/tests/test_views.py index 79f37bf86..a4af78941 100644 --- a/ghostwriter/api/tests/test_views.py +++ b/ghostwriter/api/tests/test_views.py @@ -1,5 +1,6 @@ # Standard Libraries import base64 +import copy import json import logging import os @@ -64,6 +65,7 @@ StaticServerFactory, UserFactory, ) +from ghostwriter.modules.reportwriter import jinja_string_literal, prepare_jinja2_env from ghostwriter.oplog.utils import ( CAST_GZIP_TOO_LARGE_UPLOAD_MESSAGE, get_cast_decompressed_bytes, @@ -3566,15 +3568,90 @@ def test_graphql_evidence_update_event(self): ) self.assertEqual(response.status_code, 200) self.finding.refresh_from_db() + encoded_name = jinja_string_literal("New Name") + inline_evidence = "{{ mk_evidence(" + encoded_name + ") }}" + evidence_reference = "{{ mk_ref(" + encoded_name + ") }}" self.assertEqual( self.finding.description, - "
Here is some evidence:
{{.New Name}}
{{.ref New Name}}
", + f"Here is some evidence:
{inline_evidence}
" + f"{evidence_reference}
", ) self.assertEqual( self.finding.impact, - "Here is some evidence:
{{.New Name}}
{{.ref New Name}}
", + f"Here is some evidence:
{inline_evidence}
" + f"{evidence_reference}
", ) + def test_graphql_evidence_update_encodes_friendly_name_as_literal_data(self): + payload = "safe}}CLIENT={{ client.name }}{{.ref safe" + update_data = copy.deepcopy(self.update_data_report) + update_data["event"]["data"]["new"]["friendly_name"] = payload + + response = self.client.post( + self.uri, + content_type="application/json", + data=update_data, + **{ + "HTTP_HASURA_ACTION_SECRET": f"{ACTION_SECRET}", + }, + ) + + self.assertEqual(response.status_code, 200) + self.finding.refresh_from_db() + self.assertNotIn(payload, self.finding.description) + + captured_names = [] + + def capture_name(name): + captured_names.append(name) + return "" + + env = prepare_jinja2_env() + env.globals["mk_evidence"] = capture_name + env.globals["mk_ref"] = capture_name + rendered = env.from_string(self.finding.description).render( + client={"name": "Victim Client"}, + ) + + self.assertEqual(captured_names, [payload, payload]) + self.assertNotIn("Victim Client", rendered) + + def test_graphql_evidence_update_rewrites_an_encoded_previous_name(self): + first_response = self.client.post( + self.uri, + content_type="application/json", + data=self.update_data_report, + **{ + "HTTP_HASURA_ACTION_SECRET": f"{ACTION_SECRET}", + }, + ) + self.assertEqual(first_response.status_code, 200) + + second_update = copy.deepcopy(self.update_data_report) + second_update["event"]["data"]["old"]["friendly_name"] = "New Name" + second_update["event"]["data"]["new"]["friendly_name"] = "Final Name" + second_response = self.client.post( + self.uri, + content_type="application/json", + data=second_update, + **{ + "HTTP_HASURA_ACTION_SECRET": f"{ACTION_SECRET}", + }, + ) + + self.assertEqual(second_response.status_code, 200) + self.finding.refresh_from_db() + final_literal = jinja_string_literal("Final Name") + self.assertIn( + "{{ mk_evidence(" + final_literal + ") }}", + self.finding.description, + ) + self.assertIn( + "{{ mk_ref(" + final_literal + ") }}", + self.finding.description, + ) + self.assertNotIn(jinja_string_literal("New Name"), self.finding.description) + def test_graphql_evidence_delete_event(self): self.assertTrue(os.path.exists(self.deleted_evidence.document.path)) response = self.client.post( diff --git a/ghostwriter/api/views.py b/ghostwriter/api/views.py index ed2b9efdb..769164cce 100644 --- a/ghostwriter/api/views.py +++ b/ghostwriter/api/views.py @@ -57,6 +57,7 @@ from ghostwriter.modules import codenames from ghostwriter.modules.model_utils import set_finding_positions, to_dict from ghostwriter.modules.passive_voice.detector import get_detector +from ghostwriter.modules.reportwriter import jinja_string_literal from ghostwriter.modules.reportwriter.report.json import ExportReportJson from ghostwriter.oplog.models import OplogEntry, OplogEntryEvidence, OplogEntryRecording from ghostwriter.oplog.utils import extract_cast_text, validate_cast_gzip_upload @@ -1985,12 +1986,22 @@ def post(self, request, *args, **kwargs): friendly = None friendly_ref = None if self.event["op"] == "UPDATE": - friendly = f"{{{{.{self.new_data['friendly_name']}}}}}" - friendly_ref = f"{{{{.ref {self.new_data['friendly_name']}}}}}" + encoded_name = jinja_string_literal(self.new_data["friendly_name"]) + friendly = f"{{{{ mk_evidence({encoded_name}) }}}}" + friendly_ref = f"{{{{ mk_ref({encoded_name}) }}}}" # Track previous friendly name and reference prev_friendly = f"{{{{.{self.old_data['friendly_name']}}}}}" prev_friendly_ref = f"{{{{.ref {self.old_data['friendly_name']}}}}}" + encoded_previous_name = jinja_string_literal( + self.old_data["friendly_name"] + ) + prev_encoded_friendly = ( + f"{{{{ mk_evidence({encoded_previous_name}) }}}}" + ) + prev_encoded_friendly_ref = ( + f"{{{{ mk_ref({encoded_previous_name}) }}}}" + ) logger.info( "Updating content of ReportFindingLink instances with updated name for Evidence %s", @@ -2011,9 +2022,21 @@ def post(self, request, *args, **kwargs): if self.event["op"] == "DELETE": new = current.replace(f"{prev_friendly}
", "") new = new.replace(prev_friendly_ref, "") + new = new.replace( + f"{prev_encoded_friendly}
", "" + ) + new = new.replace(prev_encoded_friendly_ref, "") else: new = current.replace(prev_friendly, friendly) new = new.replace(prev_friendly_ref, friendly_ref) + new = new.replace( + prev_encoded_friendly, + friendly, + ) + new = new.replace( + prev_encoded_friendly_ref, + friendly_ref, + ) setattr(instance, field.name, new) instance.save() except ReportFindingLink.DoesNotExist: diff --git a/ghostwriter/modules/reportwriter/__init__.py b/ghostwriter/modules/reportwriter/__init__.py index 7c4d95f42..d1d968173 100644 --- a/ghostwriter/modules/reportwriter/__init__.py +++ b/ghostwriter/modules/reportwriter/__init__.py @@ -5,9 +5,15 @@ # Standard Libraries import logging +import types +from datetime import date, datetime, time, timedelta # 3rd Party Libraries import jinja2 +import jinja2.environment +import jinja2.ext +import jinja2.nodes +import jinja2.runtime import jinja2.sandbox from ghostwriter.modules.reportwriter import jinja_funcs @@ -15,6 +21,166 @@ logger = logging.getLogger(__name__) +def jinja_string_literal(value: str) -> str: + """ + Encode a value, including quotes, without Jinja template delimiters. + + Every code point is escaped so HTML parsing and sanitizer normalization cannot + turn data into Jinja syntax before the template is compiled. + """ + return '"' + "".join(f"\\U{ord(character):08x}" for character in value) + '"' + + +class ReportSandboxedEnvironment(jinja2.sandbox.ImmutableSandboxedEnvironment): + """ + Immutable Jinja environment for all report-controlled template source. + + Compiler, template, and extension objects are capabilities rather than report + data. Templates must never inspect or call through them, even if a future + context accidentally exposes one. + """ + + _blocked_attribute_types = ( + jinja2.environment.Environment, + jinja2.environment.Template, + jinja2.environment.TemplateStream, + jinja2.ext.Extension, + jinja2.nodes.Node, + jinja2.runtime.Context, + types.ModuleType, + ) + + _blocked_callable_attribute_types = ( + types.FunctionType, + types.BuiltinFunctionType, + types.MethodType, + types.BuiltinMethodType, + ) + + _blocked_object_module_prefixes = ( + "docx.", + "docxtpl.", + ) + + _safe_builtin_method_owner_types = ( + bool, + bytes, + date, + datetime, + dict, + float, + frozenset, + int, + list, + range, + set, + str, + time, + timedelta, + tuple, + ) + + _safe_jinja_method_owner_types = ( + jinja2.runtime.AsyncLoopContext, + jinja2.runtime.LoopContext, + jinja2.utils.Cycler, + ) + + _safe_jinja_callable_types = ( + jinja2.runtime.BlockReference, + jinja2.runtime.Macro, + jinja2.utils.Joiner, + ) + + @classmethod + def _is_blocked_class(cls, obj): + """Return whether obj is a class for one of the blocked capability types.""" + return isinstance(obj, type) and issubclass(obj, cls._blocked_attribute_types) + + @classmethod + def _is_blocked_object(cls, obj): + """Return whether an object must expose no template capabilities.""" + if obj is None: + return False + return ( + getattr(type(obj), "_jinja_block_all_attributes", False) + or isinstance(obj, cls._blocked_attribute_types) + or cls._is_blocked_class(obj) + or type(obj).__module__.startswith(cls._blocked_object_module_prefixes) + ) + + def _is_registered_callable(self, obj): + """Return whether a callable was explicitly registered with the environment.""" + return any( + obj is candidate + for registry in (self.globals, self.filters, self.tests) + for candidate in registry.values() + ) or any( + obj is candidate + for candidate in ( + jinja_funcs.caption, + jinja_funcs.ref, + jinja_funcs.mk_evidence, + ) + ) + + @classmethod + def _is_safe_builtin_method(cls, obj): + """ + Return whether obj is an inherited method of a known data-only type. + + Looking up the defining class prevents a dict or string subclass from + introducing an executable method and inheriting trust from its base type. + """ + if not isinstance(obj, types.BuiltinMethodType): + return False + owner = obj.__self__ + method_name = getattr(obj, "__name__", None) + if owner is None or method_name is None: + return False + for base in type(owner).__mro__: + if method_name in base.__dict__: + return base in cls._safe_builtin_method_owner_types + return False + + def is_safe_attribute(self, obj, attr, value): + """Deny access to application-marked objects and Jinja internals.""" + if self._is_blocked_object(obj) or isinstance( + obj, + self._blocked_callable_attribute_types, + ): + return False + return super().is_safe_attribute(obj, attr, value) + + def is_safe_callable(self, obj): + """Permit only registered functions and explicitly data-only call targets.""" + owner = ( + obj.__self__ + if isinstance( + obj, + ( + types.MethodType, + types.BuiltinMethodType, + ), + ) + else None + ) + if self._is_blocked_object(obj) or self._is_blocked_object(owner): + return False + if self._is_registered_callable(obj): + return super().is_safe_callable(obj) + if self._is_safe_builtin_method(obj): + return super().is_safe_callable(obj) + if ( + isinstance(obj, types.MethodType) + and type(owner) in self._safe_jinja_method_owner_types + ): + return super().is_safe_callable(obj) + if type(obj) in self._safe_jinja_callable_types: + return super().is_safe_callable(obj) + return False + + def prepare_jinja2_env(debug=False): """Prepare a Jinja2 environment with all custom filters.""" if debug: @@ -46,7 +212,7 @@ def __bool__(self): else: undefined = jinja2.make_logging_undefined(logger=logger, base=jinja2.Undefined) - env = jinja2.sandbox.SandboxedEnvironment(undefined=undefined, extensions=["jinja2.ext.debug"], autoescape=True) + env = ReportSandboxedEnvironment(undefined=undefined, autoescape=True) env.filters["filter_severity"] = jinja_funcs.filter_severity env.filters["filter_type"] = jinja_funcs.filter_type env.filters["strip_html"] = jinja_funcs.strip_html @@ -59,24 +225,34 @@ def __bool__(self): env.filters["regex_search"] = jinja_funcs.regex_search env.filters["filter_tags"] = jinja_funcs.filter_tags env.filters["replace_blanks"] = jinja_funcs.replace_blanks - env.filters["filter_bhe_findings_by_domain"] = jinja_funcs.filter_bhe_findings_by_domain + env.filters[ + "filter_bhe_findings_by_domain" + ] = jinja_funcs.filter_bhe_findings_by_domain env.filters["translate_domain_sid"] = jinja_funcs.translate_domain_sid if debug: return env, undefined_vars return env + def report_generation_queryset(): """ Gets a queryset of Reports with `select_related` and `prefetch_related` options optimal for report generation. """ - from ghostwriter.reporting.models import Report # pylint: disable=import-outside-toplevel - return Report.objects.all().prefetch_related( - "tags", - "reportfindinglink_set", - "reportobservationlink_set", - "evidence_set", - "project__oplog_set", - "project__oplog_set__entries", - "project__oplog_set__entries__tags", - ).select_related() + from ghostwriter.reporting.models import ( # pylint: disable=import-outside-toplevel + Report, + ) + + return ( + Report.objects.all() + .prefetch_related( + "tags", + "reportfindinglink_set", + "reportobservationlink_set", + "evidence_set", + "project__oplog_set", + "project__oplog_set__entries", + "project__oplog_set__entries__tags", + ) + .select_related() + ) diff --git a/ghostwriter/modules/reportwriter/base/base.py b/ghostwriter/modules/reportwriter/base/base.py index 5b1d65820..f5b1e3b88 100644 --- a/ghostwriter/modules/reportwriter/base/base.py +++ b/ghostwriter/modules/reportwriter/base/base.py @@ -12,7 +12,11 @@ from ghostwriter.commandcenter.models import CompanyInformation, ExtraFieldSpec from ghostwriter.modules.reportwriter import prepare_jinja2_env from ghostwriter.modules.reportwriter.base import ReportExportTemplateError -from ghostwriter.modules.reportwriter.base.html_rich_text import LazilyRenderedTemplate, rich_text_template +from ghostwriter.modules.reportwriter.base.html_rich_text import ( + HtmlRichText, + LazilyRenderedTemplate, + rich_text_template, +) class ExportBase: @@ -132,6 +136,27 @@ def process_extra_fields(self, location: str, extra_fields: dict, model, context context, ) + def process_literal_extra_fields( + self, + location: str, + extra_fields: dict, + model, + ): + """ + Fill extra-field defaults without treating rich-text values as templates. + + This is used for data sources such as operation logs, whose values may + contain Jinja payloads recorded during an assessment. + """ + for field in self.extra_field_specs_for(model): + if field.internal_name not in extra_fields: + extra_fields[field.internal_name] = field.empty_value() + if field.type == "rich_text": + extra_fields[field.internal_name] = HtmlRichText( + str(extra_fields[field.internal_name]), + f"extra field {field.internal_name} of {location}", + ) + def map_rich_texts(self): """ Replaces rich text entries in `self.data` with `LazilyRenderedTemplate` or `HtmlAndRich` instances. diff --git a/ghostwriter/modules/reportwriter/base/docx.py b/ghostwriter/modules/reportwriter/base/docx.py index bf4be35c7..53790df29 100644 --- a/ghostwriter/modules/reportwriter/base/docx.py +++ b/ghostwriter/modules/reportwriter/base/docx.py @@ -34,12 +34,13 @@ "Caption", "List Paragraph", "Blockquote", - "footnote text", # Lowercase to match style name - "footnote reference" # Lowercase to match style name + "footnote text", # Lowercase to match style name + "footnote reference", # Lowercase to match style name ] + [f"Heading {i}" for i in range(1, 7)] _img_desc_replace_re = re.compile(r"^\s*\[\s*([a-zA-Z0-9_]+)\s*\]\s*(.*)$") + class ExportDocxBase(ExportBase): """ Base class for exporting DOCX (Word) documents. @@ -92,10 +93,18 @@ def __init__( try: self.word_doc = DocxTemplate(report_template.document.path) except PackageNotFoundError as err: - logger.exception("Failed to load the provided template document: %s", report_template.document.path) - raise ReportExportTemplateError("Template document file could not be found - try re-uploading it") from err + logger.exception( + "Failed to load the provided template document: %s", + report_template.document.path, + ) + raise ReportExportTemplateError( + "Template document file could not be found - try re-uploading it" + ) from err except Exception: - logger.exception("Failed to load the provided template document: %s", report_template.document.path) + logger.exception( + "Failed to load the provided template document: %s", + report_template.document.path, + ) raise self.global_report_config = ReportConfiguration.get_solo() @@ -113,21 +122,28 @@ def run(self) -> io.BytesIO: ) ReportExportTemplateError.map_errors( - lambda: self.word_doc.render(docx_context, self.jinja_env, autoescape=True), "the DOCX template" + lambda: self.word_doc.render( + docx_context, self.jinja_env, autoescape=True + ), + "the DOCX template", ) ReportExportTemplateError.map_errors( lambda: self.render_properties(docx_context), "the DOCX properties" ) except UnrecognizedImageError as err: - raise ReportExportTemplateError(f"Could not load an image: {err}", "the DOCX template") from err + raise ReportExportTemplateError( + f"Could not load an image: {err}", "the DOCX template" + ) from err except PackageNotFoundError as err: raise ReportExportTemplateError( - "The word template could not be found on the server – try uploading it again.", "the DOCX template" + "The word template could not be found on the server – try uploading it again.", + "the DOCX template", ) from err except FileNotFoundError as err: logger.exception("Missing file") raise ReportExportTemplateError( - "An evidence file was missing – try uploading it again.", "the DOCX template" + "An evidence file was missing – try uploading it again.", + "the DOCX template", ) from err out = io.BytesIO() @@ -155,7 +171,10 @@ def _cleanup_footnote_separators(self, docx_bytes: io.BytesIO) -> io.BytesIO: # Access the footnotes part (requires accessing internal python-docx members) # pylint: disable=protected-access - if not hasattr(doc._part, '_footnotes_part') or doc._part._footnotes_part is None: + if ( + not hasattr(doc._part, "_footnotes_part") + or doc._part._footnotes_part is None + ): docx_bytes.seek(0) return docx_bytes @@ -275,16 +294,21 @@ def render_properties(self, context: dict): continue out = ReportExportTemplateError.map_errors( - lambda: self.jinja_env.from_string(template_src).render(context), f"DOCX property {attr}" + lambda template_src=template_src: self.jinja_env.from_string( + template_src + ).render(context), + f"DOCX property {attr}", ) setattr(self.word_doc.core_properties, attr, out) - def render_rich_text_docx(self, rich_text: RichTextBase) -> LazySubdocRender | DocxRichText: + def render_rich_text_docx( + self, rich_text: RichTextBase + ) -> LazySubdocRender | DocxRichText: """ Renders a `RichTextBase`, converting the HTML from the rich text editor, to a Word subdoc. """ if isinstance(rich_text, HtmlAndObject): - return rich_text.obj + return rich_text.exporter_object def render(): doc = self.word_doc.new_subdoc() @@ -300,6 +324,7 @@ def render(): getattr(rich_text, "location", None), ) return doc + return LazySubdocRender(render) def replace_images(self): @@ -324,14 +349,16 @@ def replace_images(self): for hp in headers_and_footers: if not hp._has_definition: continue - toplevels.append(( - hp.part, - hp.part._element, - )) + toplevels.append( + ( + hp.part, + hp.part._element, + ) + ) # Go through each part and replace matcing drawings image_rids_and_objs = {} - for (part, element) in toplevels: + for part, element in toplevels: for drawing in element.xpath(".//w:drawing"): docpr = next(iter(drawing.xpath(".//wp:docPr")), None) if docpr is None: @@ -340,7 +367,10 @@ def replace_images(self): blip = next(iter(drawing.xpath(".//pic:pic//a:blip")), None) if blip is None: continue - if "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed" not in blip.attrib: + if ( + "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed" + not in blip.attrib + ): continue # Get image name from alt text @@ -364,8 +394,9 @@ def replace_images(self): image_rids_and_objs[key] = rid # Replace image - blip.attrib["{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"] = rid - + blip.attrib[ + "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed" + ] = rid @classmethod def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]: @@ -381,7 +412,10 @@ def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]: logger.info("Linting docx file %r", report_template.document.path) try: if not os.path.exists(report_template.document.path): - logger.error("Template file path did not exist: %r", report_template.document.path) + logger.error( + "Template file path did not exist: %r", + report_template.document.path, + ) errors.append("Template file does not exist – upload it again") return warnings, errors @@ -395,42 +429,72 @@ def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]: logger.info("Template loaded for linting") undeclared_variables = ReportExportTemplateError.map_errors( - lambda: exporter.word_doc.get_undeclared_template_variables(exporter.jinja_env), "the DOCX template" + lambda: exporter.word_doc.get_undeclared_template_variables( + exporter.jinja_env + ), + "the DOCX template", ) for variable in undeclared_variables: if variable not in lint_data: - warnings.append("Potential undefined variable: {!r}".format(variable)) + warnings.append( + "Potential undefined variable: {!r}".format(variable) + ) document_styles = exporter.word_doc.get_docx().styles for style in EXPECTED_STYLES: if style not in document_styles: - warnings.append("Template is missing a recommended style (see documentation): " + style) + warnings.append( + "Template is missing a recommended style (see documentation): " + + style + ) else: if style == "CodeInline": if document_styles[style].type != WD_STYLE_TYPE.CHARACTER: - warnings.append("CodeInline style is not a character style (see documentation)") + warnings.append( + "CodeInline style is not a character style (see documentation)" + ) if style == "CodeBlock": if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH: - warnings.append("CodeBlock style is not a paragraph style (see documentation)") + warnings.append( + "CodeBlock style is not a paragraph style (see documentation)" + ) if style == "Bullet List": if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH: - warnings.append("Bullet List style is not a paragraph style (see documentation)") + warnings.append( + "Bullet List style is not a paragraph style (see documentation)" + ) if style == "Number List": if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH: - warnings.append("Number List style is not a paragraph style (see documentation)") + warnings.append( + "Number List style is not a paragraph style (see documentation)" + ) if style == "List Paragraph": if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH: - warnings.append("List Paragraph style is not a paragraph style (see documentation)") + warnings.append( + "List Paragraph style is not a paragraph style (see documentation)" + ) if style == "footnote text": if document_styles[style].type != WD_STYLE_TYPE.PARAGRAPH: - warnings.append("Footnote Text style is not a character style (see documentation)") + warnings.append( + "Footnote Text style is not a character style (see documentation)" + ) if style == "footnote reference": if document_styles[style].type != WD_STYLE_TYPE.CHARACTER: - warnings.append("Footnote Reference style is not a character style (see documentation)") + warnings.append( + "Footnote Reference style is not a character style (see documentation)" + ) if "Table Grid" not in document_styles: - errors.append("Template is missing a required style (see documentation): Table Grid") - if report_template.p_style and report_template.p_style not in document_styles: - warnings.append("Template is missing your configured default paragraph style: " + report_template.p_style) + errors.append( + "Template is missing a required style (see documentation): Table Grid" + ) + if ( + report_template.p_style + and report_template.p_style not in document_styles + ): + warnings.append( + "Template is missing your configured default paragraph style: " + + report_template.p_style + ) exporter.run() @@ -443,7 +507,9 @@ def lint(cls, report_template: ReportTemplate) -> Tuple[List[str], List[str]]: logger.exception("Template failed linting") errors.append("Template rendering failed unexpectedly") - logger.info("Linting finished: %d warnings, %d errors", len(warnings), len(errors)) + logger.info( + "Linting finished: %d warnings, %d errors", len(warnings), len(errors) + ) return warnings, errors def bloodhound_heading_offset(self) -> int: diff --git a/ghostwriter/modules/reportwriter/base/html_rich_text.py b/ghostwriter/modules/reportwriter/base/html_rich_text.py index 73ba4dd08..fca1b4a81 100644 --- a/ghostwriter/modules/reportwriter/base/html_rich_text.py +++ b/ghostwriter/modules/reportwriter/base/html_rich_text.py @@ -1,15 +1,136 @@ - +# Standard Libraries +import html as html_lib import re +import secrets +from abc import ABC, abstractmethod from typing import Any, Callable + +# 3rd Party Libraries import bs4 import jinja2 from markupsafe import Markup -from abc import ABC, abstractmethod -from ghostwriter.modules.reportwriter import jinja_funcs +# Ghostwriter Libraries +from ghostwriter.modules.reportwriter import ReportSandboxedEnvironment, jinja_funcs from ghostwriter.modules.reportwriter.base import ReportExportTemplateError _H = [f"h{n}" for n in range(1, 7)] +JINJA_LITERAL_ATTRIBUTE = "data-gw-jinja-literal" +JINJA_REFERENCE_ENCODED_ATTRIBUTE = "data-gw-ref-encoded" +JINJA_LITERAL_VALUE_ATTRIBUTES = ( + "data-evidence-id", + "data-gw-caption", + "data-gw-evidence", + "data-gw-image", +) +_JINJA_REFERENCE_ATTRIBUTE_PATTERN = re.compile( + rf"(?P[\"'])(?P[0-9a-fA-F-]*)(?P=quote)", + re.IGNORECASE, +) + + +def _decode_jinja_reference_attributes(text: str) -> str: + """ + Restore inert editor reference attributes after Jinja has finished rendering. + + The editor persists each reference name as hexadecimal Unicode code points, so + even a malicious evidence name cannot introduce Jinja delimiters into template + source. + """ + + def replace_reference(match: re.Match) -> str: + try: + ref_name = "".join( + chr(int(code_point, 16)) + for code_point in match.group("value").split("-") + if code_point + ) + except ValueError: + return match.group(0) + return ( + f'{match.group("space")}data-gw-ref="' + f'{html_lib.escape(ref_name, quote=True)}"' + ) + + return _JINJA_REFERENCE_ATTRIBUTE_PATTERN.sub(replace_reference, text) + + +def _require_report_sandbox(template_or_environment): + """Reject templates that were not created by Ghostwriter's report sandbox.""" + environment = ( + template_or_environment.environment + if isinstance(template_or_environment, jinja2.Template) + else template_or_environment + ) + if not isinstance(environment, ReportSandboxedEnvironment): + raise TypeError("Rich-text templates must use ReportSandboxedEnvironment") + + +class CompiledRichTextTemplate: + """ + A compiled rich-text template with HTML fragments excluded from Jinja parsing. + + Literal fragments are represented by unpredictable inert placeholders while + Jinja compiles and renders the rest of the rich text. They are restored only + after the single template-rendering pass has completed. + """ + + _jinja_block_all_attributes = True + + def __init__(self, template: jinja2.Template, literal_fragments: dict[str, str]): + _require_report_sandbox(template) + self._template = template + self._literal_fragments = literal_fragments + + def render(self, *args, **kwargs): + rendered = self._template.render(*args, **kwargs) + rendered = _decode_jinja_reference_attributes(rendered) + for placeholder, literal_html in self._literal_fragments.items(): + rendered = rendered.replace(placeholder, literal_html) + return rendered + + +def _extract_jinja_literal_fragments(text: str) -> tuple[str, dict[str, str]]: + """ + Replace literal containers and stored references before Jinja parsing. + + The marker container itself is intentionally omitted from the rendered output; + only its contents are restored. Nested markers are handled by their outermost + marked ancestor. Legacy reference nodes and structural marker values are data, + never template source. + """ + soup = bs4.BeautifulSoup(text, "html.parser") + literal_fragments = {} + for node in soup.find_all(attrs={JINJA_LITERAL_ATTRIBUTE: True}): + if node.find_parent(attrs={JINJA_LITERAL_ATTRIBUTE: True}) is not None: + continue + + placeholder = f"GWJINJALITERAL{secrets.token_hex(24)}" + literal_fragments[placeholder] = _decode_jinja_reference_attributes( + node.decode_contents() + ) + node.replace_with(placeholder) + + for node in soup.find_all(attrs={"data-gw-ref": True}): + placeholder = f"GWJINJALITERAL{secrets.token_hex(24)}" + literal_fragments[placeholder] = str(node) + node.replace_with(placeholder) + + for node in soup.find_all(True): + for attribute in JINJA_LITERAL_VALUE_ATTRIBUTES: + if attribute not in node.attrs: + continue + placeholder = f"GWJINJALITERAL{secrets.token_hex(24)}" + attribute_value = node.attrs[attribute] + if isinstance(attribute_value, list): + attribute_value = " ".join(attribute_value) + literal_fragments[placeholder] = html_lib.escape( + str(attribute_value), + quote=True, + ) + node.attrs[attribute] = placeholder + return str(soup), literal_fragments def remove_trailing_empty_paragraphs(body): @@ -32,11 +153,18 @@ def remove_trailing_empty_paragraphs(body): def rich_text_template( env: jinja2.Environment, text: str, -) -> jinja2.Template: +) -> CompiledRichTextTemplate: """ - Converts rich text `text` to a Jinja template. This does some additional Ghostwriter-specific - processing. + Compile rich text into Ghostwriter's sandboxed rich-text template wrapper. + + Literal data is excluded from Jinja parsing and restored after rendering. """ + _require_report_sandbox(env) + + # Remove generated literal data from the source before any normalization can + # assemble or interpret Jinja delimiters. + text, literal_fragments = _extract_jinja_literal_fragments(text) + # Replace old `{{.item}}`` syntax with jinja templates or elements to replace def replace_old_tag(match: re.Match): contents = match.group(1).strip() @@ -55,9 +183,7 @@ def replace_old_tag(match: re.Match): text = re.sub(r"\{\{\s*\.([^\{\}]*?)\s*\}\}", replace_old_tag, text) # Replace TinyMCE page breaks with something that the parser can easily pick up - text = text.replace( - "", '
' - ) + text = text.replace("", '
') # Replace `{%li foreach %}`-esque prefixes. This is similar to what python-docx-template does. soup = bs4.BeautifulSoup(text, "html.parser") @@ -69,7 +195,10 @@ def replace_old_tag(match: re.Match): # Compile try: - return env.from_string(text) + return CompiledRichTextTemplate( + env.from_string(text), + literal_fragments, + ) except jinja2.TemplateSyntaxError as err: line = text.splitlines()[err.lineno - 1] raise ReportExportTemplateError(str(err), code_context=line) from err @@ -81,7 +210,9 @@ def _process_prefix(input_str: str, soup: bs4.BeautifulSoup, prefix: str): in the passed-in soup. """ - regex = re.compile(r"^\s*(\{%|\{\{)\s*" + re.escape(prefix) + r"\b(.*)(%\}|\}\})\s*$") + regex = re.compile( + r"^\s*(\{%|\{\{)\s*" + re.escape(prefix) + r"\b(.*)(%\}|\}\})\s*$" + ) # Store in list since we mutate the nodes matching_strings = list(soup.find_all(string=regex)) for node in matching_strings: @@ -93,7 +224,10 @@ def _process_prefix(input_str: str, soup: bs4.BeautifulSoup, prefix: str): break if parent_tag is None: line = input_str.splitlines()[node.parent.sourceline - 1] - raise ReportExportTemplateError(f"Jinja tag prefixed with '{prefix}' was not a descendant of a {prefix} tag", code_context=line) + raise ReportExportTemplateError( + f"Jinja tag prefixed with '{prefix}' was not a descendant of a {prefix} tag", + code_context=line, + ) capture = regex.search(node) parent_tag.replace_with(capture.group(1) + capture.group(2) + capture.group(3)) @@ -104,6 +238,10 @@ class RichTextBase(ABC): Base class for a value that can produce some rich text, represented as HTML. """ + # Templates only need the rendered value. Public attributes and methods are + # implementation details and must not expose application or Jinja objects. + _jinja_block_all_attributes = True + # User-friendly descriptor of where the rich text was produced location: str | None @@ -114,25 +252,32 @@ def __html__(self) -> Markup | str: """ @staticmethod - def deep_copy_process_html(value: Any, process_html: Callable[["RichTextBase"], Any]): + def deep_copy_process_html( + value: Any, process_html: Callable[["RichTextBase"], Any] + ): """ Deep copies a value, mapping any `RichTextBase` subclasses through `process_html`. """ if isinstance(value, RichTextBase): return process_html(value) if isinstance(value, dict): - return {k: RichTextBase.deep_copy_process_html(v, process_html) for k,v in value.items()} + return { + k: RichTextBase.deep_copy_process_html(v, process_html) + for k, v in value.items() + } if isinstance(value, list): return [RichTextBase.deep_copy_process_html(v, process_html) for v in value] return value + class HtmlRichText(RichTextBase): """ An HTML string, with no templating. """ + html: str - def __init__(self, html: str, location: str | None=None): + def __init__(self, html: str, location: str | None = None): super().__init__() self.html = html self.location = location @@ -140,45 +285,59 @@ def __init__(self, html: str, location: str | None=None): def __html__(self): return self.html + class LazilyRenderedTemplate(RichTextBase): """ Renders a Jinja template lazily """ - template: jinja2.Template - context: dict + location: str | None - rendered: str | None - rendering: bool - def __init__(self, template: jinja2.Template, location: str | None, context: dict): + def __init__( + self, + template: CompiledRichTextTemplate, + location: str | None, + context: dict, + ): super().__init__() - self.template = template - self.context = context + if not isinstance(template, CompiledRichTextTemplate): + raise TypeError( + "LazilyRenderedTemplate requires a CompiledRichTextTemplate" + ) + self._template = template + self._context = context self.location = location - self.rendered = None - self.rendering = False + self._rendered = None + self._rendering = False def render_html(self): """ Will throw a `ReportExportTemplateError` if the template attempted to render itself while it was rendering (i.e. infinite recursion). """ - if self.rendered is None: - if self.rendering: - raise ReportExportTemplateError(f"Circular reference to {self.location} (ensure rich text fields are not referencing each other)") - self.rendering = True - self.rendered = Markup( - ReportExportTemplateError.map_errors( - lambda: self.template.render(self.context), - self.location, + if self._rendered is None: + if self._rendering: + raise ReportExportTemplateError( + f"Circular reference to {self.location} (ensure rich text fields are not referencing each other)" ) - ) - self.rendering = False - return self.rendered + self._rendering = True + try: + # Rich-text HTML is intentionally preserved for the document + # converters; input fields are sanitized before reaching here. + self._rendered = Markup( # nosec B704 + ReportExportTemplateError.map_errors( + lambda: self._template.render(self._context), + self.location, + ) + ) + finally: + self._rendering = False + return self._rendered def __html__(self): return self.render_html() + class HtmlAndObject(RichTextBase): """ HTML rich text and an exporter-specific object (ex. a docx `RichText`). @@ -187,21 +346,29 @@ class HtmlAndObject(RichTextBase): """ html: str - obj: Any def __init__(self, html: str, obj, location: str | None = None): super().__init__() self.html = html - self.obj = obj + self._obj = obj self.location = location + @property + def exporter_object(self): + """Return the exporter-specific object to trusted exporter code.""" + return self._obj + def __html__(self): return self.html + class LazySubdocRender: """ Renders a subdocument via a render function lazily """ + + _jinja_block_all_attributes = True + def __init__(self, render): self._render = render self._rendered = None @@ -216,6 +383,7 @@ def __html__(self): self._rendered = self._render() return self._rendered.__html__() + def offset_headings(html: str, heading_offset: int): """ Increases the level of `h1-6` tags in the `html`. diff --git a/ghostwriter/modules/reportwriter/project/base.py b/ghostwriter/modules/reportwriter/project/base.py index 649d589d9..e332adeea 100644 --- a/ghostwriter/modules/reportwriter/project/base.py +++ b/ghostwriter/modules/reportwriter/project/base.py @@ -192,11 +192,10 @@ def process_projects_richtext( # Logs for log in base_context["logs"]: for entry in log["entries"]: - ex.process_extra_fields( + ex.process_literal_extra_fields( f"log entry {entry['description']} of log {log['name']}", entry["extra_fields"], OplogEntry, - rich_text_context, ) # BloodHound findings diff --git a/ghostwriter/modules/reportwriter/richtext/pptx.py b/ghostwriter/modules/reportwriter/richtext/pptx.py index 27a5a5fe6..4edf4a886 100644 --- a/ghostwriter/modules/reportwriter/richtext/pptx.py +++ b/ghostwriter/modules/reportwriter/richtext/pptx.py @@ -108,14 +108,19 @@ def tag_p(self, el, par=None, **kwargs): self.process_children(el, par=par, **kwargs) - def tag_pre(self, el, **kwargs): - top = Inches(1.65) - left = Inches(8) - width = Inches(4.5) - height = Inches(3) - textbox = self.slide.shapes.add_textbox(left, top, width, height) + def tag_pre(self, el, *, par=None, **kwargs): + if par is None: + top = Inches(1.65) + left = Inches(8) + width = Inches(4.5) + height = Inches(3) + textbox = self.slide.shapes.add_textbox(left, top, width, height) + par = textbox.text_frame.add_paragraph() + elif any(run.text for run in par.runs): + # Keep nested preformatted text in its current text frame, such as + # a table cell, instead of creating a detached slide textbox. + par = par._parent.add_paragraph() self.text_tracking.new_block() - par = textbox.text_frame.add_paragraph() par.alignment = PP_ALIGN.LEFT if len(el.contents) == 1 and el.contents[0].name == "code": @@ -133,8 +138,13 @@ def tag_pre(self, el, **kwargs): run.font.size = Pt(11) run.font.name = "Courier New" - def tag_blockquote(self, el, **kwargs): - par = self.shape.text_frame.add_paragraph() + def tag_blockquote(self, el, *, par=None, **kwargs): + if par is None: + par = self.shape.text_frame.add_paragraph() + elif any(run.text for run in par.runs): + # Keep a nested blockquote in its current text frame, including + # when that frame belongs to a table cell. + par = par._parent.add_paragraph() self.text_tracking.new_block() self.process_children(el.children, par=par, **kwargs) diff --git a/ghostwriter/reporting/tests/test_rich_text_pptx.py b/ghostwriter/reporting/tests/test_rich_text_pptx.py index 21da566e0..32516e970 100644 --- a/ghostwriter/reporting/tests/test_rich_text_pptx.py +++ b/ghostwriter/reporting/tests/test_rich_text_pptx.py @@ -95,6 +95,73 @@ def test_func(self): class RichTextToPptxTests(SimpleTestCase): maxDiff = None + def test_blockquote_in_table_cell_is_rendered(self): + ppt = pptx.Presentation() + slide = ppt.slides.add_slide(ppt.slide_layouts[SLD_LAYOUT_TITLE_AND_CONTENT]) + shape = slide.shapes.placeholders[1] + shape.text_frame.clear() + + HtmlToPptx.run( + """ ++ +
+ """, + slide, + shape, + ) + + table = next( + slide_shape.table for slide_shape in slide.shapes if slide_shape.has_table + ) + self.assertEqual( + [paragraph.text for paragraph in table.cell(0, 0).text_frame.paragraphs], + ["Before quote", "Quoted text"], + ) + + def test_preformatted_text_in_table_cell_is_rendered(self): + ppt = pptx.Presentation() + slide = ppt.slides.add_slide(ppt.slide_layouts[SLD_LAYOUT_TITLE_AND_CONTENT]) + shape = slide.shapes.placeholders[1] + shape.text_frame.clear() + + HtmlToPptx.run( + """ ++ + ++ +Before quote
+Quoted text++ +
+ """, + slide, + shape, + ) + + table = next( + slide_shape.table for slide_shape in slide.shapes if slide_shape.has_table + ) + paragraphs = table.cell(0, 0).text_frame.paragraphs + self.assertEqual( + [paragraph.text for paragraph in paragraphs], + ["Command output", "first line\nsecond line"], + ) + self.assertEqual( + {run.font.name for run in paragraphs[1].runs}, + {"Courier New"}, + ) + def test_trailing_empty_paragraph_after_list_is_omitted(self): ppt = pptx.Presentation() slide = ppt.slides.add_slide(ppt.slide_layouts[SLD_LAYOUT_TITLE_AND_CONTENT]) diff --git a/ghostwriter/reporting/tests/test_rich_text_templating.py b/ghostwriter/reporting/tests/test_rich_text_templating.py index 1e8d8e8eb..e7632247f 100644 --- a/ghostwriter/reporting/tests/test_rich_text_templating.py +++ b/ghostwriter/reporting/tests/test_rich_text_templating.py @@ -1,40 +1,525 @@ +# Standard Libraries +from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory +# Django Imports from django.test import SimpleTestCase, TestCase -from ghostwriter.factories import ExtraFieldModelFactory, ExtraFieldSpecFactory, ReportFactory -from ghostwriter.modules.reportwriter import prepare_jinja2_env +# 3rd Party Libraries +import jinja2 +from docxtpl import RichText as DocxRichText +from jinja2.exceptions import SecurityError + +# Ghostwriter Libraries +from ghostwriter.factories import ( + ExtraFieldModelFactory, + ExtraFieldSpecFactory, + OplogEntryFactory, + ReportFactory, +) +from ghostwriter.modules.reportwriter import jinja_funcs, prepare_jinja2_env from ghostwriter.modules.reportwriter.base import ReportExportTemplateError from ghostwriter.modules.reportwriter.base.base import ExportBase -from ghostwriter.modules.reportwriter.base.html_rich_text import rich_text_template +from ghostwriter.modules.reportwriter.base.html_rich_text import ( + CompiledRichTextTemplate, + HtmlRichText, + LazilyRenderedTemplate, + rich_text_template, +) +from ghostwriter.modules.reportwriter.forms import JinjaRichTextField from ghostwriter.modules.reportwriter.report.docx import ExportReportDocx +from ghostwriter.modules.reportwriter.report.json import ExportReportJson +from ghostwriter.oplog.models import OplogEntry from ghostwriter.reporting.models import Report class RichTextTemplatingTests(SimpleTestCase): maxDiff = None + @staticmethod + def render_with_project_description(source): + """Render source with the rich-text object used by the reported escapes.""" + env = prepare_jinja2_env() + context = {} + context["project"] = { + "description_rt": LazilyRenderedTemplate( + rich_text_template(env, ""), + "the project description", + context, + ) + } + return LazilyRenderedTemplate( + rich_text_template(env, source), + "test rich text", + context, + ).render_html() + + def test_debug_extension_is_not_enabled(self): + env, undefined_variables = prepare_jinja2_env(debug=True) + + self.assertNotIn("jinja2.ext.DebugExtension", env.extensions) + with self.assertRaises(jinja2.TemplateSyntaxError): + env.from_string("{% debug %}") + + env.from_string("{{ missing_variable }}").render() + self.assertEqual(undefined_variables, {"missing_variable"}) + + def test_sandbox_does_not_expose_rich_text_attributes(self): + env = prepare_jinja2_env() + rich_text = LazilyRenderedTemplate( + rich_text_template(env, "+ + ++ +Command output
++first line\nsecond lineSafe content
"), + "sensitive location", + {}, + ) + + rendered = env.from_string( + "{{ rich_text }}|{{ rich_text.location }}|" "{{ rich_text.render_html }}" + ).render(rich_text=rich_text) + + self.assertEqual(rendered, "Safe content
||") + + def test_lazy_template_rejects_raw_jinja_template(self): + template = jinja2.Template("{{ cycler.__init__.__globals__.os.name }}") + + with self.assertRaisesMessage( + TypeError, + "LazilyRenderedTemplate requires a CompiledRichTextTemplate", + ): + LazilyRenderedTemplate(template, "test", {}) + + def test_rich_text_template_rejects_unsandboxed_environment(self): + env = jinja2.Environment() + + with self.assertRaisesMessage( + TypeError, + "Rich-text templates must use ReportSandboxedEnvironment", + ): + rich_text_template(env, "safe") + + with self.assertRaisesMessage( + TypeError, + "Rich-text templates must use ReportSandboxedEnvironment", + ): + CompiledRichTextTemplate(env.from_string("safe"), {}) + + def test_sandbox_is_immutable(self): + env = prepare_jinja2_env() + + with self.assertRaises(SecurityError): + env.from_string("{{ values.clear() }}").render(values=["safe"]) + + def test_documented_jinja_features_remain_available(self): + env = prepare_jinja2_env() + template = env.from_string( + "{% set totals=namespace(value=0) %}" + '{% for item in items|sort(attribute="name") %}' + "{{ loop.index }}:{{ item.name|upper }};" + "{% set totals.value=totals.value + item.value %}" + "{% endfor %}" + 'match={{ "Ghostwriter 42"|regex_search("[0-9]+") }};' + "total={{ totals.value }}" + ) + + rendered = template.render( + items=[ + {"name": "beta", "value": 2}, + {"name": "alpha", "value": 3}, + ] + ) + + self.assertEqual(rendered, "1:ALPHA;2:BETA;match=42;total=5") + + def test_standard_jinja_globals_remain_available(self): + env = prepare_jinja2_env() + + self.assertLessEqual( + {"cycler", "dict", "joiner", "lipsum", "namespace", "range"}, + env.globals.keys(), + ) + + def test_registered_globals_and_template_macros_remain_callable(self): + env = prepare_jinja2_env() + template = env.from_string( + "{% macro render_value(value) %}[{{ value }}]{% endmacro %}" + "{% for number in range(3) %}" + "{{ render_value(dict(value=number).value) }}" + "{% endfor %}" + '{% set rows=cycler("odd", "even") %}' + "{{ rows.next() }}-{{ rows.next() }}|" + '{% set comma=joiner(",") %}' + "{{ comma() }}a{{ comma() }}b" + ) + + self.assertEqual(template.render(), "[0][1][2]odd-even|a,b") + + def test_data_only_builtin_methods_remain_callable(self): + env = prepare_jinja2_env() + template = env.from_string( + '{{ values.get("name").upper() }}|' + "{% for key, value in values.items()|sort %}" + "{{ key }}={{ value }};" + "{% endfor %}" + ) + + self.assertEqual( + template.render(values={"name": "ghostwriter", "count": 2}), + "GHOSTWRITER|count=2;name=ghostwriter;", + ) + + def test_jinja_loop_and_block_helpers_remain_callable(self): + env = prepare_jinja2_env() + template = env.from_string( + "{% block content %}content{% endblock %}|" + "{{ self.content() }}|" + "{% for number in range(2) %}{{ loop.cycle('a', 'b') }}{% endfor %}" + ) + + self.assertEqual(template.render(), "content|content|ab") + + def test_unregistered_python_callable_is_blocked(self): + env = prepare_jinja2_env() + + def unregistered(value): + return value + + with self.assertRaises(SecurityError): + env.from_string("{{ unregistered('unsafe') }}").render( + unregistered=unregistered + ) + + rendered = env.from_string('{{ mk_ref("Example") }}').render( + mk_ref=jinja_funcs.ref + ) + self.assertIn('data-gw-ref="Example"', rendered) + + def test_unregistered_bound_method_and_callable_object_are_blocked(self): + env = prepare_jinja2_env() + + class Capability: + def execute(self): + return "executed" + + def __call__(self): + return "called" + + capability = Capability() + for payload in ("{{ capability.execute() }}", "{{ capability() }}"): + with self.subTest(payload=payload), self.assertRaises(SecurityError): + env.from_string(payload).render(capability=capability) + + sink = StringIO() + with self.assertRaises(SecurityError): + env.from_string("{{ sink.write('executed') }}").render(sink=sink) + self.assertEqual(sink.getvalue(), "") + + with self.assertRaises(SecurityError): + env.from_string("{{ operation(values) }}").render( + operation=len, + values=[], + ) + + def test_data_type_subclass_cannot_introduce_callable_methods(self): + env = prepare_jinja2_env() + + class ExecutableDict(dict): + def execute(self): + return "executed" + + with self.assertRaises(SecurityError): + env.from_string("{{ values.execute() }}").render(values=ExecutableDict()) + + def test_python_callable_attributes_are_blocked(self): + env = prepare_jinja2_env() + + rendered = env.from_string( + "{{ mk_ref.__name__ }}|{{ mk_ref.jinja_pass_arg }}" + ).render(mk_ref=jinja_funcs.ref) + + self.assertEqual(rendered, "|") + + def test_docx_exporter_objects_expose_no_template_attributes(self): + env = prepare_jinja2_env() + rich_text = DocxRichText("Safe") + + rendered = env.from_string("{{ value.xml }}|{{ value.add }}").render( + value=rich_text + ) + + self.assertEqual(rendered, "|") + + def test_common_generic_sandbox_escape_families_are_blocked(self): + env = prepare_jinja2_env() + payloads = [ + '{{ cycler.__init__.__globals__["os"] }}', + '{{ lipsum.__globals__["os"] }}', + '{{ namespace.__init__.__globals__["os"] }}', + "{{ dict.__base__ }}", + '{{ "".__class__.__mro__ }}', + '{{ (namespace|attr("__init__"))|attr("__globals__") }}', + '{{ "{0.__class__}".format("x") }}', + '{{ "{x.__class__}".format_map({"x": "y"}) }}', + ] + + for payload in payloads: + with self.subTest(payload=payload): + try: + rendered = env.from_string(payload).render() + except SecurityError: + continue + self.assertEqual(rendered, "") + + def test_rich_text_form_accepts_user_authored_jinja(self): + source = ( + '{% for value in ["one", "two"] %}' + "{{ loop.index }}={{ value }} " + "{% endfor %}
" + ) + + self.assertEqual(JinjaRichTextField().clean(source), source) + + def test_nested_rich_text_still_renders_as_template_data(self): + env = prepare_jinja2_env() + context = {"client": {"name": "Example Client"}} + context["project"] = { + "description_rt": LazilyRenderedTemplate( + rich_text_template( + env, + "{{ client.name }}", + ), + "the project description", + context, + ) + } + outer = LazilyRenderedTemplate( + rich_text_template( + env, + "{{ project.description_rt }} ", + ), + "outer rich text", + context, + ) + + self.assertEqual( + outer.render_html(), + "Example Client ", + ) + + def test_marked_oplog_content_is_restored_only_after_jinja_rendering(self): + env = prepare_jinja2_env() + payload = ( + "{% set e=project.description_rt.template.environment %}" + "{{ e.template_class(" + "\"{{ cycler.__init__.__globals__.os.popen('id').read() }}\"" + ").render() }}" + ) + source = ( + "Authored template: {{ client.name }}
" + '' + f"" + "{payload}
" + "" + "{{ client.name }} {% endraw %} {#Still authored: {{ client.name|upper }}
" + ) + + rendered = rich_text_template(env, source).render( + client={"name": "Example Client"}, + project={}, + ) + + self.assertIn("Authored template: Example Client", rendered) + self.assertIn("Still authored: EXAMPLE CLIENT", rendered) + self.assertIn(payload, rendered) + self.assertIn("{{ client.name }} {% endraw %} {#", rendered) + self.assertNotIn("data-gw-jinja-literal", rendered) + + def test_marked_oplog_content_survives_html_entity_normalization(self): + env = prepare_jinja2_env() + source = ( + '' + "" + ) + + rendered = rich_text_template(env, source).render( + client={"name": "Rendered Client"} + ) + + self.assertIn("{{ client.name }}", rendered) + self.assertIn("safe}}CLIENT={{ client.name }}{{.ref safe", rendered) + self.assertNotIn("Rendered Client", rendered) + + def test_encoded_reference_is_decoded_only_after_jinja_rendering(self): + env = prepare_jinja2_env() + ref_name = "safe}}CLIENT={{ client.name }}{{.ref safe" + encoded_ref = "-".join(f"{ord(character):x}" for character in ref_name) + encoded_node = f'{{ client.name }}
" + '' + "{{.ref safe}}CLIENT={{ client.name }}{{.ref safe}}" + "
" + "' + + for source in ( + encoded_node + "
{{ client.name }}
", + f'{encoded_node}' + "{{ client.name }}
", + ): + with self.subTest(source=source): + rendered = rich_text_template(env, source).render( + client={"name": "Rendered Client"} + ) + + self.assertIn( + 'data-gw-ref="safe}}CLIENT={{ client.name }}{{.ref safe"', + rendered, + ) + self.assertNotIn("data-gw-ref-encoded", rendered) + self.assertEqual(rendered.count("Rendered Client"), 1) + + def test_legacy_reference_node_is_always_literal_data(self): + env = prepare_jinja2_env() + source = ( + '' + "{{ client.name }}" + "
" + "{{ client.name }}
" + ) + + rendered = rich_text_template(env, source).render( + client={"name": "Rendered Client"} + ) + + self.assertIn("{{ client.name }}", rendered) + self.assertEqual(rendered.count("Rendered Client"), 1) + + def test_structural_marker_values_are_always_literal_data(self): + env = prepare_jinja2_env() + marker_value = "safe}}CLIENT={{ client.name }}{{.ref safe" + + for attribute in ( + "data-evidence-id", + "data-gw-caption", + "data-gw-evidence", + "data-gw-image", + ): + with self.subTest(attribute=attribute): + source = ( + f'' + "Caption for {{ client.name }}" + "" + ) + + rendered = rich_text_template(env, source).render( + client={"name": "Rendered Client"} + ) + + self.assertIn(f'{attribute}="{marker_value}"', rendered) + self.assertIn("Caption for Rendered Client", rendered) + self.assertEqual(rendered.count("Rendered Client"), 1) + + def test_invalid_encoded_reference_remains_inert(self): + env = prepare_jinja2_env() + source = '' + + rendered = rich_text_template(env, source).render() + + self.assertIn('data-gw-ref-encoded="110000"', rendered) + + def test_debug_extension_ast_escape_is_blocked(self): + payload = """ + {% set e=project.description_rt.template.environment %} + {% set a=e.parse('{{ 0 }}') %} + {% set x=a.body[0].nodes.clear() %} + {% set x=a.body[0].nodes.append(e.extensions['jinja2.ext.DebugExtension'].call_method('__init__.__globals__["__builtins__"]["__import__"]("os").popen("id").read')) %} + {{ e.from_string(a).render() }} + """ + + with self.assertRaises(ReportExportTemplateError): + self.render_with_project_description(payload) + + def test_unsandboxed_template_constructor_escape_is_blocked(self): + payload = """ + {{ project.description_rt.template.environment.template_class( + "{{ cycler.__init__.__globals__.os.popen('id').read() }}" + ).render() }} + """ + + with self.assertRaises(ReportExportTemplateError): + self.render_with_project_description(payload) + + def test_environment_capabilities_are_blocked_if_directly_exposed(self): + env = prepare_jinja2_env() + payload = '{{ exposed.template_class("{{ 7 * 7 }}").render() }}' + + with self.assertRaises(SecurityError): + env.from_string(payload).render(exposed=env) + + direct_template_payload = '{{ exposed("{{ 7 * 7 }}").render() }}' + with self.assertRaises(SecurityError): + env.from_string(direct_template_payload).render( + exposed=jinja2.environment.Template + ) + + extension = jinja2.ext.DebugExtension(env) + with self.assertRaises(SecurityError): + env.from_string('{{ exposed.call_method("anything") }}').render( + exposed=extension + ) + + parsed_ast = env.parse("{{ 0 }}") + with self.assertRaises(SecurityError): + env.from_string('{{ exposed.set_ctx("load") }}').render(exposed=parsed_ast) + + def test_template_stream_file_write_is_blocked_if_template_is_exposed(self): + env = prepare_jinja2_env() + exposed_template = env.from_string("attacker-controlled") + + with TemporaryDirectory() as temp_dir: + destination = Path(temp_dir) / "written.py" + payload = "{{ exposed.stream().dump(destination) }}" + + with self.assertRaises(SecurityError): + env.from_string(payload).render( + exposed=exposed_template, + destination=str(destination), + ) + + self.assertFalse(destination.exists()) + def test_list(self): env, _ = prepare_jinja2_env(debug=True) - template = rich_text_template(env, "") - out = template.render({ - "thelist": ["foo", "bar", "baz"] - }) + template = rich_text_template( + env, + "
- {%li for i in thelist %}
- {{i}}
- {%li endfor %}
", + ) + out = template.render({"thelist": ["foo", "bar", "baz"]}) self.assertEqual(out, "
- {%li for i in thelist %}
- {{i}}
- {%li endfor %}
") def test_table(self): env, _ = prepare_jinja2_env(debug=True) - template = rich_text_template(env, "
- foo
- bar
- baz
") - out = template.render({ - "thelist": [["foo", 1], ["bar", 2], ["baz", 3]] - }) - self.assertEqual(out, "
{%tr for row in thelist%} {{row[0]}} {{row[1]}} {%tr endfor %} ") + template = rich_text_template( + env, + "
foo 1 bar 2 baz 3 ", + ) + out = template.render({"thelist": [["foo", 1], ["bar", 2], ["baz", 3]]}) + self.assertEqual( + out, + "
{%tr for row in thelist%} {{row[0]}} {{row[1]}} {%tr endfor %} ", + ) def test_prefix_not_nested(self): env, _ = prepare_jinja2_env(debug=True) - with self.assertRaisesMessage(ReportExportTemplateError, "Jinja tag prefixed with 'li' was not a descendant of a li tag"): - rich_text_template(env, "
foo 1 bar 2 baz 3 {%li for i in thelist %}
") + with self.assertRaisesMessage( + ReportExportTemplateError, + "Jinja tag prefixed with 'li' was not a descendant of a li tag", + ): + rich_text_template( + env, + "- {{i}}
- {%li endfor %}
{%li for i in thelist %}
", + ) - def test_legacy_reference_and_caption_tags_accept_whitespace_after_opening_braces(self): + def test_legacy_reference_and_caption_tags_accept_whitespace_after_opening_braces( + self, + ): env, _ = prepare_jinja2_env(debug=True) template = rich_text_template( env, @@ -45,7 +530,9 @@ def test_legacy_reference_and_caption_tags_accept_whitespace_after_opening_brace '- {{i}}
- {%li endfor %}
{{ .caption Here is a Caption}}
', ) out = template.render({}) - self.assertIn('data-gw-ref="Payload Hosting and Lateral Movement With Codex"', out) + self.assertIn( + 'data-gw-ref="Payload Hosting and Lateral Movement With Codex"', out + ) self.assertIn('data-gw-caption="Here is a Caption"', out) @@ -70,11 +557,15 @@ def mime_type(cls) -> str: def extension(cls) -> str: return "txt" - lazy_template = DummyExport({}, is_raw=True).create_lazy_template("test rich text", None, {}) + lazy_template = DummyExport({}, is_raw=True).create_lazy_template( + "test rich text", None, {} + ) self.assertEqual(lazy_template.render_html(), "") - def test_report_export_handles_report_extra_field_with_spaced_legacy_reference_and_caption_tags(self): + def test_report_export_handles_report_extra_field_with_spaced_legacy_reference_and_caption_tags( + self, + ): report_extra_field = ExtraFieldModelFactory( model_internal_name=Report._meta.label, model_display_name="Reports", @@ -106,3 +597,41 @@ def test_report_export_handles_report_extra_field_with_spaced_legacy_reference_a out = ExportReportDocx(report, report_template=report.docx_template).run() self.assertGreater(len(out.getvalue()), 0) + + def test_oplog_rich_text_extra_field_is_literal_report_data(self): + oplog_extra_field = ExtraFieldModelFactory( + model_internal_name=OplogEntry._meta.label, + model_display_name="Oplog Entries", + ) + ExtraFieldSpecFactory( + internal_name="analyst_notes", + display_name="Analyst Notes", + type="rich_text", + target_model=oplog_extra_field, + ) + report = ReportFactory() + payload = ( + "{{ client.name }}
" + "{% set e=project.description_rt.template.environment %}
" + ) + OplogEntryFactory( + oplog_id__project=report.project, + oplog_id__name="Literal log", + extra_fields={"analyst_notes": payload}, + ) + + exporter = ExportReportJson(report) + context = exporter.map_rich_texts() + log = next(log for log in context["logs"] if log["name"] == "Literal log") + literal_value = log["entries"][0]["extra_fields"]["analyst_notes"] + + self.assertIsInstance(literal_value, HtmlRichText) + self.assertEqual(literal_value.__html__(), payload) + + rendered = exporter.create_lazy_template( + "test oplog reference", + "{{ logs[0].entries[0].extra_fields.analyst_notes }}", + {**context, "logs": [log]}, + ).render_html() + self.assertEqual(str(rendered), payload) + self.assertNotIn(report.project.client.name, rendered) diff --git a/ghostwriter/reporting/tests/test_views.py b/ghostwriter/reporting/tests/test_views.py index 2415c0385..b57409aee 100644 --- a/ghostwriter/reporting/tests/test_views.py +++ b/ghostwriter/reporting/tests/test_views.py @@ -1033,14 +1033,22 @@ def test_tags_are_scoped_to_findings(self): self.assertIn("visible-finding-tag", tag_names) self.assertNotIn("hidden-report-tag", tag_names) self.assertNotIn("hidden-project-tag", tag_names) - self.assertIn("visible-finding-tag", response.context["autocomplete_data"]["tags"]) - self.assertNotIn("hidden-report-tag", response.context["autocomplete_data"]["tags"]) - self.assertNotIn("hidden-project-tag", response.context["autocomplete_data"]["tags"]) + self.assertIn( + "visible-finding-tag", response.context["autocomplete_data"]["tags"] + ) + self.assertNotIn( + "hidden-report-tag", response.context["autocomplete_data"]["tags"] + ) + self.assertNotIn( + "hidden-project-tag", response.context["autocomplete_data"]["tags"] + ) def test_search_report_findings(self): response = self.client_auth.get(self.uri + "?on_reports=on") self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context["filter"].qs), len(self.accessibleReportFindings)) + self.assertEqual( + len(response.context["filter"].qs), len(self.accessibleReportFindings) + ) response = self.client_auth.get(self.uri + "?on_reports=on¬_cloned=on") self.assertEqual(response.status_code, 200) @@ -1435,8 +1443,12 @@ def test_tags_are_scoped_to_visible_reports(self): tag_names = list(response.context["tags"].values_list("name", flat=True)) self.assertIn("visible-report-tag", tag_names) self.assertNotIn("hidden-report-tag", tag_names) - self.assertIn("visible-report-tag", response.context["autocomplete_data"]["tags"]) - self.assertNotIn("hidden-report-tag", response.context["autocomplete_data"]["tags"]) + self.assertIn( + "visible-report-tag", response.context["autocomplete_data"]["tags"] + ) + self.assertNotIn( + "hidden-report-tag", response.context["autocomplete_data"]["tags"] + ) class ReportDetailViewTests(TestCase): @@ -1492,9 +1504,7 @@ def test_report_caption_configuration_is_safe_in_javascript_html(self): response = self.client_mgr.get(self.uri) self.assertEqual(response.status_code, 200) - self.assertContains( - response, r"Figure\u2028window.captionXss\u003Dtrue//" - ) + self.assertContains(response, r"Figure\u2028window.captionXss\u003Dtrue//") self.assertContains(response, r"\u0026lt\u003B/p\u0026gt\u003B") self.assertNotContains(response, "Initial foothold confirmed.", }, {"type": "paragraph", "text": "Output:"}, - {"type": "code", "text": "{% raw %}PORT 80/tcp open http{% endraw %}"}, - {"type": "paragraph", "text": "{{.ref Alpha}}"}, + {"type": "code", "text": "PORT 80/tcp open http"}, + {"type": "reference", "ref": "Alpha"}, {"type": "evidence", "evidence_id": report_evidence.id}, - {"type": "paragraph", "text": "{{.ref Bravo}}"}, + {"type": "reference", "ref": "Bravo"}, {"type": "evidence", "evidence_id": finding_evidence.id}, { "type": "narrative", @@ -1747,9 +1760,7 @@ def test_view_generates_expected_outline_lines(self): ], ) - def test_view_wraps_output_as_jinja_literal_text(self): - from ghostwriter.modules.reportwriter import prepare_jinja2_env - + def test_view_returns_output_as_unmodified_literal_block_data(self): output = "\n".join( [ "project={{ project }}", @@ -1788,14 +1799,11 @@ def test_view_wraps_output_as_jinja_literal_text(self): if block["type"] == "code" ] self.assertEqual(len(code_blocks), 1) + self.assertEqual(code_blocks[0], output) - rendered = prepare_jinja2_env(debug=False).from_string(code_blocks[0]).render( - {"project": "Rendered Project"} - ) - self.assertEqual(rendered, output) - self.assertNotIn("project=Rendered Project", rendered) - - def test_view_includes_entries_matching_configured_exact_tag_case_insensitively(self): + def test_view_includes_entries_matching_configured_exact_tag_case_insensitively( + self, + ): self.report_config.outline_tags = "Credential" self.report_config.save() @@ -2657,7 +2665,9 @@ def test_json_extra_field_modal_is_lazy_loaded(self): self.assertNotIn("nested", rendered) def test_report_detail_json_lazy_loader_has_loading_spinner(self): - response = self.client_mgr.get(reverse("reporting:report_detail", kwargs={"pk": self.report.pk})) + response = self.client_mgr.get( + reverse("reporting:report_detail", kwargs={"pk": self.report.pk}) + ) self.assertEqual(response.status_code, 200) self.assertContains(response, "fa-spinner fa-spin") self.assertContains(response, "Loading JSON content...") @@ -2720,6 +2730,75 @@ def test_richtext_preview_endpoint_requires_login_and_permissions(self): response = self.client_mgr.get(uri) self.assertEqual(response.status_code, 200) + def test_richtext_preview_renders_user_authored_jinja(self): + self.report.extra_fields["narrative"] = ( + "
{{ client.name }}
" + '{% for value in ["one", "two"] %}' + "{{ loop.index }}={{ value }} " + "{% endfor %}
" + ) + self.report.save(update_fields=["extra_fields"]) + uri = reverse( + "reporting:report_extra_field_richtext", + kwargs={ + "pk": self.report.pk, + "extra_field_name": self.extra_field.internal_name, + }, + ) + + response = self.client_mgr.get(uri) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.report.project.client.name) + self.assertContains(response, "1=one 2=two") + + def test_richtext_preview_blocks_jinja_environment_access(self): + self.report.extra_fields["narrative"] = ( + "{{ project.description_rt.template.environment.template_class(" + '"{{ 7 * 7 }}").render() }}
' + ) + self.report.save(update_fields=["extra_fields"]) + uri = reverse( + "reporting:report_extra_field_richtext", + kwargs={ + "pk": self.report.pk, + "extra_field_name": self.extra_field.internal_name, + }, + ) + + response = self.client_mgr.get(uri) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Template Error") + self.assertNotContains(response, ">49<") + + def test_richtext_preview_treats_marked_oplog_content_as_literal_data(self): + self.report.extra_fields["narrative"] = ( + '' + "" + "Logged payload: {{ client.name }}
" + "" + "{% endraw %}{#Authored template: {{ client.name }}
" + ) + self.report.save(update_fields=["extra_fields"]) + uri = reverse( + "reporting:report_extra_field_richtext", + kwargs={ + "pk": self.report.pk, + "extra_field_name": self.extra_field.internal_name, + }, + ) + + response = self.client_mgr.get(uri) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Logged payload: {{ client.name }}") + self.assertContains( + response, + f"Authored template: {self.report.project.client.name}", + ) + self.assertNotContains(response, "data-gw-jinja-literal") + def test_richtext_preview_endpoint_rejects_non_richtext_fields(self): uri = reverse( "reporting:report_extra_field_richtext", @@ -2807,7 +2886,10 @@ class ExpandEvidenceAndSanitizeTests(TestCase): """Tests for expand_evidence_and_sanitize marker expansion.""" def test_ref_marker_expanded(self): - from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize + from ghostwriter.commandcenter.templatetags.extra_fields import ( + expand_evidence_and_sanitize, + ) + html = 'See for details
' result = expand_evidence_and_sanitize(html, None) self.assertIn("Figure", result) @@ -2815,7 +2897,10 @@ def test_ref_marker_expanded(self): self.assertNotIn("data-gw-ref", result) def test_inline_caption_marker_expanded(self): - from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize + from ghostwriter.commandcenter.templatetags.extra_fields import ( + expand_evidence_and_sanitize, + ) + html = 'My Caption
' result = expand_evidence_and_sanitize(html, None) self.assertIn("Figure", result) @@ -2823,7 +2908,10 @@ def test_inline_caption_marker_expanded(self): self.assertNotIn("data-gw-caption", result) def test_block_caption_wrapped_in_p(self): - from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize + from ghostwriter.commandcenter.templatetags.extra_fields import ( + expand_evidence_and_sanitize, + ) + html = 'Caption Text' result = expand_evidence_and_sanitize(html, None) self.assertIn("", result) @@ -2831,7 +2919,10 @@ def test_block_caption_wrapped_in_p(self): self.assertIn("Figure", result) def test_image_marker_without_client_decomposed(self): - from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize + from ghostwriter.commandcenter.templatetags.extra_fields import ( + expand_evidence_and_sanitize, + ) + html = '
' result = expand_evidence_and_sanitize(html, None) self.assertNotIn("CLIENT_LOGO", result) @@ -2839,12 +2930,17 @@ def test_image_marker_without_client_decomposed(self): def test_image_marker_with_client_logo(self): from unittest.mock import MagicMock, PropertyMock, patch - from ghostwriter.commandcenter.templatetags.extra_fields import expand_evidence_and_sanitize + from ghostwriter.commandcenter.templatetags.extra_fields import ( + expand_evidence_and_sanitize, + ) + client = ClientFactory() logo_mock = MagicMock() logo_mock.__bool__ = lambda s: True logo_mock.name = "test_logo.png" - with patch.object(type(client), "logo", new_callable=PropertyMock, return_value=logo_mock): + with patch.object( + type(client), "logo", new_callable=PropertyMock, return_value=logo_mock + ): html = '' result = expand_evidence_and_sanitize(html, None, client=client) self.assertIn("{% for x in %}broken{% endfor %}"} + self.report.extra_fields = { + "test_rt": "
{% for x in %}broken{% endfor %}
" + } self.report.save(update_fields=["extra_fields"]) response = self.client_mgr.get(self.uri) self.assertEqual(response.status_code, 200) @@ -3217,7 +3321,9 @@ def test_template_error_returns_200_with_error_message(self): self.assertIn("alert-danger", content) def test_export_error_returns_generic_preview_error(self): - self.report.extra_fields = {"test_rt": "{{ 'content'|regex_search('(') }}
"} + self.report.extra_fields = { + "test_rt": "{{ 'content'|regex_search('(') }}
" + } self.report.save(update_fields=["extra_fields"]) response = self.client_mgr.get(self.uri) @@ -5584,7 +5690,6 @@ def test_tags_are_scoped_to_filtered_observation_queryset(self): class ObservationCreateViewTests(TestCase): """Collection of tests for :view:`reporting.ObservationCreate`.""" - @classmethod def setUpTestData(cls): cls.observation = ObservationFactory() diff --git a/ghostwriter/reporting/views2/report.py b/ghostwriter/reporting/views2/report.py index 31c67e9e0..951708f3b 100644 --- a/ghostwriter/reporting/views2/report.py +++ b/ghostwriter/reporting/views2/report.py @@ -5,7 +5,6 @@ import os import logging import mimetypes -import re import zipfile from socket import gaierror from asgiref.sync import async_to_sync @@ -50,7 +49,6 @@ logger = logging.getLogger(__name__) channel_layer = get_channel_layer() -JINJA_ENDRAW_RE = re.compile(r"{%[-+]?\s*endraw\s*[-+]?%}") def _outline_value(value): @@ -76,21 +74,6 @@ def _outline_command_value(command): return command_text if command_text != "N/A" else "" -def _outline_jinja_raw_text(value): - """ - Return text wrapped so later rich-text Jinja rendering treats it as literal output. - """ - text = value or "" - return ( - "{% raw %}" - + JINJA_ENDRAW_RE.sub( - lambda match: "{% endraw %}{{ " + repr(match.group(0)) + " }}{% raw %}", - text, - ) - + "{% endraw %}" - ) - - def _unavailable_template_response(request, report): messages.error( request, @@ -204,10 +187,10 @@ def generate_oplog_outline_blocks(report: Report, oplog: Oplog) -> list[dict[str if has_output: blocks.append({"type": "paragraph", "text": "Output:"}) - blocks.append({"type": "code", "text": _outline_jinja_raw_text(output)}) + blocks.append({"type": "code", "text": output}) for friendly_name, evidence_id in _report_evidence_refs_for_entry(report, entry): - blocks.append({"type": "paragraph", "text": "{{.ref " + friendly_name + "}}"}) + blocks.append({"type": "reference", "ref": friendly_name}) blocks.append({"type": "evidence", "evidence_id": evidence_id}) return blocks diff --git a/javascript/src/frontend/collab_forms/rich_text_editor/oplog_outline.tsx b/javascript/src/frontend/collab_forms/rich_text_editor/oplog_outline.tsx index 6e928f5e9..721876a2f 100644 --- a/javascript/src/frontend/collab_forms/rich_text_editor/oplog_outline.tsx +++ b/javascript/src/frontend/collab_forms/rich_text_editor/oplog_outline.tsx @@ -21,6 +21,7 @@ type OutlineBlock = | { type: "paragraph"; text: string } | { type: "html"; html: string } | { type: "code"; text: string } + | { type: "reference"; ref: string } | { type: "evidence"; evidence_id: number }; type ToastLevel = "success" | "warning" | "error" | "info"; @@ -49,10 +50,14 @@ function getOplogChoices(): OplogChoice[] { } function getGenerateUrl(): string { - return document.getElementById("report-oplog-outline-url")?.textContent ?? ""; + return ( + document.getElementById("report-oplog-outline-url")?.textContent ?? "" + ); } -function buildNarrativeContent(block: Extract) { +function buildNarrativeContent( + block: Extract +) { const content: Array<{ type: "text"; text: string; @@ -88,6 +93,20 @@ function buildNarrativeContent(block: Extract ) { + return { + type: "jinjaLiteral", + content: [content], + }; +} + +function literalHtmlBlock(html: string) { + const wrapper = document.createElement("div"); + wrapper.setAttribute("data-gw-jinja-literal", "true"); + wrapper.innerHTML = html; + return wrapper.outerHTML; +} + export default function OplogOutlineButton({ editor }: { editor: Editor }) { const [isOpen, setIsOpen] = useState(false); @@ -138,16 +157,19 @@ function OplogOutlineModal(props: { editor: Editor; onClose: () => void }) { - Appends a narrative outline based on operation log entries tagged with + Appends a narrative outline based on operation log + entries tagged with
reportorevidence- to the end of this field. Any linked evidence will be included as well. + to the end of this field. Any linked evidence will be + included as well.- This is useful for quickly assembling a report - outline based on relevant activity recorded in the log(s). - You can edit the generated outline as needed after it has been inserted. + This is useful for quickly assembling a report outline + based on relevant activity recorded in the log(s). You + can edit the generated outline as needed after it has + been inserted.
@@ -273,34 +295,60 @@ async function appendOutline( editor .chain() .focus("end") - .insertContent({ - type: "paragraph", - content: buildNarrativeContent(block), - }) + .insertContent( + literalBlock({ + type: "paragraph", + content: buildNarrativeContent(block), + }) + ) .run(); } else if (block.type === "paragraph") { editor .chain() .focus("end") - .insertContent({ - type: "paragraph", - content: block.text - ? [{ type: "text", text: block.text }] - : [], - }) + .insertContent( + literalBlock({ + type: "paragraph", + content: block.text + ? [{ type: "text", text: block.text }] + : [], + }) + ) .run(); } else if (block.type === "html") { - editor.chain().focus("end").insertContent(block.html).run(); + editor + .chain() + .focus("end") + .insertContent(literalHtmlBlock(block.html)) + .run(); } else if (block.type === "code") { editor .chain() .focus("end") - .insertContent({ - type: "codeBlock", - content: block.text - ? [{ type: "text", text: block.text }] - : [], - }) + .insertContent( + literalBlock({ + type: "codeBlock", + content: block.text + ? [{ type: "text", text: block.text }] + : [], + }) + ) + .run(); + } else if (block.type === "reference") { + editor + .chain() + .focus("end") + .insertContent( + literalBlock({ + type: "paragraph", + content: [ + { + type: "jinjaReference", + attrs: { ref: block.ref }, + }, + ], + }) + ) .run(); } else { editor diff --git a/javascript/src/tiptap_gw/index.ts b/javascript/src/tiptap_gw/index.ts index 1320c9b65..188f4fa27 100644 --- a/javascript/src/tiptap_gw/index.ts +++ b/javascript/src/tiptap_gw/index.ts @@ -28,6 +28,7 @@ import Caption from "./caption"; import Footnote from "./footnote"; import { PassiveVoiceDecoration } from "./passive_voice_decoration"; import DateTimeShortcuts from "./now_shortcut"; +import JinjaLiteral, { JinjaReference } from "./jinja_literal"; const EXTENSIONS: Extensions = [ StarterKit.configure({ @@ -78,6 +79,8 @@ const EXTENSIONS: Extensions = [ Footnote, PassiveVoiceDecoration, DateTimeShortcuts, + JinjaLiteral, + JinjaReference, ]; export default EXTENSIONS; diff --git a/javascript/src/tiptap_gw/jinja_literal.ts b/javascript/src/tiptap_gw/jinja_literal.ts new file mode 100644 index 000000000..37732f344 --- /dev/null +++ b/javascript/src/tiptap_gw/jinja_literal.ts @@ -0,0 +1,119 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +const ENCODED_REFERENCE_ATTRIBUTE = "data-gw-ref-encoded"; + +function encodeReference(ref: string): string { + return Array.from(ref) + .map((character) => character.codePointAt(0)!.toString(16)) + .join("-"); +} + +function decodeReference(encodedRef: string): string | null { + if (!/^(?:[0-9a-f]+(?:-[0-9a-f]+)*)?$/i.test(encodedRef)) { + return null; + } + try { + return encodedRef + .split("-") + .filter(Boolean) + .map((codePoint) => String.fromCodePoint(parseInt(codePoint, 16))) + .join(""); + } catch { + return null; + } +} + +function referenceFromElement(element: HTMLElement): string { + const legacyRef = element.getAttribute("data-gw-ref"); + const encodedRef = element.getAttribute(ENCODED_REFERENCE_ATTRIBUTE); + if (encodedRef !== null) { + const decodedRef = decodeReference(encodedRef); + if (decodedRef) { + return decodedRef; + } + if (legacyRef === null) { + return decodedRef ?? ""; + } + } + return legacyRef ?? ""; +} + +/** + * Marks generated report content as literal data. + * + * The report renderer removes this wrapper before compiling the surrounding + * rich text as Jinja and restores its contents after rendering. + */ +const JinjaLiteral = Node.create({ + name: "jinjaLiteral", + group: "block", + content: "block+", + defining: true, + + parseHTML() { + return [{ tag: "div[data-gw-jinja-literal]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(HTMLAttributes, { + "data-gw-jinja-literal": "true", + }), + 0, + ]; + }, +}); + +/** + * A structured cross-reference used by generated oplog outlines. + * + * The serialized attribute contains only hexadecimal code points. The visible + * legacy syntax is supplied by a node view and never becomes template source. + */ +export const JinjaReference = Node.create({ + name: "jinjaReference", + group: "inline", + inline: true, + atom: true, + + addAttributes() { + return { + ref: { + default: "", + parseHTML: referenceFromElement, + renderHTML: (attributes) => ({ + [ENCODED_REFERENCE_ATTRIBUTE]: encodeReference( + String(attributes.ref) + ), + }), + }, + }; + }, + + parseHTML() { + return [ + { tag: `span[${ENCODED_REFERENCE_ATTRIBUTE}]` }, + { tag: "span[data-gw-ref]" }, + ]; + }, + + renderText({ node }) { + return String(node.attrs.ref); + }, + + renderHTML({ HTMLAttributes }) { + return ["span", HTMLAttributes]; + }, + + addNodeView() { + return ({ node }) => { + const dom = document.createElement("span"); + dom.contentEditable = "false"; + dom.textContent = `{{.ref ${node.attrs.ref}}}`; + return { dom }; + }; + }, +}); + +export default JinjaLiteral;