diff --git a/ghostwriter/modules/custom_serializers.py b/ghostwriter/modules/custom_serializers.py index afbb405b5..370009765 100644 --- a/ghostwriter/modules/custom_serializers.py +++ b/ghostwriter/modules/custom_serializers.py @@ -27,6 +27,7 @@ Finding, Report, ReportFindingLink, + ReportObservationLink, ReportTemplate, ) from ghostwriter.rolodex.models import ( @@ -220,6 +221,16 @@ def get_severity_color_hex(self, obj): return obj.severity.color_hex +class ObservationLinkSerializer(TaggitSerializer, CustomModelSerializer): + """Serialize :model:`reporting:ObservationLinkSerializer` entries.""" + + tags = TagListSerializerField() + + class Meta: + model = ReportObservationLink + fields = "__all__" + + class ReportTemplateSerializer(CustomModelSerializer): """Serialize :model:`reporting:ReportTemplate` entries.""" @@ -238,6 +249,7 @@ class ReportSerializer(TaggitSerializer, CustomModelSerializer): total_findings = SerializerMethodField("get_total_findings") findings = FindingLinkSerializer(source="reportfindinglink_set", many=True, exclude=["id", "report"]) + observations = ObservationLinkSerializer(source="reportobservationlink_set", many=True, exclude=["id", "report"]) tags = TagListSerializerField() @@ -734,6 +746,13 @@ class ReportDataSerializer(CustomModelSerializer): "report", ], ) + observations = ObservationLinkSerializer( + source="reportobservationlink_set", + many=True, + exclude=[ + "report", + ], + ) docx_template = ReportTemplateSerializer( exclude=[ "upload_date", diff --git a/ghostwriter/modules/linting_utils.py b/ghostwriter/modules/linting_utils.py index 97dfe8edc..5c46b2191 100644 --- a/ghostwriter/modules/linting_utils.py +++ b/ghostwriter/modules/linting_utils.py @@ -354,6 +354,13 @@ "tags": ["tag1", "tag2", "tag3"], }, ], + "observations": [ + { + "id": 1, + "title": "test observation", + "description": "", + } + ], "docx_template": { "id": 1, "document": "/media/template_oxnfkmX.docx", diff --git a/ghostwriter/modules/reportwriter.py b/ghostwriter/modules/reportwriter.py index f9aaa2971..60616892a 100644 --- a/ghostwriter/modules/reportwriter.py +++ b/ghostwriter/modules/reportwriter.py @@ -1572,10 +1572,11 @@ def render_subdocument(section, finding, p_style=None): return self.sacrificial_doc return None + p_style = self.report_queryset.docx_template.p_style + # Findings for finding in context["findings"]: logger.info("Processing %s", finding["title"]) - p_style = self.report_queryset.docx_template.p_style # Create ``RichText()`` object for a colored severity category finding["severity_rt"] = RichText(finding["severity"], color=finding["severity_color"]) finding["cvss_score_rt"] = RichText(finding["cvss_score"], color=finding["severity_color"]) diff --git a/ghostwriter/reporting/forms.py b/ghostwriter/reporting/forms.py index 8c236c5f0..3c2af30dc 100644 --- a/ghostwriter/reporting/forms.py +++ b/ghostwriter/reporting/forms.py @@ -35,6 +35,7 @@ Observation, Report, ReportFindingLink, + ReportObservationLink, ReportTemplate, Severity, ) @@ -1029,3 +1030,48 @@ def __init__(self, *args, **kwargs): ), ), ) + + +class ReportObservationLinkUpdateForm(forms.ModelForm): + """ + Update an individual :model:`reporting.ReportObservationLink` associated with an + individual :model:`reporting.Report`. + """ + + class Meta: + model = ReportObservationLink + exclude = ( + "report", + "position", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields: + self.fields[field].widget.attrs["autocomplete"] = "off" + self.fields["title"].widget.attrs["placeholder"] = "Observation Title" + self.fields["description"].widget.attrs["placeholder"] = "What is this ..." + self.fields["tags"].widget.attrs["placeholder"] = "ATT&CK:T1555, privesc, ..." + + self.helper = FormHelper() + self.helper.form_show_labels = True + self.helper.form_method = "post" + self.helper.form_id = "report-observation-form" + self.helper.layout = Layout( + Row( + Column("title", css_class="form-group col-md-6 mb-0"), + Column("tags", css_class="form-group col-md-6 mb-0"), + css_class="form-row", + ), + Field("description", css_class="enable-evidence-upload"), + ButtonHolder( + Submit("submit_btn", "Submit", css_class="btn btn-primary col-md-4"), + HTML( + """ + + """ + ), + ), + ) diff --git a/ghostwriter/reporting/migrations/0044_reportobservationlink.py b/ghostwriter/reporting/migrations/0044_reportobservationlink.py new file mode 100644 index 000000000..3431e21b2 --- /dev/null +++ b/ghostwriter/reporting/migrations/0044_reportobservationlink.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.19 on 2023-10-31 17:21 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('taggit', '0005_auto_20220424_2025'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('reporting', '0043_observation'), + ] + + operations = [ + migrations.CreateModel( + name='ReportObservationLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='Enter a title for this observation that will appear in the reports', max_length=255, verbose_name='Title')), + ('position', models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Report Position')), + ('description', models.TextField(blank=True, help_text='Provide a description for this observation that introduces it', null=True, verbose_name='Description')), + ('added_as_blank', models.BooleanField(default=False, help_text='Identify an observation that was created for this report instead of copied from the library', verbose_name='Added as Blank')), + ('assigned_to', models.ForeignKey(blank=True, help_text='Assign the task of editing this observation to a specific operator - defaults to the operator that added it to the report', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('report', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='reporting.report')), + ('tags', taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), + ], + options={ + 'verbose_name': 'Report observation', + 'verbose_name_plural': 'Report observations', + 'ordering': ['report', 'position'], + }, + ), + ] diff --git a/ghostwriter/reporting/models.py b/ghostwriter/reporting/models.py index 6258fdd22..c366588d7 100644 --- a/ghostwriter/reporting/models.py +++ b/ghostwriter/reporting/models.py @@ -687,3 +687,46 @@ def __str__(self): def get_absolute_url(self): return reverse("reporting:observation_detail", args=[str(self.id)]) + + +class ReportObservationLink(models.Model): + + title = models.CharField( + "Title", + max_length=255, + help_text="Enter a title for this observation that will appear in the reports", + ) + position = models.IntegerField( + "Report Position", + default=1, + validators=[MinValueValidator(1)], + ) + description = models.TextField( + "Description", + null=True, + blank=True, + help_text="Provide a description for this observation that introduces it", + ) + added_as_blank = models.BooleanField( + "Added as Blank", + default=False, + help_text="Identify an observation that was created for this report instead of copied from the library", + ) + tags = TaggableManager(blank=True) + # Foreign Keys + report = models.ForeignKey("Report", on_delete=models.CASCADE, null=True) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Assign the task of editing this observation to a specific operator - defaults to the operator that added it to the report", + ) + + class Meta: + ordering = ["report", "position"] + verbose_name = "Report observation" + verbose_name_plural = "Report observations" + + def __str__(self): + return self.title diff --git a/ghostwriter/reporting/templates/reporting/local_observation_edit.html b/ghostwriter/reporting/templates/reporting/local_observation_edit.html new file mode 100644 index 000000000..35451af58 --- /dev/null +++ b/ghostwriter/reporting/templates/reporting/local_observation_edit.html @@ -0,0 +1,124 @@ +{% extends "base_generic.html" %} + +{% load crispy_forms_tags %} + +{% load bleach_tags %} + +{% load custom_tags %} + +{% block pagetitle %}Finding Entry{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+ +
+ +
+
+

Ghostwriter supports several template keywords you may utilize to format text and insert various pieces of + information. Begin typing @ to open the autocomplete dialog for keywords.

+ + + + + + + {% verbatim %} + + {% endverbatim %} + + + + {% verbatim %} + + {% endverbatim %} + + + + {% verbatim %} + + {% endverbatim %} + + + + {% verbatim %} + + {% endverbatim %} + + + {% verbatim %} + + + + + {% endverbatim %} +
KeywordUsage
{{.client}} + {% if reportobservationlink.report.project.client.short_name %} + This keyword will be replaced with the client's short name, " + {{ reportobservationlink.report.project.client.short_name }}." + {% else %} + No short name is set for this client, so this keyword will be replaced by the full name, " + {{ reportobservationlink.report.project.client.name }}." + {% endif %} +
{{.project_type}}This keyword will be replaced with the project type in + lowercase, {{ reportobservationlink.report.project.project_type|lower }}. +
{{.project_start}}This keyword will be replaced with the project's start date in + {% settings_value "DATE_FORMAT" %} + format: {{ reportobservationlink.report.project.start_date|date:"DATE_FORMAT" }}
{{.project_end}}This keyword will be replaced with the project's end date in + {% settings_value "DATE_FORMAT" %} + format: {{ reportobservationlink.report.project.end_date|date:"DATE_FORMAT" }}
{{.caption}}Start a line of text with this keyword to make it a caption. This is + intended to follow a code block. +
+

For additional formatting, utilize the WYSIWIG HTML formatting to apply bold, italic, code, inline code, and other text styles.

+

These styles will carry over to the Word and PowerPoint reports. See the documentaiton for more + details.

+
+
+
+
+ + +

Use this form to edit "{{ reportobservationlink.title }}" just for this report.

+

When the finding is complete and ready for review, make sure you check the box down below.

+ + + {% if form.errors %} + + {% endif %} + + + {% crispy form form.helper %} +{% endblock %} + +{% block morescripts %} + {% comment %} Include the reusable delete confirmation modal and related scripts {% endcomment %} + {% include "confirm_delete_modal.html" %} +{% endblock %} diff --git a/ghostwriter/reporting/templates/reporting/observation_list.html b/ghostwriter/reporting/templates/reporting/observation_list.html index 31f12a389..39227d965 100644 --- a/ghostwriter/reporting/templates/reporting/observation_list.html +++ b/ghostwriter/reporting/templates/reporting/observation_list.html @@ -27,12 +27,38 @@ Title + + + + + + {% for observation in filter.qs %} {{observation.title}} + + + + {% endfor %} @@ -40,3 +66,69 @@ {% endif %} {% endblock %} + + +{% block morescripts %} + {% comment %} jQuery Tablesorter Script {% endcomment %} + +{% endblock %} diff --git a/ghostwriter/reporting/templates/reporting/report_detail.html b/ghostwriter/reporting/templates/reporting/report_detail.html index 0e31ba02d..c67e9c64b 100644 --- a/ghostwriter/reporting/templates/reporting/report_detail.html +++ b/ghostwriter/reporting/templates/reporting/report_detail.html @@ -318,6 +318,90 @@

Current Findings

No findings have been added to this report yet.

{% endif %} + +
+

Current Observation

+
+ +

Attach observations by searching and then clicking the button next to your desired + observation.

+ + +
+ +
+ +

Add a Blank + Observation

+ + {% if report.reportobservationlink_set.all %} + + + + + + + + + + {% for observation in report.reportobservationlink_set.all %} + + + + + + {% endfor %} + +
ObservationOptions
+ {{ observation.title }} + + + +
+ {% else %} +

No observations have been added to this report yet.

+ {% endif %} +
+ {% comment %} Generate Report Sections {% endcomment %}

Generate Reports


diff --git a/ghostwriter/reporting/urls.py b/ghostwriter/reporting/urls.py index 39a191168..86c62bfbd 100644 --- a/ghostwriter/reporting/urls.py +++ b/ghostwriter/reporting/urls.py @@ -70,6 +70,16 @@ views.ReportFindingLinkDelete.as_view(), name="ajax_delete_local_finding", ), + path( + "ajax/observation/assign/", + views.AssignObservation.as_view(), + name="ajax_assign_observation", + ), + path( + "ajax/obseravation/delete/", + views.ReportObservationLinkDelete.as_view(), + name="ajax_delete_local_observation", + ), path( "ajax/report/template/swap/", views.ReportTemplateSwap.as_view(), @@ -131,6 +141,11 @@ views.AssignBlankFinding.as_view(), name="assign_blank_finding", ), + path( + "reports/create/blank-observation/", + views.AssignBlankObservation.as_view(), + name="assign_blank_observation", + ), path( "templates/", views.ReportTemplateDetailView.as_view(), @@ -165,6 +180,11 @@ views.ReportFindingLinkUpdate.as_view(), name="local_edit", ), + path( + "reports/observations/update/", + views.ReportObservationLinkUpdate.as_view(), + name="local_observation_edit", + ), path( "reports/evidence/upload/", views.EvidenceCreate.as_view(), diff --git a/ghostwriter/reporting/views.py b/ghostwriter/reporting/views.py index 48980a64c..5a0e88411 100644 --- a/ghostwriter/reporting/views.py +++ b/ghostwriter/reporting/views.py @@ -19,7 +19,7 @@ from django.contrib.auth.decorators import login_required from django.core.files import File from django.core.files.base import ContentFile -from django.db.models import Q +from django.db.models import Q, Max from django.http import ( FileResponse, Http404, @@ -66,6 +66,7 @@ ObservationForm, ReportFindingLinkUpdateForm, ReportForm, + ReportObservationLinkUpdateForm, ReportTemplateForm, SelectReportTemplateForm, ) @@ -79,6 +80,7 @@ Observation, Report, ReportFindingLink, + ReportObservationLink, ReportTemplate, Severity, ) @@ -842,6 +844,7 @@ def __init__(self): def get(self, *args, **kwargs): obj = self.get_object() try: + report_link = ReportFindingLink( title="Blank Template", severity=self.severity, @@ -2866,7 +2869,7 @@ def handle_no_permission(self): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx["cancel_link"] = reverse("reporting:observation_detail") + ctx["cancel_link"] = reverse("reporting:observation_detail", args=[str(self.object.id)]) return ctx def get_success_url(self): @@ -2904,3 +2907,177 @@ def get_context_data(self, **kwargs): ctx["object_to_be_deleted"] = queryset.title ctx["cancel_link"] = reverse("reporting:observations") return ctx + + +class AssignObservation(RoleBasedAccessControlMixin, SingleObjectMixin, View): + """ + Copy an individual :model:`reporting.Observation` to create a new + :model:`reporting.ReportObservationLink` connected to the user's active + :model:`reporting.Report`. + """ + + model = Observation + + def post(self, *args, **kwargs): + observation_instance = self.get_object() + observation_dict = to_dict(observation_instance, resolve_fk=True) + + # Remove the tags from the observation dict to add them later with the ``taggit`` API + del observation_dict["tags"] + del observation_dict["tagged_items"] + + # The user must have the ``active_report`` session variable + active_report = self.request.session.get("active_report", None) + if active_report: + try: + report = Report.objects.get(pk=active_report["id"]) + if not verify_access(self.request.user, report.project): + return ForbiddenJsonResponse() + except Report.DoesNotExist: + message = "Please select a report to edit before trying to assign an observation." + data = {"result": "error", "message": message} + return JsonResponse(data) + + # Clone the selected object to make a new :model:`reporting.ReportObservationLink` + position = ReportObservationLink.objects.filter(report__pk=report.id).aggregate(max=Max('position'))["max"] or 0 + 1 + report_link = ReportObservationLink( + report=report, + assigned_to=self.request.user, + position=position, + added_as_blank=False, + **observation_dict, + ) + report_link.save() + report_link.tags.add(*observation_instance.tags.all()) + + message = "{} successfully added to your active report.".format(observation_instance) + data = {"result": "success", "message": message} + logger.info( + "Copied %s %s to %s %s (%s %s) by request of %s", + observation_instance.__class__.__name__, + observation_instance.id, + report.__class__.__name__, + report.id, + report_link.__class__.__name__, + report_link.id, + self.request.user, + ) + else: + message = "Please select a report to edit before trying to assign a observation." + data = {"result": "error", "message": message} + return JsonResponse(data) + + +class AssignBlankObservation(RoleBasedAccessControlMixin, SingleObjectMixin, View): + model = Report + + def test_func(self): + return verify_access(self.request.user, self.get_object().project) + + def handle_no_permission(self): + return ForbiddenJsonResponse() + + def get(self, *args, **kwargs): + obj = self.get_object() + try: + position = ReportObservationLink.objects.filter(report__pk=obj.id).aggregate(max=Max('position'))["max"] or 0 + 1 + report_link = ReportObservationLink( + title="Blank Template", + report=obj, + position=position, + added_as_blank=True, + ) + report_link.save() + + logger.info( + "Added a blank observation to %s %s by request of %s", + obj.__class__.__name__, + obj.id, + self.request.user, + ) + + messages.success( + self.request, + "Successfully added a blank observation to the report", + extra_tags="alert-success", + ) + except Exception as exception: # pragma: no cover + template = "An exception of type {0} occurred. Arguments:\n{1!r}" + log_message = template.format(type(exception).__name__, exception.args) + logger.error(log_message) + + messages.error( + self.request, + "Encountered an error while trying to add a blank observation to your report: {}".format(exception.args), + extra_tags="alert-error", + ) + + return HttpResponseRedirect(reverse("reporting:report_detail", args=(obj.id,))) + + +class ReportObservationLinkDelete(RoleBasedAccessControlMixin, SingleObjectMixin, View): + """Delete an individual :model:`reporting.ReportObservationLink`.""" + + model = ReportObservationLink + + def test_func(self): + return verify_access(self.request.user, self.get_object().report.project) + + def handle_no_permission(self): + return ForbiddenJsonResponse() + + def post(self, *args, **kwargs): + observation = self.get_object() + observation.delete() + data = { + "result": "success", + "message": "Successfully deleted {observation} and cleaned up evidence.".format(observation=observation), + } + logger.info( + "Deleted %s %s by request of %s", + observation.__class__.__name__, + observation.id, + self.request.user, + ) + + return JsonResponse(data) + + +class ReportObservationLinkUpdate(RoleBasedAccessControlMixin, UpdateView): + """ + Update an individual instance of :model:`reporting.ReportObservationLink`. + + **Context** + + ``cancel_link`` + Link for the form's Cancel button to return to report's detail page + + **Template** + + :template:`reporting/local_observation_edit.html.html` + """ + + model = ReportObservationLink + form_class = ReportObservationLinkUpdateForm + template_name = "reporting/local_observation_edit.html" + success_url = reverse_lazy("reporting:reports") + + def test_func(self): + return verify_access(self.request.user, self.get_object().report.project) + + def handle_no_permission(self): + messages.error(self.request, "You do not have permission to access that.") + return redirect("home:dashboard") + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["cancel_link"] = reverse("reporting:report_detail", kwargs={"pk": self.object.report.pk}) + return ctx + + def get_success_url(self): + messages.success( + self.request, + "Successfully updated {}.".format(self.get_object().title), + extra_tags="alert-success", + ) + return reverse("reporting:report_detail", kwargs={"pk": self.object.report.id})