diff --git a/ghostwriter/api/tests/test_views.py b/ghostwriter/api/tests/test_views.py index 72a7d9802..6468f4a65 100644 --- a/ghostwriter/api/tests/test_views.py +++ b/ghostwriter/api/tests/test_views.py @@ -20,7 +20,7 @@ ClientFactory, DomainFactory, DomainStatusFactory, - EvidenceFactory, + EvidenceOnFindingFactory, FindingFactory, HistoryFactory, OplogEntryFactory, @@ -974,7 +974,7 @@ class GraphqlDeleteEvidenceActionTests(TestCase): @classmethod def setUpTestData(cls): - cls.Evidence = EvidenceFactory._meta.model + cls.Evidence = EvidenceOnFindingFactory._meta.model cls.user = UserFactory(password=PASSWORD) cls.uri = reverse("api:graphql_delete_evidence") @@ -989,8 +989,8 @@ def setUpTestData(cls): cls.finding = ReportFindingLinkFactory(report=cls.report) cls.other_finding = ReportFindingLinkFactory(report=cls.other_report) - cls.evidence = EvidenceFactory(finding=cls.finding) - cls.other_evidence = EvidenceFactory(finding=cls.other_finding) + cls.evidence = EvidenceOnFindingFactory(finding=cls.finding) + cls.other_evidence = EvidenceOnFindingFactory(finding=cls.other_finding) def setUp(self): self.client = Client() diff --git a/ghostwriter/factories.py b/ghostwriter/factories.py index 7fbdfba02..33221ec72 100644 --- a/ghostwriter/factories.py +++ b/ghostwriter/factories.py @@ -406,7 +406,7 @@ class Meta: report = factory.SubFactory(ReportFactory) -class EvidenceFactory(factory.django.DjangoModelFactory): +class BaseEvidenceFactory(factory.django.DjangoModelFactory): class Meta: model = "reporting.Evidence" @@ -414,7 +414,6 @@ class Meta: friendly_name = factory.Sequence(lambda n: "Evidence %s" % n) caption = Faker("sentence") description = Faker("sentence") - finding = factory.SubFactory(ReportFindingLinkFactory) uploaded_by = factory.SubFactory(UserFactory) class Params: @@ -432,6 +431,14 @@ def tags(self, create, extracted, **kwargs): self.tags.add(tag) +class EvidenceOnFindingFactory(BaseEvidenceFactory): + finding = factory.SubFactory(ReportFindingLinkFactory) + + +class EvidenceOnReportFactory(BaseEvidenceFactory): + report = factory.SubFactory(ReportFactory) + + class ArchiveFactory(factory.django.DjangoModelFactory): class Meta: model = "reporting.Archive" diff --git a/ghostwriter/home/management/commands/generate_test_data.py b/ghostwriter/home/management/commands/generate_test_data.py index 119e8592e..0103e9b6b 100644 --- a/ghostwriter/home/management/commands/generate_test_data.py +++ b/ghostwriter/home/management/commands/generate_test_data.py @@ -11,7 +11,7 @@ ClientFactory, DomainFactory, DomainServerConnectionFactory, - EvidenceFactory, + EvidenceOnFindingFactory, FindingFactory, HistoryFactory, OplogEntryFactory, @@ -267,7 +267,7 @@ def handle(self, *args, **kwargs): # Create fake evidence for f in report_findings: - EvidenceFactory( + EvidenceOnFindingFactory( finding=f, uploaded_by=random.choice(assignments).operator, ) diff --git a/ghostwriter/modules/custom_serializers.py b/ghostwriter/modules/custom_serializers.py index afbb405b5..1fb85992a 100644 --- a/ghostwriter/modules/custom_serializers.py +++ b/ghostwriter/modules/custom_serializers.py @@ -196,6 +196,7 @@ class FindingLinkSerializer(TaggitSerializer, CustomModelSerializer): source="evidence_set", many=True, exclude=[ + "report", "finding", "uploaded_by", ], @@ -727,6 +728,7 @@ class ReportDataSerializer(CustomModelSerializer): deconflictions = DeconflictionSerializer(source="project.deconfliction_set", many=True, exclude=["id", "project"]) whitecards = WhiteCardSerializer(source="project.whitecard_set", many=True, exclude=["id", "project"]) infrastructure = ProjectInfrastructureSerializer(source="project") + evidence = EvidenceSerializer(source="evidence_set", many=True, exclude=["id", "report", "finding"]) findings = FindingLinkSerializer( source="reportfindinglink_set", many=True, diff --git a/ghostwriter/modules/linting_utils.py b/ghostwriter/modules/linting_utils.py index 97dfe8edc..66498af94 100644 --- a/ghostwriter/modules/linting_utils.py +++ b/ghostwriter/modules/linting_utils.py @@ -461,4 +461,17 @@ "targets": 1, }, "tools": ["beacon", "covenant", "mythic", "poseidon"], + "evidence": [ + { + "id": 1, + "file_path": "evidence/2/ghost.png", + "url": "/media/evidence/2/ghost.png", + "document": "/media/evidence/2/ghost.png", + "friendly_name": "Ghostwriter", + "upload_date": "2021-03-22", + "caption": "Brief Caption for This Evidence", + "description": "", + "tags": ["tag1", "tag2", "tag3"], + } + ], } diff --git a/ghostwriter/reporting/forms.py b/ghostwriter/reporting/forms.py index e02c4ca23..2750a4a85 100644 --- a/ghostwriter/reporting/forms.py +++ b/ghostwriter/reporting/forms.py @@ -351,7 +351,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) evidence_upload_url = reverse( "reporting:upload_evidence_modal", - kwargs={"pk": self.instance.id, "modal": "modal"}, + kwargs={"parent_type": "finding", "pk": self.instance.id, "modal": "modal"}, ) for field in self.fields: self.fields[field].widget.attrs["autocomplete"] = "off" diff --git a/ghostwriter/reporting/migrations/0043_auto_20231116_1810.py b/ghostwriter/reporting/migrations/0043_auto_20231116_1810.py new file mode 100644 index 000000000..672f8eb56 --- /dev/null +++ b/ghostwriter/reporting/migrations/0043_auto_20231116_1810.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.19 on 2023-11-16 18:10 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('reporting', '0042_auto_20230919_1809'), + ] + + operations = [ + migrations.AlterModelOptions( + name='evidence', + options={'ordering': ['finding', 'report', 'document'], 'verbose_name': 'Evidence', 'verbose_name_plural': 'Evidence'}, + ), + migrations.AddField( + model_name='evidence', + name='report', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='reporting.report'), + ), + migrations.AlterField( + model_name='evidence', + name='finding', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='reporting.reportfindinglink'), + ), + migrations.AddConstraint( + model_name='evidence', + constraint=models.CheckConstraint(check=models.Q(models.Q(('finding__isnull', True), ('report__isnull', False)), models.Q(('finding__isnull', False), ('report__isnull', True)), _connector='OR'), name='reporting_evidence_finding_or_report'), + ), + ] diff --git a/ghostwriter/reporting/models.py b/ghostwriter/reporting/models.py index bee6cecb1..c0206aff8 100644 --- a/ghostwriter/reporting/models.py +++ b/ghostwriter/reporting/models.py @@ -577,17 +577,38 @@ class Evidence(models.Model): ) tags = TaggableManager(blank=True) # Foreign Keys - finding = models.ForeignKey("ReportFindingLink", on_delete=models.CASCADE) + finding = models.ForeignKey("ReportFindingLink", on_delete=models.CASCADE, null=True, blank=True) + report = models.ForeignKey("Report", on_delete=models.CASCADE, null=True, blank=True) uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) class Meta: - ordering = ["finding", "document"] + ordering = ["finding", "report", "document"] verbose_name = "Evidence" verbose_name_plural = "Evidence" + constraints = [ + models.CheckConstraint( + name="%(app_label)s_%(class)s_finding_or_report", + check=( + models.Q(finding__isnull=True, report__isnull=False) + | models.Q(finding__isnull=False, report__isnull=True) + ) + ) + ] + def get_absolute_url(self): return reverse("reporting:evidence_detail", args=[str(self.id)]) + @property + def associated_report(self): + """ + The report associated with this evidence, either directly through `self.report` or indirectly through + `self.finding.report`. + """ + if self.finding: + return self.finding.report + return self.report + def __str__(self): return f"{self.document.name}" diff --git a/ghostwriter/reporting/signals.py b/ghostwriter/reporting/signals.py index ed6a69bc4..7d949bbcd 100644 --- a/ghostwriter/reporting/signals.py +++ b/ghostwriter/reporting/signals.py @@ -35,7 +35,7 @@ def backup_evidence_values(sender, instance, **kwargs): def evidence_update(sender, instance, **kwargs): """ On change, delete the old evidence file in the :model:`reporting.Evidence` instance when a - new file is uploaded and update teh related :model:`reporting.ReportFindingLink` instance if + new file is uploaded and update the related :model:`reporting.ReportFindingLink` instance if the `friendly_name` value changed. """ if hasattr(instance, "_current_evidence"): @@ -50,7 +50,7 @@ def evidence_update(sender, instance, **kwargs): instance._current_evidence.path, ) - if hasattr(instance, "_current_friendly_name"): + if hasattr(instance, "_current_friendly_name") and instance.finding: if instance._current_friendly_name != instance.friendly_name: ignore = [ "id", diff --git a/ghostwriter/reporting/templates/reporting/evidence_detail.html b/ghostwriter/reporting/templates/reporting/evidence_detail.html index 6d777ce25..fe91fd8fc 100644 --- a/ghostwriter/reporting/templates/reporting/evidence_detail.html +++ b/ghostwriter/reporting/templates/reporting/evidence_detail.html @@ -8,9 +8,9 @@ @@ -26,7 +26,7 @@ diff --git a/ghostwriter/reporting/templates/reporting/report_detail.html b/ghostwriter/reporting/templates/reporting/report_detail.html index 0e31ba02d..454ee51a1 100644 --- a/ghostwriter/reporting/templates/reporting/report_detail.html +++ b/ghostwriter/reporting/templates/reporting/report_detail.html @@ -243,7 +243,7 @@

Current Findings

Current Findings data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">