From 5a1ea8ae2b019b38057762acada22346f71128b4 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:00:45 -0700 Subject: [PATCH 01/14] Narrow broad exception handlers --- .../migrations/0026_convert_linting_status_to_json.py | 2 +- ghostwriter/reporting/tests/test_models.py | 10 +++++----- ghostwriter/rolodex/tests/test_models.py | 4 ++-- .../migrations/0037_convert_dns_record_to_json.py | 2 +- ghostwriter/shepherd/tests/test_models.py | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ghostwriter/reporting/migrations/0026_convert_linting_status_to_json.py b/ghostwriter/reporting/migrations/0026_convert_linting_status_to_json.py index c81b96806..0eea4195c 100644 --- a/ghostwriter/reporting/migrations/0026_convert_linting_status_to_json.py +++ b/ghostwriter/reporting/migrations/0026_convert_linting_status_to_json.py @@ -14,7 +14,7 @@ def convert_to_jsonfield(apps, schema_editor): try: entry.lint_result = json.loads(entry.lint_result) entry.save() - except: + except (TypeError, ValueError): entry.lint_result = { "result": "unknown", "warnings": [ diff --git a/ghostwriter/reporting/tests/test_models.py b/ghostwriter/reporting/tests/test_models.py index 05c67068c..d6076e1ef 100644 --- a/ghostwriter/reporting/tests/test_models.py +++ b/ghostwriter/reporting/tests/test_models.py @@ -74,7 +74,7 @@ def test_get_absolute_url(self): finding = FindingFactory() try: finding.get_absolute_url() - except: + except Exception: self.fail("Finding.get_absolute_url() raised an exception") @@ -270,7 +270,7 @@ def test_get_absolute_url(self): template = ReportTemplateFactory() try: template.get_absolute_url() - except: + except Exception: self.fail("ReportTemplate.get_absolute_url() raised an exception") def test_prop_filename(self): @@ -469,7 +469,7 @@ def test_get_absolute_url(self): report = ReportFactory() try: report.get_absolute_url() - except: + except Exception: self.fail("Report.get_absolute_url() raised an exception") def test_clear_incorrect_template_defaults_unchanged(self): @@ -768,7 +768,7 @@ def test_get_absolute_url(self): evidence = EvidenceFactory() try: evidence.get_absolute_url() - except: + except Exception: self.fail("Evidence.get_absolute_url() raised an exception") evidence.delete() @@ -795,7 +795,7 @@ def test_long_filename(self): self.assertRegexpMatches(evidence.filename, name + r"[_0-9a-zA-Z]*\.txt") try: evidence.get_absolute_url() - except: + except Exception: self.fail("Evidence.get_absolute_url() raised an exception") evidence.delete() diff --git a/ghostwriter/rolodex/tests/test_models.py b/ghostwriter/rolodex/tests/test_models.py index 16c9fa522..ad3241139 100644 --- a/ghostwriter/rolodex/tests/test_models.py +++ b/ghostwriter/rolodex/tests/test_models.py @@ -70,7 +70,7 @@ def test_get_absolute_url(self): client = ClientFactory(name="SpecterOps, Inc.") try: client.get_absolute_url() - except: + except Exception: self.fail("Client.get_absolute_url() raised an exception") def test_access(self): @@ -192,7 +192,7 @@ def test_get_absolute_url(self): project = ProjectFactory() try: project.get_absolute_url() - except: + except Exception: self.fail("Project.get_absolute_url() raised an exception") def test_checkout_adjustment_signal(self): diff --git a/ghostwriter/shepherd/migrations/0037_convert_dns_record_to_json.py b/ghostwriter/shepherd/migrations/0037_convert_dns_record_to_json.py index 97eca0296..542cee50e 100644 --- a/ghostwriter/shepherd/migrations/0037_convert_dns_record_to_json.py +++ b/ghostwriter/shepherd/migrations/0037_convert_dns_record_to_json.py @@ -19,7 +19,7 @@ def convert_to_jsonfield(apps, schema_editor): record = json.loads(json_acceptable_string) entry.dns = record entry.save() - except: + except (AttributeError, ValueError): entry.dns = {} entry.save() diff --git a/ghostwriter/shepherd/tests/test_models.py b/ghostwriter/shepherd/tests/test_models.py index fa634aa0e..1148d04da 100644 --- a/ghostwriter/shepherd/tests/test_models.py +++ b/ghostwriter/shepherd/tests/test_models.py @@ -187,7 +187,7 @@ def test_get_absolute_url(self): domain = DomainFactory() try: domain.get_absolute_url() - except: + except Exception: self.fail("Domain.get_absolute_url() raised an exception") def test_method_get_domain_age(self): @@ -292,7 +292,7 @@ def test_get_absolute_url(self): checkout = HistoryFactory() try: checkout.get_absolute_url() - except: + except Exception: self.fail("History.get_absolute_url() raised an exception") def test_method_will_be_released(self): @@ -454,7 +454,7 @@ def test_get_absolute_url(self): server = StaticServerFactory() try: server.get_absolute_url() - except: + except Exception: self.fail("StaticServer.get_absolute_url() raised an exception") @@ -538,7 +538,7 @@ def test_get_absolute_url(self): checkout = ServerHistoryFactory() try: checkout.get_absolute_url() - except: + except Exception: self.fail("ServerHistory.get_absolute_url() raised an exception") def test_property_ip_address(self): From d02c8bfbc8b3d64c5de98803cb245671d4f7bb2b Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:04:02 -0700 Subject: [PATCH 02/14] Log ignored recovery exceptions --- ghostwriter/home/templatetags/custom_tags.py | 4 +++- ghostwriter/modules/custom_serializers.py | 4 ++-- ghostwriter/modules/reportwriter/richtext/docx.py | 8 ++++---- ghostwriter/modules/reportwriter/richtext/ooxml.py | 2 +- ghostwriter/reporting/views2/report.py | 2 +- ghostwriter/reporting/views2/report_finding_link.py | 2 +- ghostwriter/reporting/views2/report_observation_link.py | 2 +- ghostwriter/rolodex/views.py | 2 +- ghostwriter/shepherd/tasks.py | 2 +- 9 files changed, 15 insertions(+), 13 deletions(-) diff --git a/ghostwriter/home/templatetags/custom_tags.py b/ghostwriter/home/templatetags/custom_tags.py index d87aa7e44..f1c592bbc 100644 --- a/ghostwriter/home/templatetags/custom_tags.py +++ b/ghostwriter/home/templatetags/custom_tags.py @@ -1,6 +1,7 @@ """This contains the custom template tags used by the Home application.""" # Standard Libraries +import logging from datetime import datetime, timedelta # Django Imports @@ -22,6 +23,7 @@ from ghostwriter.rolodex.models import ProjectAssignment register = template.Library() +logger = logging.getLogger(__name__) @register.filter(name="has_group") @@ -194,7 +196,7 @@ def add_days(date, days): days += 1 new_date = date_obj except ParserError: - pass + logger.debug("Unable to parse date value for business-day calculation.", exc_info=True) return new_date diff --git a/ghostwriter/modules/custom_serializers.py b/ghostwriter/modules/custom_serializers.py index b97354945..facadcd5b 100644 --- a/ghostwriter/modules/custom_serializers.py +++ b/ghostwriter/modules/custom_serializers.py @@ -100,7 +100,7 @@ def to_representation(self, instance): elif isinstance(value, str) and value.strip() in ("

", "

"): data[key] = "" except KeyError: - pass + logger.debug("Serializer field disappeared while normalizing its value.", exc_info=True) return data @@ -772,7 +772,7 @@ def get_recording_url(self, obj): from django.urls import reverse return reverse("oplog:oplog_entry_recording_download", kwargs={"pk": rec.pk}) except ObjectDoesNotExist: - pass + logger.debug("Oplog entry %s has no recording to serialize.", obj.pk, exc_info=True) return None class Meta: diff --git a/ghostwriter/modules/reportwriter/richtext/docx.py b/ghostwriter/modules/reportwriter/richtext/docx.py index b976a6ed3..a58050523 100644 --- a/ghostwriter/modules/reportwriter/richtext/docx.py +++ b/ghostwriter/modules/reportwriter/richtext/docx.py @@ -130,7 +130,7 @@ def style_run(self, run, style): try: run.style = "CodeInline" except KeyError: - pass + logger.debug("The DOCX template does not define the CodeInline style.", exc_info=True) run.font.no_proof = True if style.get("highlight"): run.font.highlight_color = WD_COLOR_INDEX.YELLOW @@ -204,7 +204,7 @@ def tag_p(self, el, *, par=None, **kwargs): try: par.style = self.p_style except KeyError: - pass + logger.debug("The DOCX template does not define the requested paragraph style.", exc_info=True) par_classes = set(el.attrs.get("class", [])) if "left" in par_classes: @@ -289,7 +289,7 @@ def tag_blockquote(self, el, par=None, **kwargs): try: par.style = "Blockquote" except KeyError: - pass + logger.debug("The DOCX template does not define the Blockquote style.", exc_info=True) self.process_children(el.children, par=par, **kwargs) def tag_div(self, el, **kwargs): @@ -852,6 +852,6 @@ def create(self, doc): # python-docx's deprecated style_id lookup path. par.style = "List Paragraph" except KeyError: - pass + logger.debug("The DOCX template does not define a list paragraph style.", exc_info=True) par._p.get_or_add_pPr().get_or_add_numPr().get_or_add_numId().val = numbering_id par._p.get_or_add_pPr().get_or_add_numPr().get_or_add_ilvl().val = level diff --git a/ghostwriter/modules/reportwriter/richtext/ooxml.py b/ghostwriter/modules/reportwriter/richtext/ooxml.py index 0f35db96c..64ad333a2 100644 --- a/ghostwriter/modules/reportwriter/richtext/ooxml.py +++ b/ghostwriter/modules/reportwriter/richtext/ooxml.py @@ -163,7 +163,7 @@ def style_run(self, run, style): try: run.font.size = int(style["font_size"]) except ValueError: - pass + logger.debug("Unable to apply rich-text font size.", exc_info=True) tag_code = set_style_method("code", "inline_code") tag_b = set_style_method("b", "bold") diff --git a/ghostwriter/reporting/views2/report.py b/ghostwriter/reporting/views2/report.py index df7fd24f5..31c67e9e0 100644 --- a/ghostwriter/reporting/views2/report.py +++ b/ghostwriter/reporting/views2/report.py @@ -1111,7 +1111,7 @@ def get(self, *args, **kwargs): ) except gaierror: # WebSocket are unavailable (unit testing) - pass + logger.debug("Unable to send report status update over WebSocket.", exc_info=True) return response diff --git a/ghostwriter/reporting/views2/report_finding_link.py b/ghostwriter/reporting/views2/report_finding_link.py index ec5f21141..4d11e2752 100644 --- a/ghostwriter/reporting/views2/report_finding_link.py +++ b/ghostwriter/reporting/views2/report_finding_link.py @@ -545,7 +545,7 @@ def form_valid(self, form: AssignReportFindingForm): ) except gaierror: # WebSocket are unavailable (unit testing) - pass + logger.debug("Unable to send finding assignment update over WebSocket.", exc_info=True) if self.object.assigned_to: messages.success(self.request, "Finding reassigned successfully.") else: diff --git a/ghostwriter/reporting/views2/report_observation_link.py b/ghostwriter/reporting/views2/report_observation_link.py index eb4296ef7..0548d96a0 100644 --- a/ghostwriter/reporting/views2/report_observation_link.py +++ b/ghostwriter/reporting/views2/report_observation_link.py @@ -458,7 +458,7 @@ def form_valid(self, form: AssignReportObservationForm): }, ) except gaierror: - pass + logger.debug("Unable to send observation assignment update over WebSocket.", exc_info=True) if self.object.assigned_to: messages.success(self.request, "Observation reassigned successfully.") else: diff --git a/ghostwriter/rolodex/views.py b/ghostwriter/rolodex/views.py index b215e4522..78ab95ef1 100644 --- a/ghostwriter/rolodex/views.py +++ b/ghostwriter/rolodex/views.py @@ -286,7 +286,7 @@ def get(self, *args, **kwargs): try: type_or_template_id = int(type_or_template_id) except ValueError: - pass + logger.debug("Report type or template ID is not numeric: %s", type_or_template_id) report_config = ReportConfiguration.get_solo() diff --git a/ghostwriter/shepherd/tasks.py b/ghostwriter/shepherd/tasks.py index d16612853..ea543b035 100644 --- a/ghostwriter/shepherd/tasks.py +++ b/ghostwriter/shepherd/tasks.py @@ -1239,7 +1239,7 @@ def test_digital_ocean(user): if "message" in error_message: api_response = error_message["message"] except ValueError: - pass + logger.debug("DigitalOcean returned a non-JSON authentication error response.", exc_info=True) message = f"Digital Ocean denied access with HTTP code {active_droplets.status_code} and this message: {api_response}" except ClientError: logger.error("Digital Ocean could not validate the provided API key") From 32e2660e516472ace55e25684d4c3af70d29da06 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:07:29 -0700 Subject: [PATCH 03/14] Initialize serializer base classes explicitly --- ghostwriter/modules/custom_serializers.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ghostwriter/modules/custom_serializers.py b/ghostwriter/modules/custom_serializers.py index facadcd5b..daf1c90b7 100644 --- a/ghostwriter/modules/custom_serializers.py +++ b/ghostwriter/modules/custom_serializers.py @@ -283,6 +283,9 @@ def get_cvss_score(self, obj): class ObservationLinkSerializer(TaggitSerializer, CustomModelSerializer): """Serialize :model:`reporting:ObservationLinkSerializer` entries.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + tags = TagListSerializerField() extra_fields = ExtraFieldsSerField(Observation._meta.label) @@ -303,6 +306,9 @@ class Meta: class ReportSerializer(TaggitSerializer, CustomModelSerializer): """Serialize :model:`reporting:Report` entries.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + created_by = StringRelatedField() last_update = SerializerMethodField("get_creation") @@ -341,6 +347,9 @@ class Meta: class ClientSerializer(TaggitSerializer, CustomModelSerializer): """Serialize :model:`rolodex:Client` entries.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + short_name = SerializerMethodField("get_short_name") address = SerializerMethodField("get_address") @@ -565,6 +574,9 @@ def get_end_date(self, obj): class StaticServerSerializer(TaggitSerializer, CustomModelSerializer): """Serialize :model:`shepherd.StaticServer` entries.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + provider = serializers.CharField(source="server_provider") status = serializers.CharField(source="server_status") last_used_by = StringRelatedField() @@ -655,6 +667,9 @@ class Meta: class ProjectSerializer(TaggitSerializer, CustomModelSerializer): """Serialize :model:`rolodex:Project` entries.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + name = SerializerMethodField("get_name") type = serializers.CharField(source="project_type") start_date = SerializerMethodField("get_start_date") @@ -761,6 +776,9 @@ class Meta: class OplogEntrySerializer(TaggitSerializer, CustomModelSerializer): """Serialize :model:`oplog.OplogEntry` entries.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + tags = TagListSerializerField() extra_fields = ExtraFieldsSerField(OplogEntry._meta.label) recording_url = serializers.SerializerMethodField() @@ -783,6 +801,9 @@ class Meta: class OplogSerializer(TaggitSerializer, CustomModelSerializer): """Serialize :model:`oplog.Oplog` entries.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + entries = OplogEntrySerializer( many=True, exclude=["id", "oplog_id"], From f091bf42e16ee223edaec22ec638ddea1edd696f Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:07:58 -0700 Subject: [PATCH 04/14] Narrow cache health check exception --- ghostwriter/modules/health_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostwriter/modules/health_utils.py b/ghostwriter/modules/health_utils.py index 3f1c5af1b..45b377d89 100644 --- a/ghostwriter/modules/health_utils.py +++ b/ghostwriter/modules/health_utils.py @@ -126,7 +126,7 @@ def get_cache_status(self, *args, **kwargs) -> dict: cache = django_caches[alias] cache.set("django_status_test_cache", value) status[alias] = True - except: # pragma: no cover + except Exception: # pragma: no cover status[alias] = False return status From 94a72a13aa073a72a0fd6539b9aec1a56a9d0325 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:09:13 -0700 Subject: [PATCH 05/14] Log expected recovery paths --- ghostwriter/api/views.py | 2 +- ghostwriter/oplog/views.py | 4 ++-- ghostwriter/reporting/models.py | 2 +- ghostwriter/shepherd/tasks.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ghostwriter/api/views.py b/ghostwriter/api/views.py index f1cfb9fec..1bfc9a708 100644 --- a/ghostwriter/api/views.py +++ b/ghostwriter/api/views.py @@ -1476,7 +1476,7 @@ def post(self, request, *args, **kwargs): try: entry.recording.delete() except OplogEntryRecording.DoesNotExist: - pass + logger.debug("Oplog entry %s has no existing recording to replace.", entry.id, exc_info=True) # Extract searchable text from the cast file before saving file_bytes = form.cleaned_data["file_base64"] diff --git a/ghostwriter/oplog/views.py b/ghostwriter/oplog/views.py index 52d243be1..5b5c3c68f 100644 --- a/ghostwriter/oplog/views.py +++ b/ghostwriter/oplog/views.py @@ -797,7 +797,7 @@ def _write_file_to_zip(self, zf, field_file, arcname): zf.write(path, arcname) return True except self.path_lookup_errors: - pass + logger.debug("Unable to access attachment path; falling back to streamed ZIP output.", exc_info=True) try: field_file.open("rb") @@ -920,7 +920,7 @@ def get(self, *args, **kwargs): arcname ) except OplogEntryRecording.DoesNotExist: - pass + logger.debug("Oplog entry %s has no recording to export.", entry.id, exc_info=True) # Evidence files if "evidence" in include_set or "all" in include_set: diff --git a/ghostwriter/reporting/models.py b/ghostwriter/reporting/models.py index 310d98ae6..a26802a78 100644 --- a/ghostwriter/reporting/models.py +++ b/ghostwriter/reporting/models.py @@ -119,7 +119,7 @@ def clean(self): try: old_entry = self.__class__.objects.get(pk=self.pk) except self.__class__.DoesNotExist: - pass + logger.debug("Severity %s has no previous database entry.", self.pk, exc_info=True) # A ``pre_save`` Signal is connected to this model and runs this ``clean()`` method # whenever ``save()`` is called diff --git a/ghostwriter/shepherd/tasks.py b/ghostwriter/shepherd/tasks.py index ea543b035..155fac8a0 100644 --- a/ghostwriter/shepherd/tasks.py +++ b/ghostwriter/shepherd/tasks.py @@ -442,7 +442,7 @@ def check_domains(domain_id=None): err, ) except History.DoesNotExist: - pass + logger.debug("No checkout history exists for burned domain %s.", v["domain"], exc_info=True) # If the domain isn't marked as burned, check for any informational warnings else: if lab_results[k]["warnings"]["total"] > 0: From 7ccfb774b82c93c39db29731b233fbd768357931 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:14:57 -0700 Subject: [PATCH 06/14] Remove redundant variable assignments --- ghostwriter/modules/reportwriter/richtext/docx.py | 4 ++-- ghostwriter/reporting/tests/test_models.py | 2 +- ghostwriter/reporting/tests/test_views.py | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/ghostwriter/modules/reportwriter/richtext/docx.py b/ghostwriter/modules/reportwriter/richtext/docx.py index a58050523..4b48620de 100644 --- a/ghostwriter/modules/reportwriter/richtext/docx.py +++ b/ghostwriter/modules/reportwriter/richtext/docx.py @@ -740,9 +740,9 @@ def make_cross_ref(self, par, ref: str): r.append(fldChar) # Add runs for the figure label and number - run = par.add_run(self.global_report_config.label_figure) + par.add_run(self.global_report_config.label_figure) # This ``#`` is a placeholder Word will replace with the figure's number - run = par.add_run("#") + par.add_run("#") # Close the field character run run = par.add_run() diff --git a/ghostwriter/reporting/tests/test_models.py b/ghostwriter/reporting/tests/test_models.py index d6076e1ef..391dccc2e 100644 --- a/ghostwriter/reporting/tests/test_models.py +++ b/ghostwriter/reporting/tests/test_models.py @@ -937,7 +937,7 @@ def test_json_export_with_empty_paragraph_tags(self): ) # Also create truly empty content for comparison - truly_empty_finding = ReportFindingLinkFactory( + _ = ReportFindingLinkFactory( title="Finding with Truly Empty Fields", description="", # Truly empty impact="", # Truly empty diff --git a/ghostwriter/reporting/tests/test_views.py b/ghostwriter/reporting/tests/test_views.py index 659ca7171..a61cf7573 100644 --- a/ghostwriter/reporting/tests/test_views.py +++ b/ghostwriter/reporting/tests/test_views.py @@ -4993,8 +4993,6 @@ def test_to_datetime_with_invalid_string(self): to_datetime(test_date, "%d %b %Y") def test_business_days_datetime(self): - end_date = self.test_date + timedelta(days=13) - # Monday to Monday start_date = datetime(2025, 12, 1) end_date = datetime(2025, 12, 12) From e3db097fc8499cad23beddabe92b774283672b20 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:21:24 -0700 Subject: [PATCH 07/14] Mark intentionally unused values --- ghostwriter/modules/cloud_monitors.py | 2 -- ghostwriter/oplog/tests/test_forms.py | 2 +- ghostwriter/reporting/tests/test_models.py | 2 +- ghostwriter/reporting/tests/test_views.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/ghostwriter/modules/cloud_monitors.py b/ghostwriter/modules/cloud_monitors.py index d798a0eff..b49c93979 100644 --- a/ghostwriter/modules/cloud_monitors.py +++ b/ghostwriter/modules/cloud_monitors.py @@ -313,8 +313,6 @@ def fetch_aws_s3(aws_key, aws_secret, ignore_tags=None): """ message = "" buckets = [] - if ignore_tags is None: - ignore_tags = [] try: logger.info("Collecting bucket resources from AWS S3") # Create an S3 client diff --git a/ghostwriter/oplog/tests/test_forms.py b/ghostwriter/oplog/tests/test_forms.py index bdf6e641f..33abca97a 100644 --- a/ghostwriter/oplog/tests/test_forms.py +++ b/ghostwriter/oplog/tests/test_forms.py @@ -191,7 +191,7 @@ def test_report_auto_selected_first_when_no_active(self): self.assertEqual(form.fields["report"].initial, self.report) def test_report_auto_selected_first_when_multiple_no_active(self): - second_report = ReportFactory(project=self.project) + _ = ReportFactory(project=self.project) form = OplogEvidenceForm(project=self.project) first_in_list = form.fields["report"].queryset.first() self.assertEqual(form.fields["report"].initial, first_in_list) diff --git a/ghostwriter/reporting/tests/test_models.py b/ghostwriter/reporting/tests/test_models.py index 391dccc2e..6a5d3a1cf 100644 --- a/ghostwriter/reporting/tests/test_models.py +++ b/ghostwriter/reporting/tests/test_models.py @@ -732,7 +732,7 @@ def test_exists_in_finding_library(self): self.assertFalse(blank_finding.exists_in_finding_library) # Test a finding that's linked to a library finding - cloned_finding = FindingFactory(title="Blank Finding Not in the Library") + _ = FindingFactory(title="Blank Finding Not in the Library") self.assertTrue(blank_finding.exists_in_finding_library) diff --git a/ghostwriter/reporting/tests/test_views.py b/ghostwriter/reporting/tests/test_views.py index a61cf7573..c2ad72aa0 100644 --- a/ghostwriter/reporting/tests/test_views.py +++ b/ghostwriter/reporting/tests/test_views.py @@ -1674,7 +1674,7 @@ def test_view_generates_expected_outline_lines(self): comments="

Initial foothold confirmed.

", tags=["report"], ) - entry_two = OplogEntryFactory( + _ = OplogEntryFactory( oplog_id=self.oplog, start_date=second_start, tool="", From d8e4dae79f8458a78d29ecfdf49bd390743a1bca Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:22:29 -0700 Subject: [PATCH 08/14] Improve reporting test assertions --- ghostwriter/reporting/tests/test_models.py | 2 +- ghostwriter/reporting/tests/test_views.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/ghostwriter/reporting/tests/test_models.py b/ghostwriter/reporting/tests/test_models.py index 6a5d3a1cf..1a3a9aea5 100644 --- a/ghostwriter/reporting/tests/test_models.py +++ b/ghostwriter/reporting/tests/test_models.py @@ -428,7 +428,7 @@ def test_clean_template_signal(self): template.save() - self.assertTrue(template._current_template.path not in template.document.path) + self.assertNotIn(template._current_template.path, template.document.path) self.assertFalse(os.path.exists(template._current_template.path)) self.assertTrue(os.path.exists(template.document.path)) diff --git a/ghostwriter/reporting/tests/test_views.py b/ghostwriter/reporting/tests/test_views.py index c2ad72aa0..9e6f98fe9 100644 --- a/ghostwriter/reporting/tests/test_views.py +++ b/ghostwriter/reporting/tests/test_views.py @@ -1041,9 +1041,7 @@ def test_tags_are_scoped_to_findings(self): def test_search_report_findings(self): response = self.client_auth.get(self.uri + "?on_reports=on") self.assertEqual(response.status_code, 200) - self.assertTrue( - 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) From 6221f34c0c51f0175a95f62979634ff7b979d754 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:25:13 -0700 Subject: [PATCH 09/14] Remove obsolete DOCX test code --- ghostwriter/reporting/tests/test_rich_text_docx.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ghostwriter/reporting/tests/test_rich_text_docx.py b/ghostwriter/reporting/tests/test_rich_text_docx.py index 0a6cb6d16..c71cecaa8 100644 --- a/ghostwriter/reporting/tests/test_rich_text_docx.py +++ b/ghostwriter/reporting/tests/test_rich_text_docx.py @@ -62,10 +62,6 @@ def test_func(self): out = BytesIO() doc.part.save(out) - # Uncomment to write generates docx files for manual inspection - # with open(name + ".docx", "wb") as f: - # f.write(out.getvalue()) - with ZipFile(out) as zip: with zip.open("word/document.xml") as file: contents = file.read() From 17258a0b72c27806e1b145db657ca4c7e6438dde Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:26:08 -0700 Subject: [PATCH 10/14] Use consistent JSON exception imports --- ghostwriter/api/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ghostwriter/api/views.py b/ghostwriter/api/views.py index 1bfc9a708..ed2b9efdb 100644 --- a/ghostwriter/api/views.py +++ b/ghostwriter/api/views.py @@ -10,7 +10,6 @@ from base64 import b64encode from datetime import date, datetime from http import HTTPStatus -from json import JSONDecodeError from socket import gaierror # Django Imports @@ -425,7 +424,7 @@ def setup(self, request, *args, **kwargs): self.data = data if "input" in data: self.input = data["input"] - except JSONDecodeError: + except json.JSONDecodeError: logger.exception( "Failed to decode JSON data from supposed Hasura Action request" ) @@ -638,7 +637,7 @@ def setup(self, request, *args, **kwargs): self.event = self.data["event"] self.old_data = self.data["event"]["data"]["old"] self.new_data = self.data["event"]["data"]["new"] - except JSONDecodeError: + except json.JSONDecodeError: logger.exception( "Failed to decode JSON data from supposed Hasura Event trigger: %s", request.body, @@ -2954,7 +2953,7 @@ def _validate_passive_voice_request(request): try: data = json.loads(request.body) - except JSONDecodeError: + except json.JSONDecodeError: return None, JsonResponse( {"error": "Invalid JSON in request body"}, status=HTTPStatus.BAD_REQUEST ) From 8e08a05a6f3e9462975512f6b450aa81a3143084 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:27:15 -0700 Subject: [PATCH 11/14] Mark severity default helper static --- ghostwriter/reporting/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ghostwriter/reporting/models.py b/ghostwriter/reporting/models.py index a26802a78..435c35b69 100644 --- a/ghostwriter/reporting/models.py +++ b/ghostwriter/reporting/models.py @@ -63,6 +63,7 @@ class EvidenceImageAlignmentOverride(models.TextChoices): class Severity(models.Model): """Stores an individual severity rating.""" + @staticmethod def get_default_weight(): """ Return the default weight for a new :model:`reporting.Severity` instance. From c9c90f4d2d0cabc0207dd38ecdd9e8d4f4a42520 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:45:43 -0700 Subject: [PATCH 12/14] Fix severity default migration serialization --- ghostwriter/reporting/models.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ghostwriter/reporting/models.py b/ghostwriter/reporting/models.py index 435c35b69..212f6eff4 100644 --- a/ghostwriter/reporting/models.py +++ b/ghostwriter/reporting/models.py @@ -60,15 +60,16 @@ class EvidenceImageAlignmentOverride(models.TextChoices): RIGHT = EvidenceImageAlignment.RIGHT, EvidenceImageAlignment.RIGHT.label +def get_default_severity_weight(): + """Return the default weight for a new :class:`Severity` instance.""" + return Severity.objects.count() + 1 + + class Severity(models.Model): """Stores an individual severity rating.""" - @staticmethod - def get_default_weight(): - """ - Return the default weight for a new :model:`reporting.Severity` instance. - """ - return Severity.objects.count() + 1 + # Retained for the historical migration that references this path. + get_default_weight = staticmethod(get_default_severity_weight) severity = models.CharField( "Severity", @@ -78,7 +79,7 @@ def get_default_weight(): ) weight = models.IntegerField( "Severity Weight", - default=get_default_weight, + default=get_default_severity_weight, validators=[MinValueValidator(1)], help_text="Weight for sorting severity categories in reports (lower numbers are more severe)", ) From e00e21db953949cb09455225a68dd28932fe0f7b Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 14:46:59 -0700 Subject: [PATCH 13/14] Preserve DNS migration TypeError fallback --- .../shepherd/migrations/0037_convert_dns_record_to_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostwriter/shepherd/migrations/0037_convert_dns_record_to_json.py b/ghostwriter/shepherd/migrations/0037_convert_dns_record_to_json.py index 542cee50e..062674054 100644 --- a/ghostwriter/shepherd/migrations/0037_convert_dns_record_to_json.py +++ b/ghostwriter/shepherd/migrations/0037_convert_dns_record_to_json.py @@ -19,7 +19,7 @@ def convert_to_jsonfield(apps, schema_editor): record = json.loads(json_acceptable_string) entry.dns = record entry.save() - except (AttributeError, ValueError): + except (AttributeError, TypeError, ValueError): entry.dns = {} entry.save() From f0c5098ec0055c4acb2f7c380769b4a2bd102a00 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Mon, 27 Jul 2026 15:03:58 -0700 Subject: [PATCH 14/14] Make export serialization explicit --- ghostwriter/modules/reportwriter/base/base.py | 28 ++-- .../modules/reportwriter/project/base.py | 14 +- .../modules/reportwriter/report/base.py | 29 ++-- ghostwriter/reporting/tests/test_exporters.py | 146 ++++++++++++++++++ 4 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 ghostwriter/reporting/tests/test_exporters.py diff --git a/ghostwriter/modules/reportwriter/base/base.py b/ghostwriter/modules/reportwriter/base/base.py index b35442a4b..5b1d65820 100644 --- a/ghostwriter/modules/reportwriter/base/base.py +++ b/ghostwriter/modules/reportwriter/base/base.py @@ -1,7 +1,7 @@ from datetime import datetime import io -from typing import Any, Iterable +from typing import Any, Callable, Iterable import re from venv import logger @@ -22,7 +22,8 @@ class ExportBase: # Fields * `input_object`: The object passed into `__init__`, unchanged - * `data`: The object passed into `__init__` ran through `serialize_object`, usually a dict, for passing into a Jinja env + * `data`: The object passed into `__init__` run through the supplied serializer, + usually a dict, for passing into a Jinja environment * `jinja_env`: Jinja2 environment for templating """ input_object: Any @@ -34,7 +35,14 @@ class ExportBase: preview_extra_field_model_label: str | None preview_extra_field_name: str | None - def __init__(self, input_object: Any, *, is_raw=False, jinja_debug=False): + def __init__( + self, + input_object: Any, + *, + is_raw: bool = False, + jinja_debug: bool = False, + object_serializer: Callable[[Any], Any] | None = None, + ): self.evidences_by_id = {} self.extra_fields_spec_cache = {} self.preview_extra_field_model_label = None @@ -50,15 +58,11 @@ def __init__(self, input_object: Any, *, is_raw=False, jinja_debug=False): self.data = input_object else: self.input_object = input_object - self.data = self.serialize_object(input_object) - - def serialize_object(self, object: Any) -> Any: - """ - Called by __init__ to serialize the input object to a format appropriate for use in a jinja environment. - - By default does nothing and returns `object` unchanged. - """ - return object + self.data = ( + object_serializer(input_object) + if object_serializer is not None + else input_object + ) def extra_field_specs_for(self, model: Model) -> Iterable[ExtraFieldSpec]: """ diff --git a/ghostwriter/modules/reportwriter/project/base.py b/ghostwriter/modules/reportwriter/project/base.py index 895f9bcf3..649d589d9 100644 --- a/ghostwriter/modules/reportwriter/project/base.py +++ b/ghostwriter/modules/reportwriter/project/base.py @@ -13,16 +13,22 @@ from ghostwriter.shepherd.models import Domain, StaticServer +def serialize_project(project): + """Serialize a project for export without depending on exporter state.""" + return FullProjectSerializer(project).data + + class ExportProjectBase(ExportBase): """ Mixin class for exporting projects. - Provides a `serialize_object` implementation for serializing the `Project` database object, - and helper functions for creating Jinja contexts. + Configures project serialization and provides helpers for creating Jinja + contexts. """ - def serialize_object(self, object): - return FullProjectSerializer(object).data + def __init__(self, *args, **kwargs): + kwargs["object_serializer"] = serialize_project + super().__init__(*args, **kwargs) def map_rich_texts(self): base_context = copy.deepcopy(self.data) diff --git a/ghostwriter/modules/reportwriter/report/base.py b/ghostwriter/modules/reportwriter/report/base.py index df290d3f6..5339ec29d 100644 --- a/ghostwriter/modules/reportwriter/report/base.py +++ b/ghostwriter/modules/reportwriter/report/base.py @@ -1,5 +1,6 @@ from collections import ChainMap import copy +from functools import partial import html from markupsafe import Markup from docxtpl import RichText as DocxRichText @@ -17,28 +18,34 @@ from ghostwriter.shepherd.models import Domain, StaticServer +def serialize_report(report, *, include_bloodhound=True): + """Serialize a report for export without depending on exporter state.""" + excludes = ["id"] + if not include_bloodhound: + excludes.append("bloodhound") + return ReportDataSerializer( + report, + exclude=excludes, + ).data + + class ExportReportBase(ExportBase): """ Mixin class for exporting reports. - Provides a `serialize_object` implementation for serializing the `Report` database object, - and helper functions for creating Jinja contexts. + Configures report serialization and provides helpers for creating Jinja + contexts. """ include_bloodhound: bool def __init__(self, *args, include_bloodhound=True, **kwargs): self.include_bloodhound = include_bloodhound + kwargs["object_serializer"] = partial( + serialize_report, + include_bloodhound=include_bloodhound, + ) super().__init__(*args, **kwargs) - def serialize_object(self, report): - excludes = ["id"] - if not self.include_bloodhound: - excludes.append("bloodhound") - return ReportDataSerializer( - report, - exclude=excludes, - ).data - def severity_rich_text(self, text: str, severity_color: str) -> str | DocxRichText: """ Creates an exporter specific rich text object for some text related to finding severity. diff --git a/ghostwriter/reporting/tests/test_exporters.py b/ghostwriter/reporting/tests/test_exporters.py new file mode 100644 index 000000000..8f6ebc784 --- /dev/null +++ b/ghostwriter/reporting/tests/test_exporters.py @@ -0,0 +1,146 @@ +"""Regression tests for report and project exporter initialization.""" + +# Standard Libraries +import json +from unittest.mock import Mock + +# Django Imports +from django.test import SimpleTestCase, TestCase + +# Ghostwriter Libraries +from ghostwriter.factories import ReportFactory +from ghostwriter.modules.reportwriter.base.base import ExportBase +from ghostwriter.modules.reportwriter.project.docx import ExportProjectDocx +from ghostwriter.modules.reportwriter.project.json import ExportProjectJson +from ghostwriter.modules.reportwriter.project.pptx import ExportProjectPptx +from ghostwriter.modules.reportwriter.report.docx import ExportReportDocx +from ghostwriter.modules.reportwriter.report.json import ExportReportJson +from ghostwriter.modules.reportwriter.report.pptx import ExportReportPptx +from ghostwriter.modules.reportwriter.report.xlsx import ExportReportXlsx + + +class ExportBaseInitializationTests(SimpleTestCase): + """Verify the base exporter never dispatches into subclass code during initialization.""" + + def test_uses_explicit_object_serializer(self): + class FalseySerializer: + def __init__(self): + self.mock = Mock(return_value={"serialized": True}) + + def __bool__(self): + return False + + def __call__(self, input_object): + return self.mock(input_object) + + input_object = object() + serializer = FalseySerializer() + + exporter = ExportBase(input_object, object_serializer=serializer) + + serializer.mock.assert_called_once_with(input_object) + self.assertIs(exporter.input_object, input_object) + self.assertEqual(exporter.data, {"serialized": True}) + + def test_raw_data_bypasses_object_serializer(self): + raw_data = {"raw": True} + serializer = Mock(side_effect=AssertionError("raw data must not be serialized")) + + exporter = ExportBase(raw_data, is_raw=True, object_serializer=serializer) + + serializer.assert_not_called() + self.assertIsNone(exporter.input_object) + self.assertIs(exporter.data, raw_data) + + def test_does_not_call_subclass_serialization_method(self): + class ExportWithOverriddenSerializer(ExportBase): + def serialize_object(self, input_object): + raise AssertionError("subclass method called during initialization") + + input_object = object() + + exporter = ExportWithOverriddenSerializer(input_object) + + self.assertIs(exporter.input_object, input_object) + self.assertIs(exporter.data, input_object) + + +class ConcreteExporterInitializationTests(TestCase): + """Verify every concrete exporter receives the correct serialized input.""" + + @classmethod + def setUpTestData(cls): + cls.report = ReportFactory() + cls.project = cls.report.project + + def test_report_exporters_initialize_with_report_data(self): + exporters = [ + ExportReportJson(self.report), + ExportReportDocx( + self.report, + report_template=self.report.docx_template, + ), + ExportReportXlsx(self.report), + ExportReportPptx( + self.report, + report_template=self.report.pptx_template, + ), + ] + + try: + for exporter in exporters: + with self.subTest(exporter=type(exporter).__name__): + self.assertIs(exporter.input_object, self.report) + self.assertEqual(exporter.data["title"], self.report.title) + self.assertEqual( + exporter.data["project"]["name"], + str(self.project), + ) + finally: + exporters[2].workbook.close() + + def test_project_exporters_initialize_with_project_data(self): + exporters = [ + ExportProjectJson(self.project), + ExportProjectDocx( + self.project, + report_template=self.report.docx_template, + ), + ExportProjectPptx( + self.project, + report_template=self.report.pptx_template, + ), + ] + + for exporter in exporters: + with self.subTest(exporter=type(exporter).__name__): + self.assertIs(exporter.input_object, self.project) + self.assertEqual( + exporter.data["project"]["name"], + str(self.project), + ) + self.assertEqual( + exporter.data["client"]["name"], + self.project.client.name, + ) + + def test_report_json_export_preserves_bloodhound_option(self): + exporter = ExportReportJson( + self.report, + include_bloodhound=False, + ) + + self.assertNotIn("bloodhound", exporter.data) + + output = exporter.run() + output.seek(0) + self.assertNotIn("bloodhound", json.load(output)) + + def test_report_and_project_raw_exports_bypass_model_serialization(self): + raw_data = {"raw": True} + + report_exporter = ExportReportJson(raw_data, is_raw=True) + project_exporter = ExportProjectJson(raw_data, is_raw=True) + + self.assertIs(report_exporter.data, raw_data) + self.assertIs(project_exporter.data, raw_data)