From cd3745af73617ec2128895c17e45e9cdd0578894 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 17:47:18 +0200 Subject: [PATCH 1/5] refactor(finding): extract Finding/Vulnerability_Id/Finding_Group/Finding_Template into dojo/finding/ Phase 1 of module reorg per AGENTS.md. Move Finding (+ custom FindingAdmin), Vulnerability_Id, Finding_Group, Finding_Template + admin registrations into dojo/finding/{models,admin}.py. Cross-module FKs use string refs; date/util field defaults imported from dojo.models to preserve migration serialization path; restore load-bearing parse_cvss_data re-export for dojo.location side-effect registration. No migration change. --- dojo/finding/__init__.py | 1 + dojo/finding/admin.py | 31 + dojo/finding/models.py | 1497 ++++++++++++++++++++++++++++++++++++++ dojo/models.py | 1485 +------------------------------------ 4 files changed, 1539 insertions(+), 1475 deletions(-) create mode 100644 dojo/finding/admin.py create mode 100644 dojo/finding/models.py diff --git a/dojo/finding/__init__.py b/dojo/finding/__init__.py index e69de29bb2d..83045d6089c 100644 --- a/dojo/finding/__init__.py +++ b/dojo/finding/__init__.py @@ -0,0 +1 @@ +import dojo.finding.admin # noqa: F401 diff --git a/dojo/finding/admin.py b/dojo/finding/admin.py new file mode 100644 index 00000000000..61d6098a002 --- /dev/null +++ b/dojo/finding/admin.py @@ -0,0 +1,31 @@ +from django.contrib import admin + +from dojo.finding.models import Finding, Finding_Group, Finding_Template, Vulnerability_Id + + +@admin.register(Finding) +class FindingAdmin(admin.ModelAdmin): + # TODO: Delete this after the move to Locations + # For efficiency with large databases, display many-to-many fields with raw + # IDs rather than multi-select + raw_id_fields = ( + "endpoints", + ) + + +@admin.register(Finding_Template) +class FindingTemplateAdmin(admin.ModelAdmin): + + """Admin support for the Finding_Template model.""" + + +@admin.register(Vulnerability_Id) +class VulnerabilityIdAdmin(admin.ModelAdmin): + + """Admin support for the Vulnerability_Id model.""" + + +@admin.register(Finding_Group) +class FindingGroupAdmin(admin.ModelAdmin): + + """Admin support for the Finding_Group model.""" diff --git a/dojo/finding/models.py b/dojo/finding/models.py new file mode 100644 index 00000000000..19772f95519 --- /dev/null +++ b/dojo/finding/models.py @@ -0,0 +1,1497 @@ +import base64 +import hashlib +import logging +import re +from contextlib import suppress +from datetime import datetime +from typing import TYPE_CHECKING + +import dateutil +from dateutil.parser import parse as datetutilsparse +from dateutil.relativedelta import relativedelta +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.html import escape +from django.utils.translation import gettext as _ +from django_extensions.db.models import TimeStampedModel +from tagulous.models import TagField +from titlecase import titlecase + +from dojo.base_models.base import BaseModel + +# get_current_date/tomorrow/copy_model_util are defined early in dojo.models, before the +# re-export that loads this module — so this resolves despite the partial circular load, and +# keeps their dojo.models.* path for Django migration serialization (used as field defaults). +from dojo.models import copy_model_util, get_current_date, tomorrow +from dojo.validators import cvss3_validator, cvss4_validator + +if TYPE_CHECKING: + from dojo.importers.location_manager import UnsavedLocation + +logger = logging.getLogger(__name__) +deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") + + +class Finding(BaseModel): + # Fields loaded when performing deduplication (used by get_finding_models_for_deduplication + # and build_candidate_scope_queryset to restrict the SELECT to only what is needed). + # Covers the union of all deduplication algorithms so that a single queryset works + # regardless of which algorithm is in use. Large text fields (description, mitigation, + # impact, references, …) are intentionally excluded. + DEDUPLICATION_FIELDS = [ + "id", + # FK required for select_related("test") — must not be deferred + "test", + # Fields written by set_duplicate + "duplicate", + "active", + "verified", + "duplicate_finding", + # Guard checks in set_duplicate + "is_mitigated", + "mitigated", + "out_of_scope", + "false_p", + # Accessed by status() (debug logging only) + "under_review", + "risk_accepted", + # Used by hash-code and legacy algorithms for endpoint/location matching + "dynamic_finding", + "static_finding", + # Algorithm-specific matching fields + "hash_code", # hash_code, uid_or_hash, legacy + "unique_id_from_tool", # unique_id, uid_or_hash + "title", # legacy + "cwe", # legacy + "file_path", # legacy + "line", # legacy + ] + + # Large text fields deferred in build_candidate_scope_queryset. These are + # never accessed during deduplication or reimport candidate matching, so + # excluding them reduces the data loaded for every candidate finding. + DEDUPLICATION_DEFERRED_FIELDS = [ + "description", + "mitigation", + "impact", + "steps_to_reproduce", + "severity_justification", + "references", + "url", + "cvssv3", + "cvssv4", + ] + + title = models.CharField(max_length=511, + verbose_name=_("Title"), + help_text=_("A short description of the flaw.")) + date = models.DateField(default=get_current_date, + verbose_name=_("Date"), + help_text=_("The date the flaw was discovered.")) + sla_start_date = models.DateField( + blank=True, + null=True, + verbose_name=_("SLA Start Date"), + help_text=_("(readonly)The date used as start date for SLA calculation. Set by expiring risk acceptances. Empty by default, causing a fallback to 'date'.")) + sla_expiration_date = models.DateField( + blank=True, + null=True, + verbose_name=_("SLA Expiration Date"), + help_text=_("(readonly)The date SLA expires for this finding. Empty by default, causing a fallback to 'date'.")) + cwe = models.IntegerField(default=0, null=True, blank=True, + verbose_name=_("CWE"), + help_text=_("The CWE number associated with this flaw.")) + cve = models.CharField(max_length=50, + null=True, + blank=False, + verbose_name=_("Vulnerability Id"), + help_text=_("An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.")) + epss_score = models.FloatField(default=None, null=True, blank=True, + verbose_name=_("EPSS Score"), + help_text=_("EPSS score for the CVE. Describes how likely it is the vulnerability will be exploited in the next 30 days."), + validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) + epss_percentile = models.FloatField(default=None, null=True, blank=True, + verbose_name=_("EPSS percentile"), + help_text=_("EPSS percentile for the CVE. Describes how many CVEs are scored at or below this one."), + validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) + known_exploited = models.BooleanField(default=False, + verbose_name=_("Known Exploited"), + help_text=_("Whether this vulnerability is known to have been exploited in the wild.")) + ransomware_used = models.BooleanField(default=False, + verbose_name=_("Used in Ransomware"), + help_text=_("Whether this vulnerability is known to have been leveraged as part of a ransomware campaign.")) + kev_date = models.DateField(null=True, blank=True, + verbose_name=_("KEV Date Added"), + help_text=_("The date the vulnerability was added to the KEV catalog."), + validators=[MaxValueValidator(tomorrow)]) + cvssv3 = models.TextField(validators=[cvss3_validator], + max_length=117, + null=True, + verbose_name=_("CVSS3 Vector"), + help_text=_("Common Vulnerability Scoring System version 3 (CVSS3) score associated with this finding.")) + cvssv3_score = models.FloatField(null=True, + blank=True, + verbose_name=_("CVSS3 Score"), + help_text=_("Numerical CVSSv3 score for the vulnerability. If the vector is given, the score is updated while saving the finding. The value must be between 0-10."), + validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]) + + cvssv4 = models.TextField(validators=[cvss4_validator], + max_length=255, + null=True, + verbose_name=_("CVSS4 vector"), + help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding.")) + cvssv4_score = models.FloatField(null=True, + blank=True, + verbose_name=_("CVSSv4 Score"), + help_text=_("Numerical CVSSv4 score for the vulnerability. If the vector is given, the score is updated while saving the finding. The value must be between 0-10."), + validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]) + + url = models.TextField(null=True, + blank=True, + editable=False, + verbose_name=_("URL"), + help_text=_("External reference that provides more information about this flaw.")) # not displayed and pretty much the same as references. To remove? + severity = models.CharField(max_length=200, + verbose_name=_("Severity"), + help_text=_("The severity level of this flaw (Critical, High, Medium, Low, Info).")) + description = models.TextField(verbose_name=_("Description"), + help_text=_("Longer more descriptive information about the flaw.")) + mitigation = models.TextField(verbose_name=_("Mitigation"), + null=True, + blank=True, + help_text=_("Text describing how to best fix the flaw.")) + fix_available = models.BooleanField(null=True, + default=None, + verbose_name=_("Fix Available"), + help_text=_("Denotes if there is a fix available for this flaw.")) + fix_version = models.CharField(null=True, + blank=True, + max_length=100, + verbose_name=_("Fix version"), + help_text=_("Version of the affected component in which the flaw is fixed.")) + impact = models.TextField(verbose_name=_("Impact"), + null=True, + blank=True, + help_text=_("Text describing the impact this flaw has on systems, products, enterprise, etc.")) + steps_to_reproduce = models.TextField(null=True, + blank=True, + verbose_name=_("Steps to Reproduce"), + help_text=_("Text describing the steps that must be followed in order to reproduce the flaw / bug.")) + severity_justification = models.TextField(null=True, + blank=True, + verbose_name=_("Severity Justification"), + help_text=_("Text describing why a certain severity was associated with this flaw.")) + # TODO: Delete this after the move to Locations + endpoints = models.ManyToManyField("dojo.Endpoint", + blank=True, + verbose_name=_("Endpoints"), + help_text=_("The hosts within the product that are susceptible to this flaw. + The status of the endpoint associated with this flaw (Vulnerable, Mitigated, ...)."), + through="dojo.Endpoint_Status") + references = models.TextField(null=True, + blank=True, + db_column="refs", + verbose_name=_("References"), + help_text=_("The external documentation available for this flaw.")) + test = models.ForeignKey("dojo.Test", + editable=False, + on_delete=models.CASCADE, + verbose_name=_("Test"), + help_text=_("The test that is associated with this flaw.")) + active = models.BooleanField(default=True, + verbose_name=_("Active"), + help_text=_("Denotes if this flaw is active or not.")) + # note that false positive findings cannot be verified + # in defectdojo verified means: "we have verified the finding and it turns out that it's not a false positive" + verified = models.BooleanField(default=False, + verbose_name=_("Verified"), + help_text=_("Denotes if this flaw has been manually verified by the tester.")) + false_p = models.BooleanField(default=False, + verbose_name=_("False Positive"), + help_text=_("Denotes if this flaw has been deemed a false positive by the tester.")) + duplicate = models.BooleanField(default=False, + verbose_name=_("Duplicate"), + help_text=_("Denotes if this flaw is a duplicate of other flaws reported.")) + duplicate_finding = models.ForeignKey("self", + editable=False, + null=True, + related_name="original_finding", + blank=True, on_delete=models.DO_NOTHING, + verbose_name=_("Duplicate Finding"), + help_text=_("Link to the original finding if this finding is a duplicate.")) + out_of_scope = models.BooleanField(default=False, + verbose_name=_("Out Of Scope"), + help_text=_("Denotes if this flaw falls outside the scope of the test and/or engagement.")) + risk_accepted = models.BooleanField(default=False, + verbose_name=_("Risk Accepted"), + help_text=_("Denotes if this finding has been marked as an accepted risk.")) + under_review = models.BooleanField(default=False, + verbose_name=_("Under Review"), + help_text=_("Denotes is this flaw is currently being reviewed.")) + + last_status_update = models.DateTimeField(editable=False, + null=True, + blank=True, + auto_now_add=True, + verbose_name=_("Last Status Update"), + help_text=_("Timestamp of latest status update (change in status related fields).")) + + review_requested_by = models.ForeignKey("dojo.Dojo_User", + null=True, + blank=True, + related_name="review_requested_by", + on_delete=models.RESTRICT, + verbose_name=_("Review Requested By"), + help_text=_("Documents who requested a review for this finding.")) + reviewers = models.ManyToManyField("dojo.Dojo_User", + blank=True, + verbose_name=_("Reviewers"), + help_text=_("Documents who reviewed the flaw.")) + + # Defect Tracking Review + under_defect_review = models.BooleanField(default=False, + verbose_name=_("Under Defect Review"), + help_text=_("Denotes if this finding is under defect review.")) + defect_review_requested_by = models.ForeignKey("dojo.Dojo_User", + null=True, + blank=True, + related_name="defect_review_requested_by", + on_delete=models.RESTRICT, + verbose_name=_("Defect Review Requested By"), + help_text=_("Documents who requested a defect review for this flaw.")) + is_mitigated = models.BooleanField(default=False, + verbose_name=_("Is Mitigated"), + help_text=_("Denotes if this flaw has been fixed.")) + thread_id = models.IntegerField(default=0, + editable=False, + verbose_name=_("Thread ID")) + mitigated = models.DateTimeField(editable=False, + null=True, + blank=True, + verbose_name=_("Mitigated"), + help_text=_("Denotes if this flaw has been fixed by storing the date it was fixed.")) + mitigated_by = models.ForeignKey("dojo.Dojo_User", + null=True, + editable=False, + related_name="mitigated_by", + on_delete=models.RESTRICT, + verbose_name=_("Mitigated By"), + help_text=_("Documents who has marked this flaw as fixed.")) + reporter = models.ForeignKey("dojo.Dojo_User", + editable=False, + default=1, + related_name="reporter", + on_delete=models.RESTRICT, + verbose_name=_("Reporter"), + help_text=_("Documents who reported the flaw.")) + notes = models.ManyToManyField("dojo.Notes", + blank=True, + editable=False, + verbose_name=_("Notes"), + help_text=_("Stores information pertinent to the flaw or the mitigation.")) + numerical_severity = models.CharField(max_length=4, + verbose_name=_("Numerical Severity"), + help_text=_("The numerical representation of the severity (S0, S1, S2, S3, S4).")) + last_reviewed = models.DateTimeField(null=True, + editable=False, + verbose_name=_("Last Reviewed"), + help_text=_("Provides the date the flaw was last 'touched' by a tester.")) + last_reviewed_by = models.ForeignKey("dojo.Dojo_User", + null=True, + editable=False, + related_name="last_reviewed_by", + on_delete=models.RESTRICT, + verbose_name=_("Last Reviewed By"), + help_text=_("Provides the person who last reviewed the flaw.")) + files = models.ManyToManyField("dojo.FileUpload", + blank=True, + editable=False, + verbose_name=_("Files"), + help_text=_("Files(s) related to the flaw.")) + param = models.TextField(null=True, + blank=True, + editable=False, + verbose_name=_("Parameter"), + help_text=_("Parameter used to trigger the issue (DAST).")) + payload = models.TextField(null=True, + blank=True, + editable=False, + verbose_name=_("Payload"), + help_text=_("Payload used to attack the service / application and trigger the bug / problem.")) + hash_code = models.CharField(null=True, + blank=True, + editable=False, + max_length=64, + verbose_name=_("Hash Code"), + help_text=_("A hash over a configurable set of fields that is used for findings deduplication.")) + line = models.IntegerField(null=True, + blank=True, + verbose_name=_("Line number"), + help_text=_("Source line number of the attack vector.")) + file_path = models.CharField(null=True, + blank=True, + max_length=4000, + verbose_name=_("File path"), + help_text=_("Identified file(s) containing the flaw.")) + component_name = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("Component name"), + help_text=_("Name of the affected component (library name, part of a system, ...).")) + component_version = models.CharField(null=True, + blank=True, + max_length=100, + verbose_name=_("Component version"), + help_text=_("Version of the affected component.")) + found_by = models.ManyToManyField("dojo.Test_Type", + editable=False, + verbose_name=_("Found by"), + help_text=_("The name of the scanner that identified the flaw.")) + static_finding = models.BooleanField(default=False, + verbose_name=_("Static finding (SAST)"), + help_text=_("Flaw has been detected from a Static Application Security Testing tool (SAST).")) + dynamic_finding = models.BooleanField(default=True, + verbose_name=_("Dynamic finding (DAST)"), + help_text=_("Flaw has been detected from a Dynamic Application Security Testing tool (DAST).")) + scanner_confidence = models.IntegerField(null=True, + blank=True, + default=None, + editable=False, + verbose_name=_("Scanner confidence"), + help_text=_("Confidence level of vulnerability which is supplied by the scanner.")) + sonarqube_issue = models.ForeignKey("dojo.Sonarqube_Issue", + null=True, + blank=True, + help_text=_("The SonarQube issue associated with this finding."), + verbose_name=_("SonarQube issue"), + on_delete=models.CASCADE) + unique_id_from_tool = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("Unique ID from tool"), + help_text=_("Vulnerability technical id from the source tool. Allows to track unique vulnerabilities over time across subsequent scans.")) + vuln_id_from_tool = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("Vulnerability ID from tool"), + help_text=_("Non-unique technical id from the source tool associated with the vulnerability type.")) + sast_source_object = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("SAST Source Object"), + help_text=_("Source object (variable, function...) of the attack vector.")) + sast_sink_object = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("SAST Sink Object"), + help_text=_("Sink object (variable, function...) of the attack vector.")) + sast_source_line = models.IntegerField(null=True, + blank=True, + verbose_name=_("SAST Source Line number"), + help_text=_("Source line number of the attack vector.")) + sast_source_file_path = models.CharField(null=True, + blank=True, + max_length=4000, + verbose_name=_("SAST Source File Path"), + help_text=_("Source file path of the attack vector.")) + nb_occurences = models.IntegerField(null=True, + blank=True, + verbose_name=_("Number of occurences"), + help_text=_("Number of occurences in the source tool when several vulnerabilites were found and aggregated by the scanner.")) + + # this is useful for vulnerabilities on dependencies : helps answer the question "Did I add this vulnerability or was it discovered recently?" + publish_date = models.DateField(null=True, + blank=True, + verbose_name=_("Publish date"), + help_text=_("Date when this vulnerability was made publicly available.")) + + # The service is used to generate the hash_code, so that it gets part of the deduplication of findings. + service = models.CharField(null=True, + blank=True, + max_length=200, + verbose_name=_("Service"), + help_text=_("A service is a self-contained piece of functionality within a Product. This is an optional field which is used in deduplication of findings when set.")) + + planned_remediation_date = models.DateField(null=True, + editable=True, + verbose_name=_("Planned Remediation Date"), + help_text=_("The date the flaw is expected to be remediated.")) + + planned_remediation_version = models.CharField(null=True, + blank=True, + max_length=99, + verbose_name=_("Planned remediation version"), + help_text=_("The target version when the vulnerability should be fixed / remediated")) + + effort_for_fixing = models.CharField(null=True, + blank=True, + max_length=99, + verbose_name=_("Effort for fixing"), + help_text=_("Effort for fixing / remediating the vulnerability (Low, Medium, High)")) + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding. Choose from the list or add new tags. Press Enter key to add.")) + inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) + + SEVERITIES = {"Info": 4, "Low": 3, "Medium": 2, + "High": 1, "Critical": 0} + + class Meta: + ordering = ("numerical_severity", "-date", "title", "epss_score", "epss_percentile") + indexes = [ + models.Index(fields=["test", "active", "verified"]), + + models.Index(fields=["test", "is_mitigated"]), + models.Index(fields=["test", "duplicate"]), + models.Index(fields=["test", "out_of_scope"]), + models.Index(fields=["test", "false_p"]), + + models.Index(fields=["test", "unique_id_from_tool", "duplicate"]), + models.Index(fields=["test", "hash_code", "duplicate"]), + + models.Index(fields=["test", "component_name"]), + + models.Index(fields=["cve"]), + models.Index(fields=["epss_score"]), + models.Index(fields=["epss_percentile"]), + models.Index(fields=["cwe"]), + models.Index(fields=["out_of_scope"]), + models.Index(fields=["false_p"]), + models.Index(fields=["verified"]), + models.Index(fields=["mitigated"]), + models.Index(fields=["active"]), + models.Index(fields=["numerical_severity"]), + models.Index(fields=["date"]), + models.Index(fields=["title"]), + models.Index(fields=["hash_code"]), + models.Index(fields=["unique_id_from_tool"]), + # models.Index(fields=['file_path']), # can't add index because the field has max length 4000. + models.Index(fields=["line"]), + models.Index(fields=["component_name"]), + models.Index(fields=["duplicate"]), + models.Index(fields=["is_mitigated"]), + models.Index(fields=["duplicate_finding", "id"]), + models.Index(fields=["known_exploited"]), + models.Index(fields=["ransomware_used"]), + models.Index(fields=["kev_date"]), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if settings.V3_FEATURE_LOCATIONS: + self.unsaved_locations: list[UnsavedLocation] = [] + else: + # TODO: Delete this after the move to Locations + self.unsaved_endpoints = [] + self.unsaved_request = None + self.unsaved_response = None + self.unsaved_tags = None + self.unsaved_files = None + self.unsaved_vulnerability_ids = None + + def __str__(self): + return self.title + + def save(self, dedupe_option=True, rules_option=True, product_grading_option=True, # noqa: FBT002 + issue_updater_option=True, push_to_jira=False, user=None, *args, **kwargs): # noqa: FBT002 - this is bit hard to fix nice have this universally fixed + logger.debug("Start saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") + from dojo.finding import helper as finding_helper # noqa: PLC0415 -- lazy import, avoids circular dependency + + is_new_finding = self.pk is None + + # if not isinstance(self.date, (datetime, date)): + # raise ValidationError(_("The 'date' field must be a valid date or datetime object.")) + + if not user: + from dojo.utils import get_current_user # noqa: PLC0415 -- lazy import, avoids circular dependency + user = get_current_user() + # Title Casing + self.title = titlecase(self.title[:511]) + # Set the date of the finding if nothing is supplied + if self.date is None: + self.date = timezone.now() + # Assign the numerical severity for correct sorting order + self.numerical_severity = Finding.get_numerical_severity(self.severity) + + # Synchronize cvssv3 score using cvssv3 vector + + if self.cvssv3: + try: + from dojo.utils import parse_cvss_data # noqa: PLC0415 -- lazy import, avoids circular dependency + cvss_data = parse_cvss_data(self.cvssv3) + if cvss_data: + self.cvssv3 = cvss_data.get("cvssv3") + self.cvssv3_score = cvss_data.get("cvssv3_score") + + except Exception as ex: + logger.warning("Can't compute cvssv3 score for finding id %i. Invalid cvssv3 vector found: '%s'. Exception: %s.", self.id, self.cvssv3, ex) + # remove invalid cvssv3 vector for new findings, or should we just throw a ValidationError? + if self.pk is None: + self.cvssv3 = None + + # behaviour for CVVS4 is slightly different. Extracting this into a method would lead to probably hard to read code + if self.cvssv4: + try: + from dojo.utils import parse_cvss_data # noqa: PLC0415 -- lazy import, avoids circular dependency + cvss_data = parse_cvss_data(self.cvssv4) + if cvss_data: + self.cvssv4 = cvss_data.get("cvssv4") + self.cvssv4_score = cvss_data.get("cvssv4_score") + + except Exception as ex: + logger.warning("Can't compute cvssv4 score for finding id %i. Invalid cvssv4 vector found: '%s'. Exception: %s.", self.id, self.cvssv4, ex) + self.cvssv4 = None + + self.set_hash_code(dedupe_option) + + if is_new_finding: + if settings.V3_FEATURE_LOCATIONS: + if (self.file_path is not None) and (len(self.unsaved_locations) == 0): + self.static_finding = True + self.dynamic_finding = False + elif (self.file_path is not None): + self.static_finding = True + # TODO: Delete this after the move to Locations + elif (self.file_path is not None) and (len(self.unsaved_endpoints) == 0): + self.static_finding = True + self.dynamic_finding = False + elif (self.file_path is not None): + self.static_finding = True + + # because we have reduced the number of (super()).save() calls, the helper is no longer called for new findings + # so we call it manually + finding_helper.update_finding_status(self, user, changed_fields={"id": (None, None)}) + + # logger.debug('setting static / dynamic in save') + # need to have an id/pk before we can access locations/endpoints + elif self.file_path is not None: + if settings.V3_FEATURE_LOCATIONS: + if not self.locations.exists(): + self.static_finding = True + self.dynamic_finding = False + else: + self.static_finding = True + # TODO: Delete this after the move to Locations + elif not self.endpoints.exists(): + self.static_finding = True + self.dynamic_finding = False + else: + self.static_finding = True + + # update the SLA expiration date last, after all other finding fields have been updated + self.set_sla_expiration_date() + + logger.debug("Saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") + # We cannot run the full_clean method here without issue, so we specify skip_validation + super().save(*args, **kwargs, skip_validation=True) + + # Only add to found_by for newly-created findings (avoid doing this on every update) + if is_new_finding: + self.found_by.add(self.test.test_type) + + # only perform post processing (in celery task) if needed. this check avoids submitting 1000s of tasks to celery that will do nothing + from dojo.models import System_Settings # noqa: PLC0415 -- lazy import, avoids circular dependency + system_settings = System_Settings.objects.get() + if dedupe_option or issue_updater_option or (product_grading_option and system_settings.enable_product_grade) or push_to_jira: + finding_helper.post_process_finding_save(self.id, dedupe_option=dedupe_option, rules_option=rules_option, product_grading_option=product_grading_option, + issue_updater_option=issue_updater_option, push_to_jira=push_to_jira, user=user, *args, **kwargs) + else: + logger.debug("no options selected that require finding post processing") + + def get_absolute_url(self): + return reverse("view_finding", args=[str(self.id)]) + + def copy(self, test=None): + copy = copy_model_util(self) + # Save the necessary ManyToMany relationships + old_notes = list(self.notes.all()) + old_files = list(self.files.all()) + old_reviewers = list(self.reviewers.all()) + old_found_by = list(self.found_by.all()) + old_tags = list(self.tags.all()) + # Wipe the IDs of the new object + if test: + copy.test = test + # Save the object before setting any ManyToMany relationships + copy.save() + # Copy the notes + for notes in old_notes: + copy.notes.add(notes.copy()) + # Copy the files + for files in old_files: + copy.files.add(files.copy()) + if settings.V3_FEATURE_LOCATIONS: + old_location_refs = self.locations.all() + for location_ref in old_location_refs: + location_ref.copy(copy) + else: + # TODO: Delete this after the move to Locations + # Copy the endpoint_status + old_status_findings = list(self.status_finding.all()) + for endpoint_status in old_status_findings: + endpoint_status.copy(finding=copy) # adding or setting is not necessary, link is created by Endpoint_Status.copy() + # Assign any reviewers + copy.reviewers.set(old_reviewers) + # Assign any found_by + copy.found_by.set(old_found_by) + # Assign any tags + copy.tags.set(old_tags) + + return copy + + def delete(self, *args, product_grading_option=True, **kwargs): + logger.debug("%d finding delete", self.id) + from dojo.finding import helper as finding_helper # noqa: PLC0415 -- lazy import, avoids circular dependency + finding_helper.finding_delete(self) + super().delete(*args, **kwargs) + if product_grading_option: + from dojo.models import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + Engagement, + Product, + Test, + ) + with suppress(Finding.DoesNotExist, Test.DoesNotExist, Engagement.DoesNotExist, Product.DoesNotExist): + # Suppressing a potential issue created from async delete removing + # related objects in a separate task + from dojo.utils import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + perform_product_grading, + ) + perform_product_grading(self.test.engagement.product) + + # only used by bulk risk acceptance api + @classmethod + def unaccepted_open_findings(cls): + from dojo.utils import get_system_setting # noqa: PLC0415 -- lazy import, avoids circular dependency + results = cls.objects.filter(active=True, duplicate=False, risk_accepted=False) + if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): + results = results.filter(verified=True) + + return results + + @property + def risk_acceptance(self): + ras = self.risk_acceptance_set.all() + if ras: + return ras[0] + + return None + + def compute_hash_code(self): + # Allow Pro to overwrite compute hash_code which gets dedupe settings from a database instead of django.settings + from dojo.utils import get_custom_method # noqa: PLC0415 -- lazy import, avoids circular dependency + if compute_hash_code_method := get_custom_method("FINDING_COMPUTE_HASH_METHOD"): + deduplicationLogger.debug("using custom FINDING_COMPUTE_HASH_METHOD method") + return compute_hash_code_method(self) + + # Check if all needed settings are defined + if not hasattr(settings, "HASHCODE_FIELDS_PER_SCANNER") or not hasattr(settings, "HASHCODE_ALLOWS_NULL_CWE") or not hasattr(settings, "HASHCODE_ALLOWED_FIELDS"): + deduplicationLogger.debug("no or incomplete configuration per hash_code found; using legacy algorithm") + return self.compute_hash_code_legacy() + + hash_code_fields = self.test.hash_code_fields + + # Check if hash_code fields are found in the settings + if not hash_code_fields: + deduplicationLogger.debug( + "No configuration for hash_code computation found; using default fields for " + ("dynamic" if self.dynamic_finding else "static") + " scanners") + return self.compute_hash_code_legacy() + + # Check if all elements of HASHCODE_FIELDS_PER_SCANNER are in HASHCODE_ALLOWED_FIELDS + if not (all(elem in settings.HASHCODE_ALLOWED_FIELDS for elem in hash_code_fields)): + deduplicationLogger.debug( + "compute_hash_code - configuration error: some elements of HASHCODE_FIELDS_PER_SCANNER are not in the allowed list HASHCODE_ALLOWED_FIELDS. " + "Using default fields") + return self.compute_hash_code_legacy() + + # Make sure that we have a cwe if we need one + if self.cwe == 0 and not self.test.hash_code_allows_null_cwe: + deduplicationLogger.debug( + "Cannot compute hash_code based on configured fields because cwe is 0 for finding of title '" + self.title + "' found in file '" + str(self.file_path) + + "'. Fallback to legacy mode for this finding.") + return self.compute_hash_code_legacy() + + deduplicationLogger.debug("computing hash_code for finding id " + str(self.id) + " based on: " + ", ".join(hash_code_fields)) + + fields_to_hash = "" + for hashcodeField in hash_code_fields: + # Note: preserve this field label ("endpoints") for settings purposes through the Locations migration + if hashcodeField == "endpoints": + # For locations/endpoints, need to compute the field + locations = self.get_locations() + fields_to_hash += locations + deduplicationLogger.debug(hashcodeField + " : " + locations) + elif hashcodeField == "vulnerability_ids": + # For vulnerability_ids, need to compute the field + my_vulnerability_ids = self.get_vulnerability_ids() + fields_to_hash += my_vulnerability_ids + deduplicationLogger.debug(hashcodeField + " : " + my_vulnerability_ids) + else: + # Generically use the finding attribute having the same name, converts to str in case it's integer + fields_to_hash += str(getattr(self, hashcodeField)) + deduplicationLogger.debug(hashcodeField + " : " + str(getattr(self, hashcodeField))) + + # Log the hash_code fields that are always included (but are not part of the hash_code_fields list as they are inserted downtstream in self.hash_fields) + hash_code_fields_always = getattr(settings, "HASH_CODE_FIELDS_ALWAYS", []) + for hashcodeField in hash_code_fields_always: + if getattr(self, hashcodeField): + deduplicationLogger.debug(hashcodeField + " : " + str(getattr(self, hashcodeField))) + + deduplicationLogger.debug("compute_hash_code - fields_to_hash = " + fields_to_hash) + return self.hash_fields(fields_to_hash) + + def compute_hash_code_legacy(self): + fields_to_hash = self.title + str(self.cwe) + str(self.line) + str(self.file_path) + self.description + deduplicationLogger.debug("compute_hash_code_legacy - fields_to_hash = " + fields_to_hash) + return self.hash_fields(fields_to_hash) + + # Get vulnerability_ids to use for hash_code computation + def get_vulnerability_ids(self): + + def _get_unsaved_vulnerability_ids(finding) -> str: + if finding.unsaved_vulnerability_ids: + deduplicationLogger.debug("get_vulnerability_ids before the finding was saved") + # convert list of unsaved vulnerability_ids to the list of their canonical representation + vulnerability_id_str_list = [str(vulnerability_id) for vulnerability_id in finding.unsaved_vulnerability_ids] + # deduplicate (usually done upon saving finding) and sort endpoints + return "".join(sorted(dict.fromkeys(vulnerability_id_str_list))) + deduplicationLogger.debug("finding has no unsaved vulnerability references") + return "" + + def _get_saved_vulnerability_ids(finding) -> str: + if finding.id is not None: + vulnerability_ids = Vulnerability_Id.objects.filter(finding=finding) + deduplicationLogger.debug("get_vulnerability_ids after the finding was saved. Vulnerability references count: " + str(vulnerability_ids.count())) + # convert list of vulnerability_ids to the list of their canonical representation + vulnerability_id_str_list = [str(vulnerability_id) for vulnerability_id in vulnerability_ids.all()] + # sort vulnerability_ids strings + return "".join(sorted(vulnerability_id_str_list)) + return "" + + return _get_saved_vulnerability_ids(self) or _get_unsaved_vulnerability_ids(self) + + # Get locations/endpoints to use for hash_code computation + def get_locations(self): + # TODO: Delete this after the move to Locations + if not settings.V3_FEATURE_LOCATIONS: + # Get endpoints to use for hash_code computation + # (This sometimes reports "None") + def _get_unsaved_endpoints(finding) -> str: + if len(finding.unsaved_endpoints) > 0: + deduplicationLogger.debug("get_endpoints before the finding was saved") + # convert list of unsaved endpoints to the list of their canonical representation + endpoint_str_list = [str(endpoint) for endpoint in finding.unsaved_endpoints] + # deduplicate (usually done upon saving finding) and sort endpoints + return "".join(dict.fromkeys(endpoint_str_list)) + # we can get here when the parser defines static_finding=True but leaves dynamic_finding defaulted + # In this case, before saving the finding, both static_finding and dynamic_finding are True + # After saving dynamic_finding may be set to False probably during the saving process (observed on Bandit scan before forcing dynamic_finding=False at parser level) + deduplicationLogger.debug("trying to get endpoints on a finding before it was saved but no endpoints found (static parser wrongly identified as dynamic?") + return "" + + def _get_saved_endpoints(finding) -> str: + if finding.id is not None: + deduplicationLogger.debug("get_endpoints: after the finding was saved. Endpoints count: " + str(finding.endpoints.count())) + # convert list of endpoints to the list of their canonical representation + endpoint_str_list = [str(endpoint) for endpoint in finding.endpoints.all()] + # sort endpoints strings + return "".join(sorted(endpoint_str_list)) + return "" + + return _get_saved_endpoints(self) or _get_unsaved_endpoints(self) + + def _get_unsaved_locations(finding) -> str: + if len(finding.unsaved_locations) > 0: + deduplicationLogger.debug("get_locations before the finding was saved") + # convert list of unsaved locations to the list of their canonical representation + from dojo.importers.location_manager import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + LocationManager, + ) + unsaved_locations = LocationManager.clean_unsaved_locations(finding.unsaved_locations) + # deduplicate (usually done upon saving finding) and sort locations + locations = sorted({location.get_location_value() for location in unsaved_locations}) + return "".join(locations) + # we can get here when the parser defines static_finding=True but leaves dynamic_finding defaulted + # In this case, before saving the finding, both static_finding and dynamic_finding are True + # After saving dynamic_finding may be set to False probably during the saving process (observed on Bandit scan before forcing dynamic_finding=False at parser level) + deduplicationLogger.debug("trying to get locations on a finding before it was saved but no locations found (static parser wrongly identified as dynamic?") + return "" + + def _get_saved_locations(finding) -> str: + if finding.id is not None: + from dojo.url.models import URL # noqa: PLC0415 -- lazy import, avoids circular dependency + url_locations = finding.locations.filter(location__location_type=URL.get_location_type()) + deduplicationLogger.debug("get_locations: after the finding was saved. Locations count: " + str(url_locations.count())) + # convert list of locations to the list of their canonical representation + locations = sorted({location_ref.location.get_location_value() for location_ref in url_locations.all()}) + # sort locations strings + return "".join(sorted(locations)) + return "" + + return _get_saved_locations(self) or _get_unsaved_locations(self) + + # Compute the hash_code from the fields to hash + def hash_fields(self, fields_to_hash): + if hasattr(settings, "HASH_CODE_FIELDS_ALWAYS"): + for field in settings.HASH_CODE_FIELDS_ALWAYS: + if getattr(self, field): + deduplicationLogger.debug("adding HASH_CODE_FIELDS_ALWAYSfield %s to hash_fields: %s", field, getattr(self, field)) + fields_to_hash += str(getattr(self, field)) + + logger.debug("fields_to_hash : %s", fields_to_hash) + logger.debug("fields_to_hash lower: %s", fields_to_hash.lower()) + return hashlib.sha256(fields_to_hash.casefold().encode("utf-8").strip()).hexdigest() + + def duplicate_finding_set(self): + if self.duplicate: + if self.duplicate_finding is not None: + return Finding.objects.get( + id=self.duplicate_finding.id).original_finding.all().order_by("title") + return [] + return self.original_finding.all().order_by("title") + + def get_scanner_confidence_text(self): + if self.scanner_confidence and isinstance(self.scanner_confidence, int): + if self.scanner_confidence <= 2: + return "Certain" + if self.scanner_confidence >= 3 and self.scanner_confidence <= 5: + return "Firm" + return "Tentative" + return "" + + @staticmethod + def get_numerical_severity(severity): + if severity == "Critical": + return "S0" + if severity == "High": + return "S1" + if severity == "Medium": + return "S2" + if severity == "Low": + return "S3" + if severity == "Info": + return "S4" + return "S5" + + @staticmethod + def get_number_severity(severity): + if severity == "Critical": + return 4 + if severity == "High": + return 3 + if severity == "Medium": + return 2 + if severity == "Low": + return 1 + if severity == "Info": + return 0 + return 5 + + @staticmethod + def get_severity(num_severity): + severities = {0: "Info", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"} + if num_severity in severities: + return severities[num_severity] + + return None + + def status(self): + status = [] + if self.under_review: + status += ["Under Review"] + if self.active: + status += ["Active"] + else: + status += ["Inactive"] + if self.verified: + status += ["Verified"] + if self.mitigated or self.is_mitigated: + status += ["Mitigated"] + if self.false_p: + status += ["False Positive"] + if self.out_of_scope: + status += ["Out Of Scope"] + if self.duplicate: + status += ["Duplicate"] + if self.risk_accepted: + status += ["Risk Accepted"] + if not len(status): + status += ["Initial"] + + return ", ".join([str(s) for s in status]) + + def _age(self, start_date): + if start_date and isinstance(start_date, str): + start_date = datetutilsparse(start_date).date() + + if isinstance(start_date, datetime): + start_date = start_date.date() + + if self.mitigated: + mitigated_date = self.mitigated + if isinstance(mitigated_date, datetime): + mitigated_date = self.mitigated.date() + diff = mitigated_date - start_date + else: + diff = get_current_date() - start_date + days = diff.days + return max(0, days) + + @property + def age(self): + return self._age(self.date) + + @property + def sla_age(self): + return self._age(self.get_sla_start_date()) + + def get_sla_start_date(self): + if self.sla_start_date: + return self.sla_start_date + return self.date + + def get_sla_configuration(self): + return self.test.engagement.product.sla_configuration + + def get_sla_period(self): + # Determine which method to use to calculate the SLA + from dojo.utils import get_custom_method # noqa: PLC0415 -- lazy import, avoids circular dependency + if method := get_custom_method("FINDING_SLA_PERIOD_METHOD"): + return method(self) + # Run the default method + sla_configuration = self.get_sla_configuration() + sla_period = getattr(sla_configuration, self.severity.lower(), None) + enforce_period = getattr(sla_configuration, str("enforce_" + self.severity.lower()), None) + return sla_period, enforce_period + + def set_sla_expiration_date(self): + # First check if SLA is enabled globally + from dojo.models import System_Settings # noqa: PLC0415 -- lazy import, avoids circular dependency + system_settings = System_Settings.objects.get() + if not system_settings.enable_finding_sla: + return + # Call the internal method to set the sla expiration date + self._set_sla_expiration_date() + + def _set_sla_expiration_date(self): + # some parsers provide date as a `str` instead of a `date` in which case we need to parse it #12299 on GitHub + sla_start_date = self.get_sla_start_date() + if sla_start_date and isinstance(sla_start_date, str): + sla_start_date = dateutil.parser.parse(sla_start_date).date() + + sla_period, enforce_period = self.get_sla_period() + if sla_period is not None and enforce_period: + self.sla_expiration_date = sla_start_date + relativedelta(days=sla_period) + else: + self.sla_expiration_date = None + + def sla_days_remaining(self): + if self.sla_expiration_date: + if self.mitigated: + mitigated_date = self.mitigated + if isinstance(mitigated_date, datetime): + mitigated_date = self.mitigated.date() + return (self.sla_expiration_date - mitigated_date).days + return (self.sla_expiration_date - get_current_date()).days + return None + + def sla_deadline(self): + return self.sla_expiration_date + + def github(self): + from dojo.github.models import GITHUB_Issue # noqa: PLC0415 -- lazy import, avoids circular dependency + try: + return self.github_issue + except GITHUB_Issue.DoesNotExist: + return None + + def has_github_issue(self): + from dojo.github.models import GITHUB_Issue # noqa: PLC0415 -- lazy import, avoids circular dependency + try: + # Attempt to access the github issue if it exists. If not, an exception will be caught + _ = self.github_issue + except GITHUB_Issue.DoesNotExist: + return False + return True + + def github_conf(self): + from dojo.github.models import GITHUB_PKey # noqa: PLC0415 -- lazy import, avoids circular dependency + try: + github_product_key = GITHUB_PKey.objects.get(product=self.test.engagement.product) + github_conf = github_product_key.conf + except: + github_conf = None + return github_conf + + # newer version that can work with prefetching + def github_conf_new(self): + try: + return self.test.engagement.product.github_pkey_set.all()[0].git_conf + except: + return None + + @property + def has_jira_issue(self): + from dojo.jira import services as jira_services # noqa: PLC0415 -- lazy import, avoids circular dependency + return jira_services.has_issue(self) + + @cached_property + def finding_group(self): + return self.finding_group_set.all().first() + # logger.debug('finding.finding_group: %s', group) + + @cached_property + def has_jira_group_issue(self): + if not self.has_finding_group: + return False + + from dojo.jira import services as jira_services # noqa: PLC0415 -- lazy import, avoids circular dependency + return jira_services.has_issue(self.finding_group) + + @property + def has_jira_configured(self): + from dojo.jira import services as jira_services # noqa: PLC0415 -- lazy import, avoids circular dependency + return jira_services.has_configured(self) + + @cached_property + def has_finding_group(self): + return self.finding_group is not None + + def save_no_options(self, *args, **kwargs): + logger.debug("save_no_options") + return self.save(dedupe_option=False, rules_option=False, product_grading_option=False, + issue_updater_option=False, push_to_jira=False, user=None, *args, **kwargs) + + # Check if a mandatory field is empty. If it's the case, fill it with "no given" + def clean(self): + no_check = ["test", "reporter"] + bigfields = ["description"] + for field_obj in self._meta.fields: + field = field_obj.name + if field not in no_check: + val = getattr(self, field) + if not val and field == "title": + setattr(self, field, "No title given") + if not val and field in bigfields: + setattr(self, field, f"No {field} given") + + def severity_display(self): + return self.severity + + def get_breadcrumbs(self): + bc = self.test.get_breadcrumbs() + bc += [{"title": str(self), + "url": reverse("view_finding", args=(self.id,))}] + return bc + + def get_valid_request_response_pairs(self): + empty_value = base64.b64encode(b"") + # Get a list of all req/resp pairs + all_req_resps = self.burprawrequestresponse_set.all() + # Filter away those that do not have any contents + return all_req_resps.exclude( + burpRequestBase64__exact=empty_value, + burpResponseBase64__exact=empty_value, + ) + + def get_report_requests(self): + # Get the list of request response pairs that are non empty + request_response_pairs = self.get_valid_request_response_pairs() + # Determine how many to return + if request_response_pairs.count() >= 3: + return request_response_pairs[0:3] + if request_response_pairs.count() > 0: + return request_response_pairs + return None + + def get_request(self): + # Get the list of request response pairs that are non empty + request_response_pairs = self.get_valid_request_response_pairs() + # Determine what to return + if request_response_pairs.count() > 0: + reqres = request_response_pairs.first() + return base64.b64decode(reqres.burpRequestBase64) + + def get_response(self): + # Get the list of request response pairs that are non empty + request_response_pairs = self.get_valid_request_response_pairs() + # Determine what to return + if request_response_pairs.count() > 0: + reqres = request_response_pairs.first() + res = base64.b64decode(reqres.burpResponseBase64) + # Removes all blank lines + return re.sub(r"\n\s*\n", "\n", res) + + def latest_note(self): + if self.notes.all(): + note = self.notes.all()[0] + return note.date.strftime("%Y-%m-%d %H:%M:%S") + ": " + note.author.get_full_name() + " : " + note.entry + + return "" + + def get_sast_source_file_path_with_link(self): + from dojo.utils import create_bleached_link # noqa: PLC0415 -- lazy import, avoids circular dependency + if self.sast_source_file_path is None: + return None + if self.test.engagement.source_code_management_uri is None: + return escape(self.sast_source_file_path) + link = self.test.engagement.source_code_management_uri + "/" + self.sast_source_file_path + if self.sast_source_line: + link = link + "#L" + str(self.sast_source_line) + return create_bleached_link(link, self.sast_source_file_path) + + def get_file_path_with_link(self): + from dojo.utils import create_bleached_link # noqa: PLC0415 -- lazy import, avoids circular dependency + if self.file_path is None: + return None + if self.test.engagement.source_code_management_uri is None: + return escape(self.file_path) + link = self.get_file_path_with_raw_link() + return create_bleached_link(link, self.file_path) + + def get_scm_type(self): + # extract scm type from product custom field 'scm-type' + + from dojo.models import DojoMeta # noqa: PLC0415 -- lazy import, avoids circular dependency + if hasattr(self.test.engagement, "product"): + dojo_meta = DojoMeta.objects.filter(product=self.test.engagement.product, name="scm-type").first() + if dojo_meta: + st = dojo_meta.value.strip() + if st: + return st.lower() + return "" + + def scm_public_prepare_base_link(self, uri): + # scm public (https://scm-domain.org) url template for browse is: + # https://scm-domain.org// + # but when you get repo url for git, its template is: + # https://scm-domain.org//.git + # so to create browser url - git url should be recomposed like below: + + parts_uri = uri.split(".git") + return parts_uri[0] + + def git_public_prepare_scm_link(self, uri, scm_type): + # if commit hash or branch/tag is set for engagement/test - + # hash or branch/tag should be appended to base browser link + intermediate_path = "/blob/" if scm_type in {"github", "gitlab"} else "/src/" + + link = self.scm_public_prepare_base_link(uri) + if self.test.commit_hash: + link += intermediate_path + self.test.commit_hash + "/" + self.file_path + elif self.test.engagement.commit_hash: + link += intermediate_path + self.test.engagement.commit_hash + "/" + self.file_path + elif self.test.branch_tag: + link += intermediate_path + self.test.branch_tag + "/" + self.file_path + elif self.test.engagement.branch_tag: + link += intermediate_path + self.test.engagement.branch_tag + "/" + self.file_path + else: + link += intermediate_path + "master/" + self.file_path + + return link + + def bitbucket_standalone_prepare_scm_base_link(self, uri): + # bitbucket onpremise/standalone url template for browse is: + # https://bb.example.com/projects//repos/ + # but when you get repo url for git, its template is: + # https://bb.example.com/scm//.git + # or for user public repo^ + # https://bb.example.com/users//repos/ + # but when you get repo url for git, its template is: + # https://bb.example.com/scm//.git (username often could be prefixed with ~) + # so to create borwser url - git url should be recomposed like below: + + parts_uri = uri.split(".git") + parts_scm = parts_uri[0].split("/scm/") + parts_project = parts_scm[1].split("/") + project = parts_project[0] + if project.startswith("~"): + return parts_scm[0] + "/users/" + parts_project[0][1:] + "/repos/" + parts_project[1] + "/browse" + return parts_scm[0] + "/projects/" + parts_project[0] + "/repos/" + parts_project[1] + "/browse" + + def bitbucket_standalone_prepare_scm_link(self, uri): + # if commit hash or branch/tag is set for engagement/test - + # hash or barnch/tag should be appended to base browser link + + link = self.bitbucket_standalone_prepare_scm_base_link(uri) + if self.test.commit_hash: + link += "/" + self.file_path + "?at=" + self.test.commit_hash + elif self.test.engagement.commit_hash: + link += "/" + self.file_path + "?at=" + self.test.engagement.commit_hash + elif self.test.branch_tag: + link += "/" + self.file_path + "?at=" + self.test.branch_tag + elif self.test.engagement.branch_tag: + link += "/" + self.file_path + "?at=" + self.test.engagement.branch_tag + else: + link += "/" + self.file_path + + return link + + def get_file_path_with_raw_link(self): + if self.file_path is None: + return None + + link = self.test.engagement.source_code_management_uri + scm_type = self.get_scm_type() + if (self.test.engagement.source_code_management_uri is not None): + if scm_type == "bitbucket-standalone": + link = self.bitbucket_standalone_prepare_scm_link(link) + elif scm_type in {"github", "gitlab", "gitea", "codeberg", "bitbucket"}: + link = self.git_public_prepare_scm_link(link, scm_type) + elif "https://github.com/" in self.test.engagement.source_code_management_uri: + link = self.git_public_prepare_scm_link(link, "github") + else: + link += "/" + self.file_path + else: + link += "/" + self.file_path + + # than - add line part to browser url + if self.line: + if scm_type in {"github", "gitlab", "gitea", "codeberg"} or "https://github.com/" in self.test.engagement.source_code_management_uri: + link = link + "#L" + str(self.line) + elif scm_type == "bitbucket-standalone": + link = link + "#" + str(self.line) + elif scm_type == "bitbucket": + link = link + "#lines-" + str(self.line) + return link + + def get_references_with_links(self): + from dojo.utils import create_bleached_link # noqa: PLC0415 -- lazy import, avoids circular dependency + if self.references is None: + return None + matches = re.findall(r"([\(|\[]?(https?):((//)|(\\\\))+([\w\d:#@%/;$~_?\+-=\\\.&](#!)?)*[\)|\]]?)", self.references) + + processed_matches = [] + for match in matches: + # Check if match isn't already a markdown link + # Only replace the same matches one time, otherwise the links will be corrupted + if not (match[0].startswith("[") or match[0].startswith("(")) and match[0] not in processed_matches: + self.references = self.references.replace(match[0], create_bleached_link(match[0], match[0]), 1) + processed_matches.append(match[0]) + + return self.references + + @cached_property + def vulnerability_ids(self): + # Get vulnerability ids from database and convert to list of strings + vulnerability_ids_model = self.vulnerability_id_set.all() + vulnerability_ids = [vulnerability_id.vulnerability_id for vulnerability_id in vulnerability_ids_model] + + # Synchronize the cve field with the unsaved_vulnerability_ids + # We do this to be as flexible as possible to handle the fields until + # the cve field is not needed anymore and can be removed. + if vulnerability_ids and self.cve: + # Make sure the first entry of the list is the value of the cve field + vulnerability_ids.insert(0, self.cve) + elif not vulnerability_ids and self.cve: + # If there is no list, make one with the value of the cve field + vulnerability_ids = [self.cve] + + # Remove duplicates + return list(dict.fromkeys(vulnerability_ids)) + + @property + def violates_sla(self): + return (self.sla_expiration_date and self.sla_expiration_date < timezone.now().date()) + + def set_hash_code(self, dedupe_option): + from dojo.utils import get_custom_method # noqa: PLC0415 -- lazy import, avoids circular dependency + if hash_method := get_custom_method("FINDING_HASH_METHOD"): + deduplicationLogger.debug("Using custom hash method") + hash_method(self, dedupe_option) + # Finding.save is called once from serializers.py with dedupe_option=False because the finding is not ready yet, for example the endpoints are not built + # It is then called a second time with dedupe_option defaulted to true; now we can compute the hash_code and run the deduplication + elif dedupe_option: + finding_id = self.id if self.id is not None else "unsaved" + if self.hash_code is not None: + deduplicationLogger.debug("Hash_code already computed for finding: %s", finding_id) + else: + self.hash_code = self.compute_hash_code() + deduplicationLogger.debug("Hash_code computed for finding: %s: %s", finding_id, self.hash_code) + + +class Vulnerability_Id(models.Model): + finding = models.ForeignKey("dojo.Finding", editable=False, on_delete=models.CASCADE) + vulnerability_id = models.TextField(max_length=50, blank=False, null=False) + + def __str__(self): + return self.vulnerability_id + + def get_absolute_url(self): + return reverse("view_finding", args=[str(self.finding.id)]) + + +class Finding_Group(TimeStampedModel): + + GROUP_BY_OPTIONS = [("component_name", "Component Name"), + ("component_name+component_version", "Component Name + Version"), + ("file_path", "File path"), + ("finding_title", "Finding Title"), + ("vuln_id_from_tool", "Vulnerability ID from Tool")] + + name = models.CharField(max_length=255, blank=False, null=False) + test = models.ForeignKey("dojo.Test", on_delete=models.CASCADE) + findings = models.ManyToManyField("dojo.Finding") + creator = models.ForeignKey("dojo.Dojo_User", on_delete=models.RESTRICT) + + def __str__(self): + return self.name + + @property + def has_jira_issue(self): + from dojo.jira import services as jira_services # noqa: PLC0415 -- lazy import, avoids circular dependency + return jira_services.has_issue(self) + + @cached_property + def severity(self): + if not self.findings.all(): + return None + max_number_severity = max(Finding.get_number_severity(find.severity) for find in self.findings.all()) + return Finding.get_severity(max_number_severity) + + @cached_property + def components(self): + components: dict[str, set[str | None]] = {} + for finding in self.findings.all(): + if finding.component_name is not None: + components.setdefault(finding.component_name, set()).add(finding.component_version) + return "; ".join(f"""{name}: {", ".join(map(str, versions))}""" for name, versions in components.items()) + + @property + def age(self): + if not self.findings.all(): + return None + + return max(find.age for find in self.findings.all()) + + @cached_property + def sla_days_remaining_internal(self): + if not self.findings.all(): + return None + + return min([find.sla_days_remaining() for find in self.findings.all() if find.sla_days_remaining()], default=None) + + def sla_days_remaining(self): + return self.sla_days_remaining_internal + + def sla_deadline(self): + if not self.findings.all(): + return None + + return min([find.sla_deadline() for find in self.findings.all() if find.sla_deadline()], default=None) + + def status(self): + if not self.findings.all(): + return None + + if any(find.active for find in self.findings.all()): + return "Active" + + if all(find.is_mitigated for find in self.findings.all()): + return "Mitigated" + + return "Inactive" + + @cached_property + def mitigated(self): + return all(find.mitigated is not None for find in self.findings.all()) + + def get_sla_start_date(self): + return min(find.get_sla_start_date() for find in self.findings.all()) + + def get_absolute_url(self): + return reverse("view_test", args=[str(self.test.id)]) + + class Meta: + ordering = ["id"] + + +class Finding_Template(models.Model): + title = models.TextField(max_length=1000) + cwe = models.IntegerField(default=None, null=True, blank=True) + cve = models.CharField(max_length=50, + null=True, + blank=False, + verbose_name="Vulnerability Id", + help_text="An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.") + cvssv3 = models.TextField(help_text=_("Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this finding."), validators=[cvss3_validator], max_length=117, null=True, verbose_name=_("CVSS v3 vector")) + cvssv3_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv3 score")) + cvssv4 = models.TextField(help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding."), validators=[cvss4_validator], max_length=255, null=True, verbose_name=_("CVSS4 vector")) + cvssv4_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv4 score")) + + severity = models.CharField(max_length=200, null=True, blank=True) + description = models.TextField(null=True, blank=True) + mitigation = models.TextField(null=True, blank=True) + impact = models.TextField(null=True, blank=True) + references = models.TextField(null=True, blank=True, db_column="refs") + last_used = models.DateTimeField(null=True, editable=False) + numerical_severity = models.CharField(max_length=4, null=True, blank=True, editable=False) + + # Remediation planning fields + fix_available = models.BooleanField(null=True, blank=True, help_text=_("Indicates if a fix is available for this vulnerability type")) + fix_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Version where fix is available")) + planned_remediation_version = models.CharField(max_length=99, null=True, blank=True, help_text=_("Target version for remediation")) + effort_for_fixing = models.CharField(max_length=99, null=True, blank=True, help_text=_("Effort estimate for fixing (e.g., Low/Medium/High)")) + + # Technical details fields + steps_to_reproduce = models.TextField(null=True, blank=True, help_text=_("Standard reproduction steps for this vulnerability type")) + severity_justification = models.TextField(null=True, blank=True, help_text=_("Explanation of why this severity level is appropriate")) + component_name = models.CharField(max_length=500, null=True, blank=True, help_text=_("Affected component name (e.g., library name)")) + component_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Affected component version")) + + # Notes field (single note content, not a list) + notes = models.TextField(null=True, blank=True, help_text=_("Note content to add when applying this template")) + + # String-based list fields (newline-separated) + vulnerability_ids_text = models.TextField(null=True, blank=True, help_text=_("Vulnerability IDs (one per line)")) + endpoints_text = models.TextField(null=True, blank=True, help_text=_("Endpoint URLs (one per line)")) + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.")) + + SEVERITIES = {"Info": 4, "Low": 3, "Medium": 2, + "High": 1, "Critical": 0} + + class Meta: + ordering = ["-cwe"] + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("edit_template", args=[str(self.id)]) + + def get_breadcrumbs(self): + return [{"title": str(self), + "url": reverse("view_template", args=(self.id,))}] + + @property + def vulnerability_ids(self): + """Parse vulnerability IDs from TextField string (newline-separated).""" + vulnerability_ids = [] + + # Get from the TextField + if self.vulnerability_ids_text: + # Parse newline-separated string, remove empty lines + vulnerability_ids = [line.strip() for line in self.vulnerability_ids_text.split("\n") if line.strip()] + + # Synchronize the cve field with the vulnerability_ids + # We do this to be as flexible as possible to handle the fields until + # the cve field is not needed anymore and can be removed. + if vulnerability_ids and self.cve and self.cve not in vulnerability_ids: + # Make sure the first entry of the list is the value of the cve field + vulnerability_ids.insert(0, self.cve) + elif not vulnerability_ids and self.cve: + # If there is no list, make one with the value of the cve field + vulnerability_ids = [self.cve] + + # Remove duplicates + return list(dict.fromkeys(vulnerability_ids)) + + @property + def endpoints(self): + """Parse endpoint URLs from TextField string (newline-separated).""" + if not self.endpoints_text: + return [] + # Parse newline-separated string, remove empty lines + return [line.strip() for line in self.endpoints_text.split("\n") if line.strip()] diff --git a/dojo/models.py b/dojo/models.py index 5f709f4b543..3cd1041caec 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1,22 +1,16 @@ import base64 import contextlib import copy -import hashlib import logging import re import warnings -from contextlib import suppress -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path -from typing import TYPE_CHECKING from urllib.parse import urlparse from uuid import uuid4 -import dateutil import hyperlink import tagulous.admin -from dateutil.parser import parse as datetutilsparse -from dateutil.relativedelta import relativedelta from django import forms from django.conf import settings from django.contrib import admin @@ -31,8 +25,6 @@ from django.urls import reverse from django.utils import timezone from django.utils.deconstruct import deconstructible -from django.utils.functional import cached_property -from django.utils.html import escape from django.utils.timezone import now from django.utils.translation import gettext as _ from django_extensions.db.models import TimeStampedModel @@ -41,14 +33,6 @@ from polymorphic.models import PolymorphicModel from tagulous.models import TagField from tagulous.models.managers import FakeTagRelatedManager # noqa: F401 -- backward compat re-export -from titlecase import titlecase - -from dojo.base_models.base import BaseModel -from dojo.validators import cvss3_validator, cvss4_validator - -if TYPE_CHECKING: - from dojo.importers.location_manager import UnsavedLocation - logger = logging.getLogger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") @@ -760,7 +744,7 @@ def clean(self): Test, Test_Import, # noqa: F401 -- re-export Test_Import_Finding_Action, # noqa: F401 -- re-export - Test_Type, + Test_Type, # noqa: F401 -- re-export ) @@ -1516,1457 +1500,12 @@ class Meta: ordering = ("-created", ) -class Finding(BaseModel): - # Fields loaded when performing deduplication (used by get_finding_models_for_deduplication - # and build_candidate_scope_queryset to restrict the SELECT to only what is needed). - # Covers the union of all deduplication algorithms so that a single queryset works - # regardless of which algorithm is in use. Large text fields (description, mitigation, - # impact, references, …) are intentionally excluded. - DEDUPLICATION_FIELDS = [ - "id", - # FK required for select_related("test") — must not be deferred - "test", - # Fields written by set_duplicate - "duplicate", - "active", - "verified", - "duplicate_finding", - # Guard checks in set_duplicate - "is_mitigated", - "mitigated", - "out_of_scope", - "false_p", - # Accessed by status() (debug logging only) - "under_review", - "risk_accepted", - # Used by hash-code and legacy algorithms for endpoint/location matching - "dynamic_finding", - "static_finding", - # Algorithm-specific matching fields - "hash_code", # hash_code, uid_or_hash, legacy - "unique_id_from_tool", # unique_id, uid_or_hash - "title", # legacy - "cwe", # legacy - "file_path", # legacy - "line", # legacy - ] - - # Large text fields deferred in build_candidate_scope_queryset. These are - # never accessed during deduplication or reimport candidate matching, so - # excluding them reduces the data loaded for every candidate finding. - DEDUPLICATION_DEFERRED_FIELDS = [ - "description", - "mitigation", - "impact", - "steps_to_reproduce", - "severity_justification", - "references", - "url", - "cvssv3", - "cvssv4", - ] - - title = models.CharField(max_length=511, - verbose_name=_("Title"), - help_text=_("A short description of the flaw.")) - date = models.DateField(default=get_current_date, - verbose_name=_("Date"), - help_text=_("The date the flaw was discovered.")) - sla_start_date = models.DateField( - blank=True, - null=True, - verbose_name=_("SLA Start Date"), - help_text=_("(readonly)The date used as start date for SLA calculation. Set by expiring risk acceptances. Empty by default, causing a fallback to 'date'.")) - sla_expiration_date = models.DateField( - blank=True, - null=True, - verbose_name=_("SLA Expiration Date"), - help_text=_("(readonly)The date SLA expires for this finding. Empty by default, causing a fallback to 'date'.")) - cwe = models.IntegerField(default=0, null=True, blank=True, - verbose_name=_("CWE"), - help_text=_("The CWE number associated with this flaw.")) - cve = models.CharField(max_length=50, - null=True, - blank=False, - verbose_name=_("Vulnerability Id"), - help_text=_("An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.")) - epss_score = models.FloatField(default=None, null=True, blank=True, - verbose_name=_("EPSS Score"), - help_text=_("EPSS score for the CVE. Describes how likely it is the vulnerability will be exploited in the next 30 days."), - validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) - epss_percentile = models.FloatField(default=None, null=True, blank=True, - verbose_name=_("EPSS percentile"), - help_text=_("EPSS percentile for the CVE. Describes how many CVEs are scored at or below this one."), - validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) - known_exploited = models.BooleanField(default=False, - verbose_name=_("Known Exploited"), - help_text=_("Whether this vulnerability is known to have been exploited in the wild.")) - ransomware_used = models.BooleanField(default=False, - verbose_name=_("Used in Ransomware"), - help_text=_("Whether this vulnerability is known to have been leveraged as part of a ransomware campaign.")) - kev_date = models.DateField(null=True, blank=True, - verbose_name=_("KEV Date Added"), - help_text=_("The date the vulnerability was added to the KEV catalog."), - validators=[MaxValueValidator(tomorrow)]) - cvssv3 = models.TextField(validators=[cvss3_validator], - max_length=117, - null=True, - verbose_name=_("CVSS3 Vector"), - help_text=_("Common Vulnerability Scoring System version 3 (CVSS3) score associated with this finding.")) - cvssv3_score = models.FloatField(null=True, - blank=True, - verbose_name=_("CVSS3 Score"), - help_text=_("Numerical CVSSv3 score for the vulnerability. If the vector is given, the score is updated while saving the finding. The value must be between 0-10."), - validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]) - - cvssv4 = models.TextField(validators=[cvss4_validator], - max_length=255, - null=True, - verbose_name=_("CVSS4 vector"), - help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding.")) - cvssv4_score = models.FloatField(null=True, - blank=True, - verbose_name=_("CVSSv4 Score"), - help_text=_("Numerical CVSSv4 score for the vulnerability. If the vector is given, the score is updated while saving the finding. The value must be between 0-10."), - validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]) - - url = models.TextField(null=True, - blank=True, - editable=False, - verbose_name=_("URL"), - help_text=_("External reference that provides more information about this flaw.")) # not displayed and pretty much the same as references. To remove? - severity = models.CharField(max_length=200, - verbose_name=_("Severity"), - help_text=_("The severity level of this flaw (Critical, High, Medium, Low, Info).")) - description = models.TextField(verbose_name=_("Description"), - help_text=_("Longer more descriptive information about the flaw.")) - mitigation = models.TextField(verbose_name=_("Mitigation"), - null=True, - blank=True, - help_text=_("Text describing how to best fix the flaw.")) - fix_available = models.BooleanField(null=True, - default=None, - verbose_name=_("Fix Available"), - help_text=_("Denotes if there is a fix available for this flaw.")) - fix_version = models.CharField(null=True, - blank=True, - max_length=100, - verbose_name=_("Fix version"), - help_text=_("Version of the affected component in which the flaw is fixed.")) - impact = models.TextField(verbose_name=_("Impact"), - null=True, - blank=True, - help_text=_("Text describing the impact this flaw has on systems, products, enterprise, etc.")) - steps_to_reproduce = models.TextField(null=True, - blank=True, - verbose_name=_("Steps to Reproduce"), - help_text=_("Text describing the steps that must be followed in order to reproduce the flaw / bug.")) - severity_justification = models.TextField(null=True, - blank=True, - verbose_name=_("Severity Justification"), - help_text=_("Text describing why a certain severity was associated with this flaw.")) - # TODO: Delete this after the move to Locations - endpoints = models.ManyToManyField(Endpoint, - blank=True, - verbose_name=_("Endpoints"), - help_text=_("The hosts within the product that are susceptible to this flaw. + The status of the endpoint associated with this flaw (Vulnerable, Mitigated, ...)."), - through=Endpoint_Status) - references = models.TextField(null=True, - blank=True, - db_column="refs", - verbose_name=_("References"), - help_text=_("The external documentation available for this flaw.")) - test = models.ForeignKey(Test, - editable=False, - on_delete=models.CASCADE, - verbose_name=_("Test"), - help_text=_("The test that is associated with this flaw.")) - active = models.BooleanField(default=True, - verbose_name=_("Active"), - help_text=_("Denotes if this flaw is active or not.")) - # note that false positive findings cannot be verified - # in defectdojo verified means: "we have verified the finding and it turns out that it's not a false positive" - verified = models.BooleanField(default=False, - verbose_name=_("Verified"), - help_text=_("Denotes if this flaw has been manually verified by the tester.")) - false_p = models.BooleanField(default=False, - verbose_name=_("False Positive"), - help_text=_("Denotes if this flaw has been deemed a false positive by the tester.")) - duplicate = models.BooleanField(default=False, - verbose_name=_("Duplicate"), - help_text=_("Denotes if this flaw is a duplicate of other flaws reported.")) - duplicate_finding = models.ForeignKey("self", - editable=False, - null=True, - related_name="original_finding", - blank=True, on_delete=models.DO_NOTHING, - verbose_name=_("Duplicate Finding"), - help_text=_("Link to the original finding if this finding is a duplicate.")) - out_of_scope = models.BooleanField(default=False, - verbose_name=_("Out Of Scope"), - help_text=_("Denotes if this flaw falls outside the scope of the test and/or engagement.")) - risk_accepted = models.BooleanField(default=False, - verbose_name=_("Risk Accepted"), - help_text=_("Denotes if this finding has been marked as an accepted risk.")) - under_review = models.BooleanField(default=False, - verbose_name=_("Under Review"), - help_text=_("Denotes is this flaw is currently being reviewed.")) - - last_status_update = models.DateTimeField(editable=False, - null=True, - blank=True, - auto_now_add=True, - verbose_name=_("Last Status Update"), - help_text=_("Timestamp of latest status update (change in status related fields).")) - - review_requested_by = models.ForeignKey(Dojo_User, - null=True, - blank=True, - related_name="review_requested_by", - on_delete=models.RESTRICT, - verbose_name=_("Review Requested By"), - help_text=_("Documents who requested a review for this finding.")) - reviewers = models.ManyToManyField(Dojo_User, - blank=True, - verbose_name=_("Reviewers"), - help_text=_("Documents who reviewed the flaw.")) - - # Defect Tracking Review - under_defect_review = models.BooleanField(default=False, - verbose_name=_("Under Defect Review"), - help_text=_("Denotes if this finding is under defect review.")) - defect_review_requested_by = models.ForeignKey(Dojo_User, - null=True, - blank=True, - related_name="defect_review_requested_by", - on_delete=models.RESTRICT, - verbose_name=_("Defect Review Requested By"), - help_text=_("Documents who requested a defect review for this flaw.")) - is_mitigated = models.BooleanField(default=False, - verbose_name=_("Is Mitigated"), - help_text=_("Denotes if this flaw has been fixed.")) - thread_id = models.IntegerField(default=0, - editable=False, - verbose_name=_("Thread ID")) - mitigated = models.DateTimeField(editable=False, - null=True, - blank=True, - verbose_name=_("Mitigated"), - help_text=_("Denotes if this flaw has been fixed by storing the date it was fixed.")) - mitigated_by = models.ForeignKey(Dojo_User, - null=True, - editable=False, - related_name="mitigated_by", - on_delete=models.RESTRICT, - verbose_name=_("Mitigated By"), - help_text=_("Documents who has marked this flaw as fixed.")) - reporter = models.ForeignKey(Dojo_User, - editable=False, - default=1, - related_name="reporter", - on_delete=models.RESTRICT, - verbose_name=_("Reporter"), - help_text=_("Documents who reported the flaw.")) - notes = models.ManyToManyField(Notes, - blank=True, - editable=False, - verbose_name=_("Notes"), - help_text=_("Stores information pertinent to the flaw or the mitigation.")) - numerical_severity = models.CharField(max_length=4, - verbose_name=_("Numerical Severity"), - help_text=_("The numerical representation of the severity (S0, S1, S2, S3, S4).")) - last_reviewed = models.DateTimeField(null=True, - editable=False, - verbose_name=_("Last Reviewed"), - help_text=_("Provides the date the flaw was last 'touched' by a tester.")) - last_reviewed_by = models.ForeignKey(Dojo_User, - null=True, - editable=False, - related_name="last_reviewed_by", - on_delete=models.RESTRICT, - verbose_name=_("Last Reviewed By"), - help_text=_("Provides the person who last reviewed the flaw.")) - files = models.ManyToManyField(FileUpload, - blank=True, - editable=False, - verbose_name=_("Files"), - help_text=_("Files(s) related to the flaw.")) - param = models.TextField(null=True, - blank=True, - editable=False, - verbose_name=_("Parameter"), - help_text=_("Parameter used to trigger the issue (DAST).")) - payload = models.TextField(null=True, - blank=True, - editable=False, - verbose_name=_("Payload"), - help_text=_("Payload used to attack the service / application and trigger the bug / problem.")) - hash_code = models.CharField(null=True, - blank=True, - editable=False, - max_length=64, - verbose_name=_("Hash Code"), - help_text=_("A hash over a configurable set of fields that is used for findings deduplication.")) - line = models.IntegerField(null=True, - blank=True, - verbose_name=_("Line number"), - help_text=_("Source line number of the attack vector.")) - file_path = models.CharField(null=True, - blank=True, - max_length=4000, - verbose_name=_("File path"), - help_text=_("Identified file(s) containing the flaw.")) - component_name = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("Component name"), - help_text=_("Name of the affected component (library name, part of a system, ...).")) - component_version = models.CharField(null=True, - blank=True, - max_length=100, - verbose_name=_("Component version"), - help_text=_("Version of the affected component.")) - found_by = models.ManyToManyField(Test_Type, - editable=False, - verbose_name=_("Found by"), - help_text=_("The name of the scanner that identified the flaw.")) - static_finding = models.BooleanField(default=False, - verbose_name=_("Static finding (SAST)"), - help_text=_("Flaw has been detected from a Static Application Security Testing tool (SAST).")) - dynamic_finding = models.BooleanField(default=True, - verbose_name=_("Dynamic finding (DAST)"), - help_text=_("Flaw has been detected from a Dynamic Application Security Testing tool (DAST).")) - scanner_confidence = models.IntegerField(null=True, - blank=True, - default=None, - editable=False, - verbose_name=_("Scanner confidence"), - help_text=_("Confidence level of vulnerability which is supplied by the scanner.")) - sonarqube_issue = models.ForeignKey(Sonarqube_Issue, - null=True, - blank=True, - help_text=_("The SonarQube issue associated with this finding."), - verbose_name=_("SonarQube issue"), - on_delete=models.CASCADE) - unique_id_from_tool = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("Unique ID from tool"), - help_text=_("Vulnerability technical id from the source tool. Allows to track unique vulnerabilities over time across subsequent scans.")) - vuln_id_from_tool = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("Vulnerability ID from tool"), - help_text=_("Non-unique technical id from the source tool associated with the vulnerability type.")) - sast_source_object = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("SAST Source Object"), - help_text=_("Source object (variable, function...) of the attack vector.")) - sast_sink_object = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("SAST Sink Object"), - help_text=_("Sink object (variable, function...) of the attack vector.")) - sast_source_line = models.IntegerField(null=True, - blank=True, - verbose_name=_("SAST Source Line number"), - help_text=_("Source line number of the attack vector.")) - sast_source_file_path = models.CharField(null=True, - blank=True, - max_length=4000, - verbose_name=_("SAST Source File Path"), - help_text=_("Source file path of the attack vector.")) - nb_occurences = models.IntegerField(null=True, - blank=True, - verbose_name=_("Number of occurences"), - help_text=_("Number of occurences in the source tool when several vulnerabilites were found and aggregated by the scanner.")) - - # this is useful for vulnerabilities on dependencies : helps answer the question "Did I add this vulnerability or was it discovered recently?" - publish_date = models.DateField(null=True, - blank=True, - verbose_name=_("Publish date"), - help_text=_("Date when this vulnerability was made publicly available.")) - - # The service is used to generate the hash_code, so that it gets part of the deduplication of findings. - service = models.CharField(null=True, - blank=True, - max_length=200, - verbose_name=_("Service"), - help_text=_("A service is a self-contained piece of functionality within a Product. This is an optional field which is used in deduplication of findings when set.")) - - planned_remediation_date = models.DateField(null=True, - editable=True, - verbose_name=_("Planned Remediation Date"), - help_text=_("The date the flaw is expected to be remediated.")) - - planned_remediation_version = models.CharField(null=True, - blank=True, - max_length=99, - verbose_name=_("Planned remediation version"), - help_text=_("The target version when the vulnerability should be fixed / remediated")) - - effort_for_fixing = models.CharField(null=True, - blank=True, - max_length=99, - verbose_name=_("Effort for fixing"), - help_text=_("Effort for fixing / remediating the vulnerability (Low, Medium, High)")) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding. Choose from the list or add new tags. Press Enter key to add.")) - inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) - - SEVERITIES = {"Info": 4, "Low": 3, "Medium": 2, - "High": 1, "Critical": 0} - - class Meta: - ordering = ("numerical_severity", "-date", "title", "epss_score", "epss_percentile") - indexes = [ - models.Index(fields=["test", "active", "verified"]), - - models.Index(fields=["test", "is_mitigated"]), - models.Index(fields=["test", "duplicate"]), - models.Index(fields=["test", "out_of_scope"]), - models.Index(fields=["test", "false_p"]), - - models.Index(fields=["test", "unique_id_from_tool", "duplicate"]), - models.Index(fields=["test", "hash_code", "duplicate"]), - - models.Index(fields=["test", "component_name"]), - - models.Index(fields=["cve"]), - models.Index(fields=["epss_score"]), - models.Index(fields=["epss_percentile"]), - models.Index(fields=["cwe"]), - models.Index(fields=["out_of_scope"]), - models.Index(fields=["false_p"]), - models.Index(fields=["verified"]), - models.Index(fields=["mitigated"]), - models.Index(fields=["active"]), - models.Index(fields=["numerical_severity"]), - models.Index(fields=["date"]), - models.Index(fields=["title"]), - models.Index(fields=["hash_code"]), - models.Index(fields=["unique_id_from_tool"]), - # models.Index(fields=['file_path']), # can't add index because the field has max length 4000. - models.Index(fields=["line"]), - models.Index(fields=["component_name"]), - models.Index(fields=["duplicate"]), - models.Index(fields=["is_mitigated"]), - models.Index(fields=["duplicate_finding", "id"]), - models.Index(fields=["known_exploited"]), - models.Index(fields=["ransomware_used"]), - models.Index(fields=["kev_date"]), - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if settings.V3_FEATURE_LOCATIONS: - self.unsaved_locations: list[UnsavedLocation] = [] - else: - # TODO: Delete this after the move to Locations - self.unsaved_endpoints = [] - self.unsaved_request = None - self.unsaved_response = None - self.unsaved_tags = None - self.unsaved_files = None - self.unsaved_vulnerability_ids = None - - def __str__(self): - return self.title - - def save(self, dedupe_option=True, rules_option=True, product_grading_option=True, # noqa: FBT002 - issue_updater_option=True, push_to_jira=False, user=None, *args, **kwargs): # noqa: FBT002 - this is bit hard to fix nice have this universally fixed - logger.debug("Start saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") - from dojo.finding import helper as finding_helper # noqa: PLC0415 circular import - - is_new_finding = self.pk is None - - # if not isinstance(self.date, (datetime, date)): - # raise ValidationError(_("The 'date' field must be a valid date or datetime object.")) - - if not user: - from dojo.utils import get_current_user # noqa: PLC0415 circular import - user = get_current_user() - # Title Casing - self.title = titlecase(self.title[:511]) - # Set the date of the finding if nothing is supplied - if self.date is None: - self.date = timezone.now() - # Assign the numerical severity for correct sorting order - self.numerical_severity = Finding.get_numerical_severity(self.severity) - - # Synchronize cvssv3 score using cvssv3 vector - - if self.cvssv3: - try: - cvss_data = parse_cvss_data(self.cvssv3) - if cvss_data: - self.cvssv3 = cvss_data.get("cvssv3") - self.cvssv3_score = cvss_data.get("cvssv3_score") - - except Exception as ex: - logger.warning("Can't compute cvssv3 score for finding id %i. Invalid cvssv3 vector found: '%s'. Exception: %s.", self.id, self.cvssv3, ex) - # remove invalid cvssv3 vector for new findings, or should we just throw a ValidationError? - if self.pk is None: - self.cvssv3 = None - - # behaviour for CVVS4 is slightly different. Extracting this into a method would lead to probably hard to read code - if self.cvssv4: - try: - cvss_data = parse_cvss_data(self.cvssv4) - if cvss_data: - self.cvssv4 = cvss_data.get("cvssv4") - self.cvssv4_score = cvss_data.get("cvssv4_score") - - except Exception as ex: - logger.warning("Can't compute cvssv4 score for finding id %i. Invalid cvssv4 vector found: '%s'. Exception: %s.", self.id, self.cvssv4, ex) - self.cvssv4 = None - - self.set_hash_code(dedupe_option) - - if is_new_finding: - if settings.V3_FEATURE_LOCATIONS: - if (self.file_path is not None) and (len(self.unsaved_locations) == 0): - self.static_finding = True - self.dynamic_finding = False - elif (self.file_path is not None): - self.static_finding = True - # TODO: Delete this after the move to Locations - elif (self.file_path is not None) and (len(self.unsaved_endpoints) == 0): - self.static_finding = True - self.dynamic_finding = False - elif (self.file_path is not None): - self.static_finding = True - - # because we have reduced the number of (super()).save() calls, the helper is no longer called for new findings - # so we call it manually - finding_helper.update_finding_status(self, user, changed_fields={"id": (None, None)}) - - # logger.debug('setting static / dynamic in save') - # need to have an id/pk before we can access locations/endpoints - elif self.file_path is not None: - if settings.V3_FEATURE_LOCATIONS: - if not self.locations.exists(): - self.static_finding = True - self.dynamic_finding = False - else: - self.static_finding = True - # TODO: Delete this after the move to Locations - elif not self.endpoints.exists(): - self.static_finding = True - self.dynamic_finding = False - else: - self.static_finding = True - - # update the SLA expiration date last, after all other finding fields have been updated - self.set_sla_expiration_date() - - logger.debug("Saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") - # We cannot run the full_clean method here without issue, so we specify skip_validation - super().save(*args, **kwargs, skip_validation=True) - - # Only add to found_by for newly-created findings (avoid doing this on every update) - if is_new_finding: - self.found_by.add(self.test.test_type) - - # only perform post processing (in celery task) if needed. this check avoids submitting 1000s of tasks to celery that will do nothing - system_settings = System_Settings.objects.get() - if dedupe_option or issue_updater_option or (product_grading_option and system_settings.enable_product_grade) or push_to_jira: - finding_helper.post_process_finding_save(self.id, dedupe_option=dedupe_option, rules_option=rules_option, product_grading_option=product_grading_option, - issue_updater_option=issue_updater_option, push_to_jira=push_to_jira, user=user, *args, **kwargs) - else: - logger.debug("no options selected that require finding post processing") - - def get_absolute_url(self): - return reverse("view_finding", args=[str(self.id)]) - - def copy(self, test=None): - copy = copy_model_util(self) - # Save the necessary ManyToMany relationships - old_notes = list(self.notes.all()) - old_files = list(self.files.all()) - old_reviewers = list(self.reviewers.all()) - old_found_by = list(self.found_by.all()) - old_tags = list(self.tags.all()) - # Wipe the IDs of the new object - if test: - copy.test = test - # Save the object before setting any ManyToMany relationships - copy.save() - # Copy the notes - for notes in old_notes: - copy.notes.add(notes.copy()) - # Copy the files - for files in old_files: - copy.files.add(files.copy()) - if settings.V3_FEATURE_LOCATIONS: - old_location_refs = self.locations.all() - for location_ref in old_location_refs: - location_ref.copy(copy) - else: - # TODO: Delete this after the move to Locations - # Copy the endpoint_status - old_status_findings = list(self.status_finding.all()) - for endpoint_status in old_status_findings: - endpoint_status.copy(finding=copy) # adding or setting is not necessary, link is created by Endpoint_Status.copy() - # Assign any reviewers - copy.reviewers.set(old_reviewers) - # Assign any found_by - copy.found_by.set(old_found_by) - # Assign any tags - copy.tags.set(old_tags) - - return copy - - def delete(self, *args, product_grading_option=True, **kwargs): - logger.debug("%d finding delete", self.id) - from dojo.finding import helper as finding_helper # noqa: PLC0415 circular import - finding_helper.finding_delete(self) - super().delete(*args, **kwargs) - if product_grading_option: - with suppress(Finding.DoesNotExist, Test.DoesNotExist, Engagement.DoesNotExist, Product.DoesNotExist): - # Suppressing a potential issue created from async delete removing - # related objects in a separate task - from dojo.utils import perform_product_grading # noqa: PLC0415 circular import - perform_product_grading(self.test.engagement.product) - - # only used by bulk risk acceptance api - @classmethod - def unaccepted_open_findings(cls): - from dojo.utils import get_system_setting # noqa: PLC0415 circular import - results = cls.objects.filter(active=True, duplicate=False, risk_accepted=False) - if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): - results = results.filter(verified=True) - - return results - - @property - def risk_acceptance(self): - ras = self.risk_acceptance_set.all() - if ras: - return ras[0] - - return None - - def compute_hash_code(self): - # Allow Pro to overwrite compute hash_code which gets dedupe settings from a database instead of django.settings - from dojo.utils import get_custom_method # noqa: PLC0415 circular import - if compute_hash_code_method := get_custom_method("FINDING_COMPUTE_HASH_METHOD"): - deduplicationLogger.debug("using custom FINDING_COMPUTE_HASH_METHOD method") - return compute_hash_code_method(self) - - # Check if all needed settings are defined - if not hasattr(settings, "HASHCODE_FIELDS_PER_SCANNER") or not hasattr(settings, "HASHCODE_ALLOWS_NULL_CWE") or not hasattr(settings, "HASHCODE_ALLOWED_FIELDS"): - deduplicationLogger.debug("no or incomplete configuration per hash_code found; using legacy algorithm") - return self.compute_hash_code_legacy() - - hash_code_fields = self.test.hash_code_fields - - # Check if hash_code fields are found in the settings - if not hash_code_fields: - deduplicationLogger.debug( - "No configuration for hash_code computation found; using default fields for " + ("dynamic" if self.dynamic_finding else "static") + " scanners") - return self.compute_hash_code_legacy() - - # Check if all elements of HASHCODE_FIELDS_PER_SCANNER are in HASHCODE_ALLOWED_FIELDS - if not (all(elem in settings.HASHCODE_ALLOWED_FIELDS for elem in hash_code_fields)): - deduplicationLogger.debug( - "compute_hash_code - configuration error: some elements of HASHCODE_FIELDS_PER_SCANNER are not in the allowed list HASHCODE_ALLOWED_FIELDS. " - "Using default fields") - return self.compute_hash_code_legacy() - - # Make sure that we have a cwe if we need one - if self.cwe == 0 and not self.test.hash_code_allows_null_cwe: - deduplicationLogger.debug( - "Cannot compute hash_code based on configured fields because cwe is 0 for finding of title '" + self.title + "' found in file '" + str(self.file_path) - + "'. Fallback to legacy mode for this finding.") - return self.compute_hash_code_legacy() - - deduplicationLogger.debug("computing hash_code for finding id " + str(self.id) + " based on: " + ", ".join(hash_code_fields)) - - fields_to_hash = "" - for hashcodeField in hash_code_fields: - # Note: preserve this field label ("endpoints") for settings purposes through the Locations migration - if hashcodeField == "endpoints": - # For locations/endpoints, need to compute the field - locations = self.get_locations() - fields_to_hash += locations - deduplicationLogger.debug(hashcodeField + " : " + locations) - elif hashcodeField == "vulnerability_ids": - # For vulnerability_ids, need to compute the field - my_vulnerability_ids = self.get_vulnerability_ids() - fields_to_hash += my_vulnerability_ids - deduplicationLogger.debug(hashcodeField + " : " + my_vulnerability_ids) - else: - # Generically use the finding attribute having the same name, converts to str in case it's integer - fields_to_hash += str(getattr(self, hashcodeField)) - deduplicationLogger.debug(hashcodeField + " : " + str(getattr(self, hashcodeField))) - - # Log the hash_code fields that are always included (but are not part of the hash_code_fields list as they are inserted downtstream in self.hash_fields) - hash_code_fields_always = getattr(settings, "HASH_CODE_FIELDS_ALWAYS", []) - for hashcodeField in hash_code_fields_always: - if getattr(self, hashcodeField): - deduplicationLogger.debug(hashcodeField + " : " + str(getattr(self, hashcodeField))) - - deduplicationLogger.debug("compute_hash_code - fields_to_hash = " + fields_to_hash) - return self.hash_fields(fields_to_hash) - - def compute_hash_code_legacy(self): - fields_to_hash = self.title + str(self.cwe) + str(self.line) + str(self.file_path) + self.description - deduplicationLogger.debug("compute_hash_code_legacy - fields_to_hash = " + fields_to_hash) - return self.hash_fields(fields_to_hash) - - # Get vulnerability_ids to use for hash_code computation - def get_vulnerability_ids(self): - - def _get_unsaved_vulnerability_ids(finding) -> str: - if finding.unsaved_vulnerability_ids: - deduplicationLogger.debug("get_vulnerability_ids before the finding was saved") - # convert list of unsaved vulnerability_ids to the list of their canonical representation - vulnerability_id_str_list = [str(vulnerability_id) for vulnerability_id in finding.unsaved_vulnerability_ids] - # deduplicate (usually done upon saving finding) and sort endpoints - return "".join(sorted(dict.fromkeys(vulnerability_id_str_list))) - deduplicationLogger.debug("finding has no unsaved vulnerability references") - return "" - - def _get_saved_vulnerability_ids(finding) -> str: - if finding.id is not None: - vulnerability_ids = Vulnerability_Id.objects.filter(finding=finding) - deduplicationLogger.debug("get_vulnerability_ids after the finding was saved. Vulnerability references count: " + str(vulnerability_ids.count())) - # convert list of vulnerability_ids to the list of their canonical representation - vulnerability_id_str_list = [str(vulnerability_id) for vulnerability_id in vulnerability_ids.all()] - # sort vulnerability_ids strings - return "".join(sorted(vulnerability_id_str_list)) - return "" - - return _get_saved_vulnerability_ids(self) or _get_unsaved_vulnerability_ids(self) - - # Get locations/endpoints to use for hash_code computation - def get_locations(self): - # TODO: Delete this after the move to Locations - if not settings.V3_FEATURE_LOCATIONS: - # Get endpoints to use for hash_code computation - # (This sometimes reports "None") - def _get_unsaved_endpoints(finding) -> str: - if len(finding.unsaved_endpoints) > 0: - deduplicationLogger.debug("get_endpoints before the finding was saved") - # convert list of unsaved endpoints to the list of their canonical representation - endpoint_str_list = [str(endpoint) for endpoint in finding.unsaved_endpoints] - # deduplicate (usually done upon saving finding) and sort endpoints - return "".join(dict.fromkeys(endpoint_str_list)) - # we can get here when the parser defines static_finding=True but leaves dynamic_finding defaulted - # In this case, before saving the finding, both static_finding and dynamic_finding are True - # After saving dynamic_finding may be set to False probably during the saving process (observed on Bandit scan before forcing dynamic_finding=False at parser level) - deduplicationLogger.debug("trying to get endpoints on a finding before it was saved but no endpoints found (static parser wrongly identified as dynamic?") - return "" - - def _get_saved_endpoints(finding) -> str: - if finding.id is not None: - deduplicationLogger.debug("get_endpoints: after the finding was saved. Endpoints count: " + str(finding.endpoints.count())) - # convert list of endpoints to the list of their canonical representation - endpoint_str_list = [str(endpoint) for endpoint in finding.endpoints.all()] - # sort endpoints strings - return "".join(sorted(endpoint_str_list)) - return "" - - return _get_saved_endpoints(self) or _get_unsaved_endpoints(self) - - def _get_unsaved_locations(finding) -> str: - if len(finding.unsaved_locations) > 0: - deduplicationLogger.debug("get_locations before the finding was saved") - # convert list of unsaved locations to the list of their canonical representation - from dojo.importers.location_manager import LocationManager # noqa: PLC0415 - unsaved_locations = LocationManager.clean_unsaved_locations(finding.unsaved_locations) - # deduplicate (usually done upon saving finding) and sort locations - locations = sorted({location.get_location_value() for location in unsaved_locations}) - return "".join(locations) - # we can get here when the parser defines static_finding=True but leaves dynamic_finding defaulted - # In this case, before saving the finding, both static_finding and dynamic_finding are True - # After saving dynamic_finding may be set to False probably during the saving process (observed on Bandit scan before forcing dynamic_finding=False at parser level) - deduplicationLogger.debug("trying to get locations on a finding before it was saved but no locations found (static parser wrongly identified as dynamic?") - return "" - - def _get_saved_locations(finding) -> str: - if finding.id is not None: - from dojo.url.models import URL # noqa: PLC0415 - url_locations = finding.locations.filter(location__location_type=URL.get_location_type()) - deduplicationLogger.debug("get_locations: after the finding was saved. Locations count: " + str(url_locations.count())) - # convert list of locations to the list of their canonical representation - locations = sorted({location_ref.location.get_location_value() for location_ref in url_locations.all()}) - # sort locations strings - return "".join(sorted(locations)) - return "" - - return _get_saved_locations(self) or _get_unsaved_locations(self) - - # Compute the hash_code from the fields to hash - def hash_fields(self, fields_to_hash): - if hasattr(settings, "HASH_CODE_FIELDS_ALWAYS"): - for field in settings.HASH_CODE_FIELDS_ALWAYS: - if getattr(self, field): - deduplicationLogger.debug("adding HASH_CODE_FIELDS_ALWAYSfield %s to hash_fields: %s", field, getattr(self, field)) - fields_to_hash += str(getattr(self, field)) - - logger.debug("fields_to_hash : %s", fields_to_hash) - logger.debug("fields_to_hash lower: %s", fields_to_hash.lower()) - return hashlib.sha256(fields_to_hash.casefold().encode("utf-8").strip()).hexdigest() - - def duplicate_finding_set(self): - if self.duplicate: - if self.duplicate_finding is not None: - return Finding.objects.get( - id=self.duplicate_finding.id).original_finding.all().order_by("title") - return [] - return self.original_finding.all().order_by("title") - - def get_scanner_confidence_text(self): - if self.scanner_confidence and isinstance(self.scanner_confidence, int): - if self.scanner_confidence <= 2: - return "Certain" - if self.scanner_confidence >= 3 and self.scanner_confidence <= 5: - return "Firm" - return "Tentative" - return "" - - @staticmethod - def get_numerical_severity(severity): - if severity == "Critical": - return "S0" - if severity == "High": - return "S1" - if severity == "Medium": - return "S2" - if severity == "Low": - return "S3" - if severity == "Info": - return "S4" - return "S5" - - @staticmethod - def get_number_severity(severity): - if severity == "Critical": - return 4 - if severity == "High": - return 3 - if severity == "Medium": - return 2 - if severity == "Low": - return 1 - if severity == "Info": - return 0 - return 5 - - @staticmethod - def get_severity(num_severity): - severities = {0: "Info", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"} - if num_severity in severities: - return severities[num_severity] - - return None - - def status(self): - status = [] - if self.under_review: - status += ["Under Review"] - if self.active: - status += ["Active"] - else: - status += ["Inactive"] - if self.verified: - status += ["Verified"] - if self.mitigated or self.is_mitigated: - status += ["Mitigated"] - if self.false_p: - status += ["False Positive"] - if self.out_of_scope: - status += ["Out Of Scope"] - if self.duplicate: - status += ["Duplicate"] - if self.risk_accepted: - status += ["Risk Accepted"] - if not len(status): - status += ["Initial"] - - return ", ".join([str(s) for s in status]) - - def _age(self, start_date): - if start_date and isinstance(start_date, str): - start_date = datetutilsparse(start_date).date() - - if isinstance(start_date, datetime): - start_date = start_date.date() - - if self.mitigated: - mitigated_date = self.mitigated - if isinstance(mitigated_date, datetime): - mitigated_date = self.mitigated.date() - diff = mitigated_date - start_date - else: - diff = get_current_date() - start_date - days = diff.days - return max(0, days) - - @property - def age(self): - return self._age(self.date) - - @property - def sla_age(self): - return self._age(self.get_sla_start_date()) - - def get_sla_start_date(self): - if self.sla_start_date: - return self.sla_start_date - return self.date - - def get_sla_configuration(self): - return self.test.engagement.product.sla_configuration - - def get_sla_period(self): - # Determine which method to use to calculate the SLA - from dojo.utils import get_custom_method # noqa: PLC0415 circular import - if method := get_custom_method("FINDING_SLA_PERIOD_METHOD"): - return method(self) - # Run the default method - sla_configuration = self.get_sla_configuration() - sla_period = getattr(sla_configuration, self.severity.lower(), None) - enforce_period = getattr(sla_configuration, str("enforce_" + self.severity.lower()), None) - return sla_period, enforce_period - - def set_sla_expiration_date(self): - # First check if SLA is enabled globally - system_settings = System_Settings.objects.get() - if not system_settings.enable_finding_sla: - return - # Call the internal method to set the sla expiration date - self._set_sla_expiration_date() - - def _set_sla_expiration_date(self): - # some parsers provide date as a `str` instead of a `date` in which case we need to parse it #12299 on GitHub - sla_start_date = self.get_sla_start_date() - if sla_start_date and isinstance(sla_start_date, str): - sla_start_date = dateutil.parser.parse(sla_start_date).date() - - sla_period, enforce_period = self.get_sla_period() - if sla_period is not None and enforce_period: - self.sla_expiration_date = sla_start_date + relativedelta(days=sla_period) - else: - self.sla_expiration_date = None - - def sla_days_remaining(self): - if self.sla_expiration_date: - if self.mitigated: - mitigated_date = self.mitigated - if isinstance(mitigated_date, datetime): - mitigated_date = self.mitigated.date() - return (self.sla_expiration_date - mitigated_date).days - return (self.sla_expiration_date - get_current_date()).days - return None - - def sla_deadline(self): - return self.sla_expiration_date - - def github(self): - try: - return self.github_issue - except GITHUB_Issue.DoesNotExist: - return None - - def has_github_issue(self): - try: - # Attempt to access the github issue if it exists. If not, an exception will be caught - _ = self.github_issue - except GITHUB_Issue.DoesNotExist: - return False - return True - - def github_conf(self): - try: - github_product_key = GITHUB_PKey.objects.get(product=self.test.engagement.product) - github_conf = github_product_key.conf - except: - github_conf = None - return github_conf - - # newer version that can work with prefetching - def github_conf_new(self): - try: - return self.test.engagement.product.github_pkey_set.all()[0].git_conf - except: - return None - - @property - def has_jira_issue(self): - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_issue(self) - - @cached_property - def finding_group(self): - return self.finding_group_set.all().first() - # logger.debug('finding.finding_group: %s', group) - - @cached_property - def has_jira_group_issue(self): - if not self.has_finding_group: - return False - - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_issue(self.finding_group) - - @property - def has_jira_configured(self): - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_configured(self) - - @cached_property - def has_finding_group(self): - return self.finding_group is not None - - def save_no_options(self, *args, **kwargs): - logger.debug("save_no_options") - return self.save(dedupe_option=False, rules_option=False, product_grading_option=False, - issue_updater_option=False, push_to_jira=False, user=None, *args, **kwargs) - - # Check if a mandatory field is empty. If it's the case, fill it with "no given" - def clean(self): - no_check = ["test", "reporter"] - bigfields = ["description"] - for field_obj in self._meta.fields: - field = field_obj.name - if field not in no_check: - val = getattr(self, field) - if not val and field == "title": - setattr(self, field, "No title given") - if not val and field in bigfields: - setattr(self, field, f"No {field} given") - - def severity_display(self): - return self.severity - - def get_breadcrumbs(self): - bc = self.test.get_breadcrumbs() - bc += [{"title": str(self), - "url": reverse("view_finding", args=(self.id,))}] - return bc - - def get_valid_request_response_pairs(self): - empty_value = base64.b64encode(b"") - # Get a list of all req/resp pairs - all_req_resps = self.burprawrequestresponse_set.all() - # Filter away those that do not have any contents - return all_req_resps.exclude( - burpRequestBase64__exact=empty_value, - burpResponseBase64__exact=empty_value, - ) - - def get_report_requests(self): - # Get the list of request response pairs that are non empty - request_response_pairs = self.get_valid_request_response_pairs() - # Determine how many to return - if request_response_pairs.count() >= 3: - return request_response_pairs[0:3] - if request_response_pairs.count() > 0: - return request_response_pairs - return None - - def get_request(self): - # Get the list of request response pairs that are non empty - request_response_pairs = self.get_valid_request_response_pairs() - # Determine what to return - if request_response_pairs.count() > 0: - reqres = request_response_pairs.first() - return base64.b64decode(reqres.burpRequestBase64) - - def get_response(self): - # Get the list of request response pairs that are non empty - request_response_pairs = self.get_valid_request_response_pairs() - # Determine what to return - if request_response_pairs.count() > 0: - reqres = request_response_pairs.first() - res = base64.b64decode(reqres.burpResponseBase64) - # Removes all blank lines - return re.sub(r"\n\s*\n", "\n", res) - - def latest_note(self): - if self.notes.all(): - note = self.notes.all()[0] - return note.date.strftime("%Y-%m-%d %H:%M:%S") + ": " + note.author.get_full_name() + " : " + note.entry - - return "" - - def get_sast_source_file_path_with_link(self): - from dojo.utils import create_bleached_link # noqa: PLC0415 circular import - if self.sast_source_file_path is None: - return None - if self.test.engagement.source_code_management_uri is None: - return escape(self.sast_source_file_path) - link = self.test.engagement.source_code_management_uri + "/" + self.sast_source_file_path - if self.sast_source_line: - link = link + "#L" + str(self.sast_source_line) - return create_bleached_link(link, self.sast_source_file_path) - - def get_file_path_with_link(self): - from dojo.utils import create_bleached_link # noqa: PLC0415 circular import - if self.file_path is None: - return None - if self.test.engagement.source_code_management_uri is None: - return escape(self.file_path) - link = self.get_file_path_with_raw_link() - return create_bleached_link(link, self.file_path) - - def get_scm_type(self): - # extract scm type from product custom field 'scm-type' - - if hasattr(self.test.engagement, "product"): - dojo_meta = DojoMeta.objects.filter(product=self.test.engagement.product, name="scm-type").first() - if dojo_meta: - st = dojo_meta.value.strip() - if st: - return st.lower() - return "" - - def scm_public_prepare_base_link(self, uri): - # scm public (https://scm-domain.org) url template for browse is: - # https://scm-domain.org// - # but when you get repo url for git, its template is: - # https://scm-domain.org//.git - # so to create browser url - git url should be recomposed like below: - - parts_uri = uri.split(".git") - return parts_uri[0] - - def git_public_prepare_scm_link(self, uri, scm_type): - # if commit hash or branch/tag is set for engagement/test - - # hash or branch/tag should be appended to base browser link - intermediate_path = "/blob/" if scm_type in {"github", "gitlab"} else "/src/" - - link = self.scm_public_prepare_base_link(uri) - if self.test.commit_hash: - link += intermediate_path + self.test.commit_hash + "/" + self.file_path - elif self.test.engagement.commit_hash: - link += intermediate_path + self.test.engagement.commit_hash + "/" + self.file_path - elif self.test.branch_tag: - link += intermediate_path + self.test.branch_tag + "/" + self.file_path - elif self.test.engagement.branch_tag: - link += intermediate_path + self.test.engagement.branch_tag + "/" + self.file_path - else: - link += intermediate_path + "master/" + self.file_path - - return link - - def bitbucket_standalone_prepare_scm_base_link(self, uri): - # bitbucket onpremise/standalone url template for browse is: - # https://bb.example.com/projects//repos/ - # but when you get repo url for git, its template is: - # https://bb.example.com/scm//.git - # or for user public repo^ - # https://bb.example.com/users//repos/ - # but when you get repo url for git, its template is: - # https://bb.example.com/scm//.git (username often could be prefixed with ~) - # so to create borwser url - git url should be recomposed like below: - - parts_uri = uri.split(".git") - parts_scm = parts_uri[0].split("/scm/") - parts_project = parts_scm[1].split("/") - project = parts_project[0] - if project.startswith("~"): - return parts_scm[0] + "/users/" + parts_project[0][1:] + "/repos/" + parts_project[1] + "/browse" - return parts_scm[0] + "/projects/" + parts_project[0] + "/repos/" + parts_project[1] + "/browse" - - def bitbucket_standalone_prepare_scm_link(self, uri): - # if commit hash or branch/tag is set for engagement/test - - # hash or barnch/tag should be appended to base browser link - - link = self.bitbucket_standalone_prepare_scm_base_link(uri) - if self.test.commit_hash: - link += "/" + self.file_path + "?at=" + self.test.commit_hash - elif self.test.engagement.commit_hash: - link += "/" + self.file_path + "?at=" + self.test.engagement.commit_hash - elif self.test.branch_tag: - link += "/" + self.file_path + "?at=" + self.test.branch_tag - elif self.test.engagement.branch_tag: - link += "/" + self.file_path + "?at=" + self.test.engagement.branch_tag - else: - link += "/" + self.file_path - - return link - - def get_file_path_with_raw_link(self): - if self.file_path is None: - return None - - link = self.test.engagement.source_code_management_uri - scm_type = self.get_scm_type() - if (self.test.engagement.source_code_management_uri is not None): - if scm_type == "bitbucket-standalone": - link = self.bitbucket_standalone_prepare_scm_link(link) - elif scm_type in {"github", "gitlab", "gitea", "codeberg", "bitbucket"}: - link = self.git_public_prepare_scm_link(link, scm_type) - elif "https://github.com/" in self.test.engagement.source_code_management_uri: - link = self.git_public_prepare_scm_link(link, "github") - else: - link += "/" + self.file_path - else: - link += "/" + self.file_path - - # than - add line part to browser url - if self.line: - if scm_type in {"github", "gitlab", "gitea", "codeberg"} or "https://github.com/" in self.test.engagement.source_code_management_uri: - link = link + "#L" + str(self.line) - elif scm_type == "bitbucket-standalone": - link = link + "#" + str(self.line) - elif scm_type == "bitbucket": - link = link + "#lines-" + str(self.line) - return link - - def get_references_with_links(self): - from dojo.utils import create_bleached_link # noqa: PLC0415 circular import - if self.references is None: - return None - matches = re.findall(r"([\(|\[]?(https?):((//)|(\\\\))+([\w\d:#@%/;$~_?\+-=\\\.&](#!)?)*[\)|\]]?)", self.references) - - processed_matches = [] - for match in matches: - # Check if match isn't already a markdown link - # Only replace the same matches one time, otherwise the links will be corrupted - if not (match[0].startswith("[") or match[0].startswith("(")) and match[0] not in processed_matches: - self.references = self.references.replace(match[0], create_bleached_link(match[0], match[0]), 1) - processed_matches.append(match[0]) - - return self.references - - @cached_property - def vulnerability_ids(self): - # Get vulnerability ids from database and convert to list of strings - vulnerability_ids_model = self.vulnerability_id_set.all() - vulnerability_ids = [vulnerability_id.vulnerability_id for vulnerability_id in vulnerability_ids_model] - - # Synchronize the cve field with the unsaved_vulnerability_ids - # We do this to be as flexible as possible to handle the fields until - # the cve field is not needed anymore and can be removed. - if vulnerability_ids and self.cve: - # Make sure the first entry of the list is the value of the cve field - vulnerability_ids.insert(0, self.cve) - elif not vulnerability_ids and self.cve: - # If there is no list, make one with the value of the cve field - vulnerability_ids = [self.cve] - - # Remove duplicates - return list(dict.fromkeys(vulnerability_ids)) - - @property - def violates_sla(self): - return (self.sla_expiration_date and self.sla_expiration_date < timezone.now().date()) - - def set_hash_code(self, dedupe_option): - from dojo.utils import get_custom_method # noqa: PLC0415 circular import - if hash_method := get_custom_method("FINDING_HASH_METHOD"): - deduplicationLogger.debug("Using custom hash method") - hash_method(self, dedupe_option) - # Finding.save is called once from serializers.py with dedupe_option=False because the finding is not ready yet, for example the endpoints are not built - # It is then called a second time with dedupe_option defaulted to true; now we can compute the hash_code and run the deduplication - elif dedupe_option: - finding_id = self.id if self.id is not None else "unsaved" - if self.hash_code is not None: - deduplicationLogger.debug("Hash_code already computed for finding: %s", finding_id) - else: - self.hash_code = self.compute_hash_code() - deduplicationLogger.debug("Hash_code computed for finding: %s: %s", finding_id, self.hash_code) - - -class FindingAdmin(admin.ModelAdmin): - # TODO: Delete this after the move to Locations - # For efficiency with large databases, display many-to-many fields with raw - # IDs rather than multi-select - raw_id_fields = ( - "endpoints", - ) - - -class Vulnerability_Id(models.Model): - finding = models.ForeignKey(Finding, editable=False, on_delete=models.CASCADE) - vulnerability_id = models.TextField(max_length=50, blank=False, null=False) - - def __str__(self): - return self.vulnerability_id - - def get_absolute_url(self): - return reverse("view_finding", args=[str(self.finding.id)]) - - -class Finding_Group(TimeStampedModel): - - GROUP_BY_OPTIONS = [("component_name", "Component Name"), - ("component_name+component_version", "Component Name + Version"), - ("file_path", "File path"), - ("finding_title", "Finding Title"), - ("vuln_id_from_tool", "Vulnerability ID from Tool")] - - name = models.CharField(max_length=255, blank=False, null=False) - test = models.ForeignKey(Test, on_delete=models.CASCADE) - findings = models.ManyToManyField(Finding) - creator = models.ForeignKey(Dojo_User, on_delete=models.RESTRICT) - - def __str__(self): - return self.name - - @property - def has_jira_issue(self): - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_issue(self) - - @cached_property - def severity(self): - if not self.findings.all(): - return None - max_number_severity = max(Finding.get_number_severity(find.severity) for find in self.findings.all()) - return Finding.get_severity(max_number_severity) - - @cached_property - def components(self): - components: dict[str, set[str | None]] = {} - for finding in self.findings.all(): - if finding.component_name is not None: - components.setdefault(finding.component_name, set()).add(finding.component_version) - return "; ".join(f"""{name}: {", ".join(map(str, versions))}""" for name, versions in components.items()) - - @property - def age(self): - if not self.findings.all(): - return None - - return max(find.age for find in self.findings.all()) - - @cached_property - def sla_days_remaining_internal(self): - if not self.findings.all(): - return None - - return min([find.sla_days_remaining() for find in self.findings.all() if find.sla_days_remaining()], default=None) - - def sla_days_remaining(self): - return self.sla_days_remaining_internal - - def sla_deadline(self): - if not self.findings.all(): - return None - - return min([find.sla_deadline() for find in self.findings.all() if find.sla_deadline()], default=None) - - def status(self): - if not self.findings.all(): - return None - - if any(find.active for find in self.findings.all()): - return "Active" - - if all(find.is_mitigated for find in self.findings.all()): - return "Mitigated" - - return "Inactive" - - @cached_property - def mitigated(self): - return all(find.mitigated is not None for find in self.findings.all()) - - def get_sla_start_date(self): - return min(find.get_sla_start_date() for find in self.findings.all()) - - def get_absolute_url(self): - return reverse("view_test", args=[str(self.test.id)]) - - class Meta: - ordering = ["id"] - - -class Finding_Template(models.Model): - title = models.TextField(max_length=1000) - cwe = models.IntegerField(default=None, null=True, blank=True) - cve = models.CharField(max_length=50, - null=True, - blank=False, - verbose_name="Vulnerability Id", - help_text="An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.") - cvssv3 = models.TextField(help_text=_("Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this finding."), validators=[cvss3_validator], max_length=117, null=True, verbose_name=_("CVSS v3 vector")) - cvssv3_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv3 score")) - cvssv4 = models.TextField(help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding."), validators=[cvss4_validator], max_length=255, null=True, verbose_name=_("CVSS4 vector")) - cvssv4_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv4 score")) - - severity = models.CharField(max_length=200, null=True, blank=True) - description = models.TextField(null=True, blank=True) - mitigation = models.TextField(null=True, blank=True) - impact = models.TextField(null=True, blank=True) - references = models.TextField(null=True, blank=True, db_column="refs") - last_used = models.DateTimeField(null=True, editable=False) - numerical_severity = models.CharField(max_length=4, null=True, blank=True, editable=False) - - # Remediation planning fields - fix_available = models.BooleanField(null=True, blank=True, help_text=_("Indicates if a fix is available for this vulnerability type")) - fix_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Version where fix is available")) - planned_remediation_version = models.CharField(max_length=99, null=True, blank=True, help_text=_("Target version for remediation")) - effort_for_fixing = models.CharField(max_length=99, null=True, blank=True, help_text=_("Effort estimate for fixing (e.g., Low/Medium/High)")) - - # Technical details fields - steps_to_reproduce = models.TextField(null=True, blank=True, help_text=_("Standard reproduction steps for this vulnerability type")) - severity_justification = models.TextField(null=True, blank=True, help_text=_("Explanation of why this severity level is appropriate")) - component_name = models.CharField(max_length=500, null=True, blank=True, help_text=_("Affected component name (e.g., library name)")) - component_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Affected component version")) - - # Notes field (single note content, not a list) - notes = models.TextField(null=True, blank=True, help_text=_("Note content to add when applying this template")) - - # String-based list fields (newline-separated) - vulnerability_ids_text = models.TextField(null=True, blank=True, help_text=_("Vulnerability IDs (one per line)")) - endpoints_text = models.TextField(null=True, blank=True, help_text=_("Endpoint URLs (one per line)")) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.")) - - SEVERITIES = {"Info": 4, "Low": 3, "Medium": 2, - "High": 1, "Critical": 0} - - class Meta: - ordering = ["-cwe"] - - def __str__(self): - return self.title - - def get_absolute_url(self): - return reverse("edit_template", args=[str(self.id)]) - - def get_breadcrumbs(self): - return [{"title": str(self), - "url": reverse("view_template", args=(self.id,))}] - - @property - def vulnerability_ids(self): - """Parse vulnerability IDs from TextField string (newline-separated).""" - vulnerability_ids = [] - - # Get from the TextField - if self.vulnerability_ids_text: - # Parse newline-separated string, remove empty lines - vulnerability_ids = [line.strip() for line in self.vulnerability_ids_text.split("\n") if line.strip()] - - # Synchronize the cve field with the vulnerability_ids - # We do this to be as flexible as possible to handle the fields until - # the cve field is not needed anymore and can be removed. - if vulnerability_ids and self.cve and self.cve not in vulnerability_ids: - # Make sure the first entry of the list is the value of the cve field - vulnerability_ids.insert(0, self.cve) - elif not vulnerability_ids and self.cve: - # If there is no list, make one with the value of the cve field - vulnerability_ids = [self.cve] - - # Remove duplicates - return list(dict.fromkeys(vulnerability_ids)) - - @property - def endpoints(self): - """Parse endpoint URLs from TextField string (newline-separated).""" - if not self.endpoints_text: - return [] - # Parse newline-separated string, remove empty lines - return [line.strip() for line in self.endpoints_text.split("\n") if line.strip()] +from dojo.finding.models import ( # noqa: E402 -- re-export; class-body FKs below reference these + Finding, + Finding_Group, # noqa: F401 -- re-export + Finding_Template, + Vulnerability_Id, # noqa: F401 -- re-export +) class Check_List(models.Model): @@ -3626,8 +2165,8 @@ def __str__(self): # The audit system is configured in DojoAppConfig.ready() to ensure all models are loaded -from dojo.utils import ( # noqa: E402 # there is issue due to a circular import - parse_cvss_data, +from dojo.utils import ( # noqa: E402 + parse_cvss_data, # noqa: F401 -- backward compat re-export; side-effect loads dojo.utils → dojo.location models ) tagulous.admin.register(Product.tags) @@ -3660,7 +2199,6 @@ def __str__(self): admin.site.register(Languages) admin.site.register(Language_Type) admin.site.register(App_Analysis) -admin.site.register(Finding, FindingAdmin) admin.site.register(FileUpload) admin.site.register(FileAccessToken) admin.site.register(Risk_Acceptance) @@ -3708,12 +2246,9 @@ def __str__(self): admin.site.register(Report_Type) admin.site.register(DojoMeta) admin.site.register(Development_Environment) -admin.site.register(Finding_Template) -admin.site.register(Vulnerability_Id) admin.site.register(BurpRawRequestResponse) admin.site.register(Announcement) admin.site.register(UserAnnouncement) admin.site.register(BannerConf) admin.site.register(Tool_Product_History) admin.site.register(General_Survey) -admin.site.register(Finding_Group) From 46d7502e361e82971f74456e31c0cc9aff46a464 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 22:32:20 +0200 Subject: [PATCH 2/5] refactor(finding): move forms + UI filters into dojo/finding/ui/ [finding Phase 3,4] --- dojo/api_v2/views.py | 6 +- dojo/filters.py | 1041 +--------------- dojo/finding/ui/__init__.py | 0 dojo/finding/ui/filters.py | 1100 +++++++++++++++++ dojo/finding/ui/forms.py | 1086 ++++++++++++++++ dojo/finding/views.py | 22 +- dojo/finding_group/views.py | 4 +- dojo/forms.py | 1075 +--------------- dojo/metrics/utils.py | 6 +- dojo/product/ui/views.py | 2 + dojo/reports/views.py | 4 +- dojo/reports/widgets.py | 2 + dojo/search/views.py | 2 +- dojo/test/ui/views.py | 2 +- unittests/test_filter_finding_mitigation.py | 3 +- .../test_finding_group_filter_context.py | 2 +- unittests/test_test_type_active_toggle.py | 2 +- 17 files changed, 2234 insertions(+), 2125 deletions(-) create mode 100644 dojo/finding/ui/__init__.py create mode 100644 dojo/finding/ui/filters.py create mode 100644 dojo/finding/ui/forms.py diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 653dfaf2d8d..bdcaa185f5d 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -59,12 +59,14 @@ ApiRiskAcceptanceFilter, ApiTemplateFindingFilter, ApiUserFilter, - ReportFindingFilter, - ReportFindingFilterWithoutObjectLookups, ) from dojo.finding.queries import ( get_authorized_findings, ) +from dojo.finding.ui.filters import ( + ReportFindingFilter, + ReportFindingFilterWithoutObjectLookups, +) from dojo.finding.views import ( duplicate_cluster, reset_finding_duplicate_status_internal, diff --git a/dojo/filters.py b/dojo/filters.py index 55afc778196..ba68d7a180a 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -6,7 +6,6 @@ import six import tagulous -from django import forms from django.apps import apps from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -18,10 +17,8 @@ BooleanFilter, CharFilter, DateFilter, - DateFromToRangeFilter, DateTimeFilter, FilterSet, - ModelChoiceFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter, NumberFilter, @@ -50,12 +47,8 @@ VERIFIED_FINDINGS_QUERY, WAS_ACCEPTED_FINDINGS_QUERY, ) -from dojo.finding.queries import get_authorized_findings_for_queryset -from dojo.finding_group.queries import get_authorized_finding_groups_for_queryset from dojo.labels import get_labels -from dojo.location.status import FindingLocationStatus from dojo.models import ( - EFFORT_FOR_FIXING_CHOICES, SEVERITY_CHOICES, App_Analysis, ChoiceQuestion, @@ -67,7 +60,6 @@ Engagement, Engagement_Survey, Finding, - Finding_Group, Finding_Template, Note_Type, Product, @@ -75,17 +67,13 @@ Question, Risk_Acceptance, Test, - Test_Type, TextQuestion, User, Vulnerability_Id, ) from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types -from dojo.risk_acceptance.queries import get_authorized_risk_acceptances -from dojo.test.queries import get_authorized_tests -from dojo.user.queries import get_authorized_users -from dojo.utils import get_system_setting, get_visible_scan_types, is_finding_groups_enabled, truncate_timezone_aware +from dojo.utils import get_system_setting, is_finding_groups_enabled, truncate_timezone_aware logger = logging.getLogger(__name__) @@ -1240,706 +1228,6 @@ def filter_percentage(self, queryset, name, value): return queryset.filter(**lookup_kwargs) -class FindingFilterHelper(FilterSet): - title = CharFilter(lookup_expr="icontains") - date = DateRangeFilter(field_name="date", label="Date Discovered") - on = DateFilter(field_name="date", lookup_expr="exact", label="Discovered On") - before = DateFilter(field_name="date", lookup_expr="lt", label="Discovered Before") - after = DateFilter(field_name="date", lookup_expr="gt", label="Discovered After") - last_reviewed = DateRangeFilter() - last_status_update = DateRangeFilter() - cwe = MultipleChoiceFilter(choices=[]) - vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") - severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) - duplicate = ReportBooleanFilter() - is_mitigated = ReportBooleanFilter() - fix_available = ReportBooleanFilter() - mitigation = CharFilter(lookup_expr="icontains") - mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") - mitigated = DateRangeFilter(field_name="mitigated", label="Mitigated Date") - mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", label="Mitigated On", method="filter_mitigated_on") - mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt", label="Mitigated Before") - mitigated_after = DateTimeFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After", method="filter_mitigated_after") - planned_remediation_date = DateRangeOmniFilter() - planned_remediation_version = CharFilter(lookup_expr="icontains", label=_("Planned remediation version")) - file_path = CharFilter(lookup_expr="icontains") - param = CharFilter(lookup_expr="icontains") - payload = CharFilter(lookup_expr="icontains") - test__test_type = ModelMultipleChoiceFilter(queryset=Test_Type.objects.all(), label="Test Type") - service = CharFilter(lookup_expr="icontains") - test__engagement__version = CharFilter(lookup_expr="icontains", label="Engagement Version") - test__version = CharFilter(lookup_expr="icontains", label="Test Version") - risk_acceptance = ReportRiskAcceptanceFilter(label="Risk Accepted") - effort_for_fixing = MultipleChoiceFilter(choices=EFFORT_FOR_FIXING_CHOICES) - test_import_finding_action__test_import = NumberFilter(widget=HiddenInput()) - status = FindingStatusFilter(label="Status") - test__engagement__product__lifecycle = MultipleChoiceFilter( - choices=Product.LIFECYCLE_CHOICES, - label=labels.ASSET_LIFECYCLE_LABEL) - if settings.V3_FEATURE_LOCATIONS: - location_status = MultipleChoiceFilter( - field_name="locations__status", - choices=FindingLocationStatus.choices, - help_text="Status of the Location from the Findings relationship", - ) - endpoints__host = CharFilter( - field_name="locations__location__url__host", method="filter_endpoints_host", label="Endpoint Host", - ) - endpoints = NumberFilter(field_name="locations__location", method="filter_endpoints", widget=HiddenInput()) - - def filter_endpoints_host(self, queryset, name, value): - return filter_endpoints_host_base( - queryset, - name, - value, - endpoint_id=self.data.get("endpoints"), - statuses=self.data.getlist("location_status"), - ) - - def filter_endpoints(self, queryset, name, value): - return filter_endpoints_base( - queryset, - name, - value, - statuses=self.data.getlist("location_status"), - host=self.data.get("endpoints__host"), - ) - else: - # TODO: Delete this after the move to Locations - endpoints__host = CharFilter(lookup_expr="icontains", label="Endpoint Host") - endpoints = NumberFilter(widget=HiddenInput()) - - has_component = BooleanFilter( - field_name="component_name", - lookup_expr="isnull", - exclude=True, - label="Has Component") - has_notes = BooleanFilter( - field_name="notes", - lookup_expr="isnull", - exclude=True, - label="Has notes") - - if is_finding_groups_enabled(): - has_finding_group = BooleanFilter( - field_name="finding_group", - lookup_expr="isnull", - exclude=True, - label="Is Grouped") - - if get_system_setting("enable_jira"): - has_jira_issue = BooleanFilter( - field_name="jira_issue", - lookup_expr="isnull", - exclude=True, - label="Has JIRA") - jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation", label="JIRA Creation") - jira_change = DateRangeFilter(field_name="jira_issue__jira_change", label="JIRA Updated") - jira_issue__jira_key = CharFilter(field_name="jira_issue__jira_key", lookup_expr="icontains", label="JIRA issue") - - if is_finding_groups_enabled(): - has_jira_group_issue = BooleanFilter( - field_name="finding_group__jira_issue", - lookup_expr="isnull", - exclude=True, - label="Has Group JIRA") - has_any_jira_issue = FindingHasJIRAFilter( - label="Has Any JIRA Issue", - help_text="Matches JIRA issues linked to the finding itself or to the finding's group.", - ) - - outside_of_sla = FindingSLAFilter(label="Outside of SLA") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - epss_score = PercentageFilter(field_name="epss_score", label="EPSS score") - epss_score_range = PercentageRangeFilter( - field_name="epss_score", - label="EPSS score range", - help_text=( - "The range of EPSS score percentages to filter on; the left input is a lower bound, " - "the right is an upper bound. Leaving one empty will skip that bound (e.g., leaving " - "the lower bound input empty will filter only on the upper bound -- filtering on " - '"less than or equal").' - )) - epss_percentile = PercentageFilter(field_name="epss_percentile", label="EPSS percentile") - epss_percentile_range = PercentageRangeFilter( - field_name="epss_percentile", - label="EPSS percentile range", - help_text=( - "The range of EPSS percentiles to filter on; the left input is a lower bound, the right " - "is an upper bound. Leaving one empty will skip that bound (e.g., leaving the lower bound " - 'input empty will filter only on the upper bound -- filtering on "less than or equal").' - )) - kev_date = DateFilter(field_name="kev_date", lookup_expr="exact", label="Added to KEV On") - kev_before = DateFilter(field_name="kev_date", lookup_expr="lt", label="Added to KEV Before") - kev_after = DateFilter(field_name="kev_date", lookup_expr="gt", label="Added to KEV After") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("numerical_severity", "numerical_severity"), - ("date", "date"), - ("mitigated", "mitigated"), - ("fix_available", "fix_available"), - ("risk_acceptance__created__date", - "risk_acceptance__created__date"), - ("last_reviewed", "last_reviewed"), - ("planned_remediation_date", "planned_remediation_date"), - ("planned_remediation_version", "planned_remediation_version"), - ("title", "title"), - ("test__engagement__product__name", - "test__engagement__product__name"), - ("service", "service"), - ("sla_age_days", "sla_age_days"), - ("epss_score", "epss_score"), - ("epss_percentile", "epss_percentile"), - ("known_exploited", "known_exploited"), - ("ransomware_used", "ransomware_used"), - ("kev_date", "kev_date"), - ), - field_labels={ - "numerical_severity": "Severity", - "date": "Date", - "risk_acceptance__created__date": "Acceptance Date", - "mitigated": "Mitigated Date", - "fix_available": "Fix Available", - "title": "Finding Name", - "test__engagement__product__name": labels.ASSET_FILTERS_NAME_LABEL, - "epss_score": "EPSS Score", - "epss_percentile": "EPSS Percentile", - "known_exploited": "Known Exploited", - "ransomware_used": "Ransomware Used", - "kev_date": "Date added to KEV", - "sla_age_days": "SLA age (days)", - "planned_remediation_date": "Planned Remediation", - "planned_remediation_version": "Planned remediation version", - }, - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if "test__test_type" in self.form.fields: - self.form.fields["test__test_type"].queryset = get_visible_scan_types() - - def set_date_fields(self, *args: list, **kwargs: dict): - date_input_widget = forms.DateInput(attrs={"class": "datepicker", "placeholder": "YYYY-MM-DD"}, format="%Y-%m-%d") - self.form.fields["on"].widget = date_input_widget - self.form.fields["before"].widget = date_input_widget - self.form.fields["after"].widget = date_input_widget - self.form.fields["kev_date"].widget = date_input_widget - self.form.fields["kev_before"].widget = date_input_widget - self.form.fields["kev_after"].widget = date_input_widget - self.form.fields["mitigated_on"].widget = date_input_widget - self.form.fields["mitigated_before"].widget = date_input_widget - self.form.fields["mitigated_after"].widget = date_input_widget - self.form.fields["cwe"].choices = cwe_options(self.queryset) - - def filter_mitigated_after(self, queryset, name, value): - if value.hour == 0 and value.minute == 0 and value.second == 0: - value = value.replace(hour=23, minute=59, second=59) - - return queryset.filter(mitigated__gt=value) - - def filter_mitigated_on(self, queryset, name, value): - if value.hour == 0 and value.minute == 0 and value.second == 0: - # we have a simple date without a time, lets get a range from this morning to tonight at 23:59:59:999 - nextday = value + timedelta(days=1) - return queryset.filter(mitigated__gte=value, mitigated__lt=nextday) - - return queryset.filter(mitigated=value) - - def filter_mitigation_available(self, queryset, name, value): - if value: - return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") - return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) - - -def get_finding_group_queryset_for_context(pid=None, eid=None, tid=None): - """ - Helper function to build finding group queryset based on context hierarchy. - Context priority: test > engagement > product > global - - Args: - pid: Product ID (least specific) - eid: Engagement ID - tid: Test ID (most specific) - - Returns: - QuerySet of Finding_Group filtered by context - - """ - if tid is not None: - # Most specific: filter by test - return Finding_Group.objects.filter(test_id=tid).only("id", "name") - if eid is not None: - # Filter by engagement's tests - return Finding_Group.objects.filter(test__engagement_id=eid).only("id", "name") - if pid is not None: - # Filter by product's tests - return Finding_Group.objects.filter(test__engagement__product_id=pid).only("id", "name") - # Global: return all (authorization will be applied separately) - return Finding_Group.objects.all().only("id", "name") - - -class FindingFilterWithoutObjectLookups(FindingFilterHelper, FindingTagStringFilter): - test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) - test__engagement__product = NumberFilter(widget=HiddenInput()) - reporter = CharFilter( - field_name="reporter__username", - lookup_expr="iexact", - label="Reporter Username", - help_text="Search for Reporter names that are an exact match") - reporter_contains = CharFilter( - field_name="reporter__username", - lookup_expr="icontains", - label="Reporter Username Contains", - help_text="Search for Reporter names that contain a given pattern") - reviewers = CharFilter( - field_name="reviewers__username", - lookup_expr="iexact", - label="Reviewer Username", - help_text="Search for Reviewer names that are an exact match") - reviewers_contains = CharFilter( - field_name="reviewers__username", - lookup_expr="icontains", - label="Reviewer Username Contains", - help_text="Search for Reviewer usernames that contain a given pattern") - test__engagement__product__prod_type__name = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="iexact", - label=labels.ORG_FILTERS_NAME_LABEL, - help_text=labels.ORG_FILTERS_NAME_HELP) - test__engagement__product__prod_type__name_contains = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="icontains", - label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) - test__engagement__product__name = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="iexact", - label=labels.ASSET_FILTERS_NAME_LABEL, - help_text=labels.ASSET_FILTERS_NAME_HELP) - test__engagement__product__name_contains = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="icontains", - label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) - test__engagement__name = CharFilter( - field_name="test__engagement__name", - lookup_expr="iexact", - label="Engagement Name", - help_text="Search for Engagement names that are an exact match") - test__engagement__name_contains = CharFilter( - field_name="test__engagement__name", - lookup_expr="icontains", - label="Engagement name Contains", - help_text="Search for Engagement names that contain a given pattern") - test__name = CharFilter( - field_name="test__title", - lookup_expr="iexact", - label="Test Name", - help_text="Search for Test names that are an exact match") - test__name_contains = CharFilter( - field_name="test__title", - lookup_expr="icontains", - label="Test name Contains", - help_text="Search for Test names that contain a given pattern") - - if is_finding_groups_enabled(): - finding_group__name = CharFilter( - field_name="finding_group__name", - lookup_expr="iexact", - label="Finding Group Name", - help_text="Search for Finding Group names that are an exact match") - finding_group__name_contains = CharFilter( - field_name="finding_group__name", - lookup_expr="icontains", - label="Finding Group Name Contains", - help_text="Search for Finding Group names that contain a given pattern") - - class Meta: - model = Finding - fields = get_finding_filterset_fields(filter_string_matching=True) - - exclude = ["url", "description", "mitigation", "impact", - "endpoints", "references", - "thread_id", "notes", "scanner_confidence", - "numerical_severity", "line", "duplicate_finding", - "hash_code", "reviewers", "created", "files", - "sla_start_date", "sla_expiration_date", "cvssv3", - "severity_justification", "steps_to_reproduce"] - - def __init__(self, *args, **kwargs): - self.user = None - self.pid = None - self.eid = None - self.tid = None - if "user" in kwargs: - self.user = kwargs.pop("user") - - if "pid" in kwargs: - self.pid = kwargs.pop("pid") - if "eid" in kwargs: - self.eid = kwargs.pop("eid") - if "tid" in kwargs: - self.tid = kwargs.pop("tid") - super().__init__(*args, **kwargs) - # Set some date fields - self.set_date_fields(*args, **kwargs) - # Don't show the product/engagement/test filter fields when in specific context - if self.tid or self.eid or self.pid: - if "test__engagement__product__name" in self.form.fields: - del self.form.fields["test__engagement__product__name"] - if "test__engagement__product__name_contains" in self.form.fields: - del self.form.fields["test__engagement__product__name_contains"] - if "test__engagement__product__prod_type__name" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type__name"] - if "test__engagement__product__prod_type__name_contains" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type__name_contains"] - # Also hide engagement and test fields if in test or engagement context - if self.tid: - if "test__engagement__name" in self.form.fields: - del self.form.fields["test__engagement__name"] - if "test__engagement__name_contains" in self.form.fields: - del self.form.fields["test__engagement__name_contains"] - if "test__name" in self.form.fields: - del self.form.fields["test__name"] - if "test__name_contains" in self.form.fields: - del self.form.fields["test__name_contains"] - elif self.eid: - if "test__engagement__name" in self.form.fields: - del self.form.fields["test__engagement__name"] - if "test__engagement__name_contains" in self.form.fields: - del self.form.fields["test__engagement__name_contains"] - - -class FindingFilter(FindingFilterHelper, FindingTagFilter): - reporter = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none()) - reviewers = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none()) - test__engagement__product__prod_type = ModelMultipleChoiceFilter( - queryset=Product_Type.objects.none(), - label=labels.ORG_FILTERS_LABEL) - test__engagement__product = ModelMultipleChoiceFilter( - queryset=Product.objects.none(), - label=labels.ASSET_FILTERS_LABEL) - test__engagement = ModelMultipleChoiceFilter( - queryset=Engagement.objects.none(), - label="Engagement") - test = ModelMultipleChoiceFilter( - queryset=Test.objects.none(), - label="Test") - - if is_finding_groups_enabled(): - finding_group = ModelMultipleChoiceFilter( - queryset=Finding_Group.objects.none(), - label="Finding Group") - - class Meta: - model = Finding - fields = get_finding_filterset_fields() - - exclude = ["url", "description", "mitigation", "impact", - "endpoints", "references", - "thread_id", "notes", "scanner_confidence", - "numerical_severity", "line", "duplicate_finding", - "hash_code", "reviewers", "created", "files", - "sla_start_date", "sla_expiration_date", "cvssv3", - "severity_justification", "steps_to_reproduce"] - - def __init__(self, *args, **kwargs): - self.user = None - self.pid = None - self.eid = None - self.tid = None - if "user" in kwargs: - self.user = kwargs.pop("user") - - if "pid" in kwargs: - self.pid = kwargs.pop("pid") - if "eid" in kwargs: - self.eid = kwargs.pop("eid") - if "tid" in kwargs: - self.tid = kwargs.pop("tid") - super().__init__(*args, **kwargs) - # Set some date fields - self.set_date_fields(*args, **kwargs) - # Don't show the product filter on the product finding view - self.set_related_object_fields(*args, **kwargs) - - def set_related_object_fields(self, *args: list, **kwargs: dict): - # Use helper to get contextual finding group queryset - finding_group_query = get_finding_group_queryset_for_context( - pid=self.pid, - eid=self.eid, - tid=self.tid, - ) - - # Filter by most specific context: test > engagement > product - if self.tid is not None: - # Test context: filter finding groups by test - if "test__engagement__product" in self.form.fields: - del self.form.fields["test__engagement__product"] - if "test__engagement__product__prod_type" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type"] - if "test__engagement" in self.form.fields: - del self.form.fields["test__engagement"] - if "test" in self.form.fields: - del self.form.fields["test"] - elif self.eid is not None: - # Engagement context: filter finding groups by engagement - if "test__engagement__product" in self.form.fields: - del self.form.fields["test__engagement__product"] - if "test__engagement__product__prod_type" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type"] - if "test__engagement" in self.form.fields: - del self.form.fields["test__engagement"] - # Filter tests by engagement - get_authorized_tests doesn't support engagement param - engagement = Engagement.objects.filter(id=self.eid).select_related("product").first() - if engagement: - self.form.fields["test"].queryset = get_authorized_tests("view", product=engagement.product).filter(engagement_id=self.eid).prefetch_related("test_type") - elif self.pid is not None: - # Product context: filter finding groups by product - if "test__engagement__product" in self.form.fields: - del self.form.fields["test__engagement__product"] - if "test__engagement__product__prod_type" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type"] - # TODO: add authorized check to be sure - if "test__engagement" in self.form.fields: - self.form.fields["test__engagement"].queryset = Engagement.objects.filter( - product_id=self.pid, - ).all() - if "test" in self.form.fields: - self.form.fields["test"].queryset = get_authorized_tests("view", product=self.pid).prefetch_related("test_type") - else: - # Global context: show all authorized finding groups - self.form.fields[ - "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") - self.form.fields["test__engagement"].queryset = get_authorized_engagements("view") - if "test" in self.form.fields: - del self.form.fields["test"] - - if self.form.fields.get("test__engagement__product"): - self.form.fields["test__engagement__product"].queryset = get_authorized_products("view") - if self.form.fields.get("finding_group", None): - self.form.fields["finding_group"].queryset = get_authorized_finding_groups_for_queryset("view", finding_group_query, user=self.user) - self.form.fields["reporter"].queryset = get_authorized_users("view") - self.form.fields["reviewers"].queryset = self.form.fields["reporter"].queryset - - -class FindingGroupsFilter(FilterSet): - name = CharFilter(lookup_expr="icontains", label="Name") - severity = ChoiceFilter( - choices=[ - ("Low", "Low"), - ("Medium", "Medium"), - ("High", "High"), - ("Critical", "Critical"), - ], - label="Min Severity", - ) - engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") - product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label=labels.ASSET_LABEL) - - class Meta: - model = Finding - fields = ["name", "severity", "engagement", "product"] - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop("user", None) - self.pid = kwargs.pop("pid", None) - super().__init__(*args, **kwargs) - self.set_related_object_fields() - - def set_related_object_fields(self): - if self.pid is not None: - self.form.fields["engagement"].queryset = Engagement.objects.filter(product_id=self.pid) - if "product" in self.form.fields: - del self.form.fields["product"] - else: - self.form.fields["product"].queryset = get_authorized_products("view") - self.form.fields["engagement"].queryset = get_authorized_engagements("view") - - -class AcceptedFindingFilter(FindingFilter): - risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date") - risk_acceptance__owner = ModelMultipleChoiceFilter( - queryset=Dojo_User.objects.none(), - label="Risk Acceptance Owner") - risk_acceptance = ModelMultipleChoiceFilter( - queryset=Risk_Acceptance.objects.none(), - label="Accepted By") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields["risk_acceptance__owner"].queryset = get_authorized_users("view") - self.form.fields["risk_acceptance"].queryset = get_authorized_risk_acceptances("edit") - - -class AcceptedFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups): - risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date") - risk_acceptance__owner = CharFilter( - field_name="risk_acceptance__owner__username", - lookup_expr="iexact", - label="Risk Acceptance Owner Username", - help_text="Search for Risk Acceptance Owners username that are an exact match") - risk_acceptance__owner_contains = CharFilter( - field_name="risk_acceptance__owner__username", - lookup_expr="icontains", - label="Risk Acceptance Owner Username Contains", - help_text="Search for Risk Acceptance Owners username that contain a given pattern") - risk_acceptance__name = CharFilter( - field_name="risk_acceptance__name", - lookup_expr="iexact", - label="Risk Acceptance Name", - help_text="Search for Risk Acceptance name that are an exact match") - risk_acceptance__name_contains = CharFilter( - field_name="risk_acceptance__name", - lookup_expr="icontains", - label="Risk Acceptance Name", - help_text="Search for Risk Acceptance name contain a given pattern") - - -class SimilarFindingHelper(FilterSet): - hash_code = MultipleChoiceFilter() - vulnerability_ids = CharFilter(method=custom_vulnerability_id_filter, label="Vulnerability Ids") - - def update_data(self, data: dict, *args: list, **kwargs: dict): - # if filterset is bound, use initial values as defaults - # because of this, we can't rely on the self.form.has_changed - self.has_changed = True - if not data and self.finding: - # get a mutable copy of the QueryDict - data = data.copy() - - data["vulnerability_ids"] = ",".join(self.finding.vulnerability_ids) - data["cwe"] = self.finding.cwe - data["file_path"] = self.finding.file_path - data["line"] = self.finding.line - data["unique_id_from_tool"] = self.finding.unique_id_from_tool - data["test__test_type"] = self.finding.test.test_type - data["test__engagement__product"] = self.finding.test.engagement.product - data["test__engagement__product__prod_type"] = self.finding.test.engagement.product.prod_type - - self.has_changed = False - - def set_hash_codes(self, *args: list, **kwargs: dict): - if self.finding and self.finding.hash_code: - self.form.fields["hash_code"] = forms.MultipleChoiceField(choices=[(self.finding.hash_code, self.finding.hash_code[:24] + "...")], required=False, initial=[]) - - def filter_queryset(self, *args: list, **kwargs: dict): - queryset = super().filter_queryset(*args, **kwargs) - queryset = get_authorized_findings_for_queryset("view", queryset, self.user) - return queryset.exclude(pk=self.finding.pk) - - -class SimilarFindingFilter(FindingFilter, SimilarFindingHelper): - class Meta(FindingFilter.Meta): - model = Finding - # slightly different fields from FindingFilter, but keep the same ordering for UI consistency - fields = get_finding_filterset_fields(similar=True) - - def __init__(self, data=None, *args, **kwargs): - self.user = None - if "user" in kwargs: - self.user = kwargs.pop("user") - self.finding = None - if "finding" in kwargs: - self.finding = kwargs.pop("finding") - self.update_data(data, *args, **kwargs) - super().__init__(data, *args, **kwargs) - self.set_hash_codes(*args, **kwargs) - - -class SimilarFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups, SimilarFindingHelper): - class Meta(FindingFilterWithoutObjectLookups.Meta): - model = Finding - # slightly different fields from FindingFilter, but keep the same ordering for UI consistency - fields = get_finding_filterset_fields(similar=True, filter_string_matching=True) - - def __init__(self, data=None, *args, **kwargs): - self.user = None - if "user" in kwargs: - self.user = kwargs.pop("user") - self.finding = None - if "finding" in kwargs: - self.finding = kwargs.pop("finding") - self.update_data(data, *args, **kwargs) - super().__init__(data, *args, **kwargs) - self.set_hash_codes(*args, **kwargs) - - -class TemplateFindingFilter(DojoFilter): - title = CharFilter(lookup_expr="icontains") - cwe = MultipleChoiceFilter(choices=[]) - severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) - - tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - queryset=Finding.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") - - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Finding.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("cwe", "cwe"), - ("title", "title"), - ("numerical_severity", "numerical_severity"), - ), - field_labels={ - "numerical_severity": "Severity", - }, - ) - - class Meta: - model = Finding_Template - exclude = ["description", "mitigation", "impact", - "references", "numerical_severity"] - - not_test__tags = ModelMultipleChoiceFilter( - field_name="test__tags__name", - to_field_name="name", - exclude=True, - label="Test without tags", - queryset=Test.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_test__engagement__tags = ModelMultipleChoiceFilter( - field_name="test__engagement__tags__name", - to_field_name="name", - exclude=True, - label="Engagement without tags", - queryset=Engagement.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_test__engagement__product__tags = ModelMultipleChoiceFilter( - field_name="test__engagement__product__tags__name", - to_field_name="name", - exclude=True, - label=labels.ASSET_FILTERS_WITHOUT_TAGS_LABEL, - queryset=Product.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields["cwe"].choices = cwe_options(self.queryset) - - class ApiTemplateFindingFilter(DojoFilter): tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") tags = CharFieldInFilter( @@ -1967,66 +1255,6 @@ class Meta: "mitigation"] -class MetricsFindingFilter(FindingFilter): - start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) - end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) - date = MetricsDateRangeFilter() - vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") - - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Endpoint.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - - def __init__(self, *args, **kwargs): - if args[0]: - if args[0].get("start_date", "") or args[0].get("end_date", ""): - args[0]._mutable = True - args[0]["date"] = 8 - args[0]._mutable = False - - super().__init__(*args, **kwargs) - - class Meta(FindingFilter.Meta): - model = Finding - fields = get_finding_filterset_fields(metrics=True) - - -class MetricsFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups): - start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) - end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) - date = MetricsDateRangeFilter() - vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") - - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Endpoint.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - - def __init__(self, *args, **kwargs): - if args[0]: - if args[0].get("start_date", "") or args[0].get("end_date", ""): - args[0]._mutable = True - args[0]["date"] = 8 - args[0]._mutable = False - - super().__init__(*args, **kwargs) - - class Meta(FindingFilterWithoutObjectLookups.Meta): - model = Finding - fields = get_finding_filterset_fields(metrics=True, filter_string_matching=True) - - class MetricsEndpointFilterHelper(FilterSet): start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) @@ -2645,273 +1873,6 @@ class Meta: exclude = ["product"] -class ReportFindingFilterHelper(FilterSet): - title = CharFilter(lookup_expr="icontains", label="Name") - date = DateFromToRangeFilter(field_name="date", label="Date Discovered") - date_recent = DateRangeFilter(field_name="date", label="Relative Date") - severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) - active = ReportBooleanFilter() - is_mitigated = ReportBooleanFilter() - mitigated = DateRangeFilter(label="Mitigated Date") - verified = ReportBooleanFilter() - false_p = ReportBooleanFilter(label="False Positive") - risk_acceptance = ReportRiskAcceptanceFilter(label="Risk Accepted") - duplicate = ReportBooleanFilter() - out_of_scope = ReportBooleanFilter() - outside_of_sla = FindingSLAFilter(label="Outside of SLA") - file_path = CharFilter(lookup_expr="icontains") - mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") - - o = OrderingFilter( - fields=( - ("title", "title"), - ("date", "date"), - ("fix_available", "fix_available"), - ("numerical_severity", "numerical_severity"), - ("epss_score", "epss_score"), - ("epss_percentile", "epss_percentile"), - ("test__engagement__product__name", "test__engagement__product__name"), - ), - ) - - class Meta: - model = Finding - # exclude sonarqube issue as by default it will show all without checking permissions - exclude = ["date", "cwe", "url", "description", "mitigation", "impact", - "references", "sonarqube_issue", "duplicate_finding", - "thread_id", "notes", "inherited_tags", "endpoints", - "numerical_severity", "reporter", "last_reviewed", - "jira_creation", "jira_change", "files"] - - def filter_mitigation_available(self, queryset, name, value): - if value: - return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") - return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) - - def manage_kwargs(self, kwargs): - self.prod_type = None - self.product = None - self.engagement = None - self.test = None - if "prod_type" in kwargs: - self.prod_type = kwargs.pop("prod_type") - if "product" in kwargs: - self.product = kwargs.pop("product") - if "engagement" in kwargs: - self.engagement = kwargs.pop("engagement") - if "test" in kwargs: - self.test = kwargs.pop("test") - - @property - def qs(self): - parent = super().qs - return get_authorized_findings_for_queryset("view", parent) - - -class ReportFindingFilter(ReportFindingFilterHelper, FindingTagFilter): - test__engagement__product = ModelMultipleChoiceFilter( - queryset=Product.objects.none(), label=labels.ASSET_FILTERS_LABEL) - test__engagement__product__prod_type = ModelMultipleChoiceFilter( - queryset=Product_Type.objects.none(), - label=labels.ORG_FILTERS_LABEL) - test__engagement__product__lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES, label=labels.ASSET_LIFECYCLE_LABEL) - test__engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") - duplicate_finding = ModelChoiceFilter(queryset=Finding.objects.filter(original_finding__isnull=False).distinct()) - - def __init__(self, *args, **kwargs): - self.manage_kwargs(kwargs) - super().__init__(*args, **kwargs) - - # duplicate_finding queryset needs to restricted in line with permissions - # and inline with report scope to avoid a dropdown with 100K entries - duplicate_finding_query_set = self.form.fields["duplicate_finding"].queryset - duplicate_finding_query_set = get_authorized_findings_for_queryset("view", duplicate_finding_query_set) - - if self.test: - duplicate_finding_query_set = duplicate_finding_query_set.filter(test=self.test) - del self.form.fields["test__tags"] - del self.form.fields["test__engagement__tags"] - del self.form.fields["test__engagement__product__tags"] - if self.engagement: - duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement=self.engagement) - del self.form.fields["test__engagement__tags"] - del self.form.fields["test__engagement__product__tags"] - elif self.product: - duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement__product=self.product) - del self.form.fields["test__engagement__product"] - del self.form.fields["test__engagement__product__tags"] - elif self.prod_type: - duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement__product__prod_type=self.prod_type) - del self.form.fields["test__engagement__product__prod_type"] - - self.form.fields["duplicate_finding"].queryset = duplicate_finding_query_set - - if "test__engagement__product__prod_type" in self.form.fields: - self.form.fields[ - "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") - if "test__engagement__product" in self.form.fields: - self.form.fields[ - "test__engagement__product"].queryset = get_authorized_products("view") - if "test__engagement" in self.form.fields: - self.form.fields["test__engagement"].queryset = get_authorized_engagements("view") - - -class ReportFindingFilterWithoutObjectLookups(ReportFindingFilterHelper, FindingTagStringFilter): - test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) - test__engagement__product = NumberFilter(widget=HiddenInput()) - test__engagement = NumberFilter(widget=HiddenInput()) - test = NumberFilter(widget=HiddenInput()) - endpoint = NumberFilter(widget=HiddenInput()) - reporter = CharFilter( - field_name="reporter__username", - lookup_expr="iexact", - label="Reporter Username", - help_text="Search for Reporter names that are an exact match") - reporter_contains = CharFilter( - field_name="reporter__username", - lookup_expr="icontains", - label="Reporter Username Contains", - help_text="Search for Reporter names that contain a given pattern") - reviewers = CharFilter( - field_name="reviewers__username", - lookup_expr="iexact", - label="Reviewer Username", - help_text="Search for Reviewer names that are an exact match") - reviewers_contains = CharFilter( - field_name="reviewers__username", - lookup_expr="icontains", - label="Reviewer Username Contains", - help_text="Search for Reviewer usernames that contain a given pattern") - last_reviewed_by = CharFilter( - field_name="last_reviewed_by__username", - lookup_expr="iexact", - label="Last Reviewed By Username", - help_text="Search for Last Reviewed By names that are an exact match") - last_reviewed_by_contains = CharFilter( - field_name="last_reviewed_by__username", - lookup_expr="icontains", - label="Last Reviewed By Username Contains", - help_text="Search for Last Reviewed By usernames that contain a given pattern") - review_requested_by = CharFilter( - field_name="review_requested_by__username", - lookup_expr="iexact", - label="Review Requested By Username", - help_text="Search for Review Requested By names that are an exact match") - review_requested_by_contains = CharFilter( - field_name="review_requested_by__username", - lookup_expr="icontains", - label="Review Requested By Username Contains", - help_text="Search for Review Requested By usernames that contain a given pattern") - mitigated_by = CharFilter( - field_name="mitigated_by__username", - lookup_expr="iexact", - label="Mitigator Username", - help_text="Search for Mitigator names that are an exact match") - mitigated_by_contains = CharFilter( - field_name="mitigated_by__username", - lookup_expr="icontains", - label="Mitigator Username Contains", - help_text="Search for Mitigator usernames that contain a given pattern") - defect_review_requested_by = CharFilter( - field_name="defect_review_requested_by__username", - lookup_expr="iexact", - label="Requester of Defect Review Username", - help_text="Search for Requester of Defect Review names that are an exact match") - defect_review_requested_by_contains = CharFilter( - field_name="defect_review_requested_by__username", - lookup_expr="icontains", - label="Requester of Defect Review Username Contains", - help_text="Search for Requester of Defect Review usernames that contain a given pattern") - test__engagement__product__prod_type__name = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="iexact", - label=labels.ORG_FILTERS_NAME_LABEL, - help_text=labels.ORG_FILTERS_NAME_HELP) - test__engagement__product__prod_type__name_contains = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="icontains", - label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) - test__engagement__product__name = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="iexact", - label=labels.ASSET_FILTERS_NAME_LABEL, - help_text=labels.ASSET_FILTERS_NAME_HELP) - test__engagement__product__name_contains = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="icontains", - label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) - test__engagement__name = CharFilter( - field_name="test__engagement__name", - lookup_expr="iexact", - label="Engagement Name", - help_text="Search for Engagement names that are an exact match") - test__engagement__name_contains = CharFilter( - field_name="test__engagement__name", - lookup_expr="icontains", - label="Engagement name Contains", - help_text="Search for Engagement names that contain a given pattern") - test__name = CharFilter( - field_name="test__title", - lookup_expr="iexact", - label="Test Name", - help_text="Search for Test names that are an exact match") - test__name_contains = CharFilter( - field_name="test__title", - lookup_expr="icontains", - label="Test name Contains", - help_text="Search for Test names that contain a given pattern") - - def __init__(self, *args, **kwargs): - self.manage_kwargs(kwargs) - super().__init__(*args, **kwargs) - - product_type_refs = [ - "test__engagement__product__prod_type__name", - "test__engagement__product__prod_type__name_contains", - ] - product_refs = [ - "test__engagement__product__name", - "test__engagement__product__name_contains", - "test__engagement__product__tags", - "test__engagement__product__tags_contains", - "not_test__engagement__product__tags", - "not_test__engagement__product__tags_contains", - ] - engagement_refs = [ - "test__engagement__name", - "test__engagement__name_contains", - "test__engagement__tags", - "test__engagement__tags_contains", - "not_test__engagement__tags", - "not_test__engagement__tags_contains", - ] - test_refs = [ - "test__name", - "test__name_contains", - "test__tags", - "test__tags_contains", - "not_test__tags", - "not_test__tags_contains", - ] - - if self.test: - self.delete_tags_from_form(product_type_refs) - self.delete_tags_from_form(product_refs) - self.delete_tags_from_form(engagement_refs) - self.delete_tags_from_form(test_refs) - elif self.engagement: - self.delete_tags_from_form(product_type_refs) - self.delete_tags_from_form(product_refs) - self.delete_tags_from_form(engagement_refs) - elif self.product: - self.delete_tags_from_form(product_type_refs) - self.delete_tags_from_form(product_refs) - elif self.prod_type: - self.delete_tags_from_form(product_type_refs) - - class UserFilter(DojoFilter): first_name = CharFilter(lookup_expr="icontains") last_name = CharFilter(lookup_expr="icontains") diff --git a/dojo/finding/ui/__init__.py b/dojo/finding/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/finding/ui/filters.py b/dojo/finding/ui/filters.py new file mode 100644 index 00000000000..b886c9a5232 --- /dev/null +++ b/dojo/finding/ui/filters.py @@ -0,0 +1,1100 @@ +from datetime import timedelta + +from django import forms +from django.conf import settings +from django.db.models import Q +from django.forms import HiddenInput +from django.utils.translation import gettext_lazy as _ +from django_filters import ( + BooleanFilter, + CharFilter, + ChoiceFilter, + DateFilter, + DateFromToRangeFilter, + DateTimeFilter, + FilterSet, + ModelChoiceFilter, + ModelMultipleChoiceFilter, + MultipleChoiceFilter, + NumberFilter, + OrderingFilter, +) + +from dojo.engagement.queries import get_authorized_engagements +from dojo.filters import ( + DateRangeFilter, + DateRangeOmniFilter, + DojoFilter, + FindingHasJIRAFilter, + FindingSLAFilter, + FindingStatusFilter, + FindingTagFilter, + FindingTagStringFilter, + MetricsDateRangeFilter, + PercentageFilter, + PercentageRangeFilter, + ReportBooleanFilter, + ReportRiskAcceptanceFilter, + custom_vulnerability_id_filter, + cwe_options, + filter_endpoints_base, + filter_endpoints_host_base, + get_finding_filterset_fields, + vulnerability_id_filter, +) +from dojo.finding.queries import ( + get_authorized_findings_for_queryset, +) +from dojo.finding_group.queries import get_authorized_finding_groups_for_queryset +from dojo.labels import get_labels +from dojo.location.status import FindingLocationStatus +from dojo.models import ( + EFFORT_FOR_FIXING_CHOICES, + SEVERITY_CHOICES, + Dojo_User, + Endpoint, + Engagement, + Finding, + Finding_Group, + Finding_Template, + Product, + Product_Type, + Risk_Acceptance, + Test, + Test_Type, +) +from dojo.product.queries import get_authorized_products +from dojo.product_type.queries import get_authorized_product_types +from dojo.risk_acceptance.queries import get_authorized_risk_acceptances +from dojo.test.queries import get_authorized_tests +from dojo.user.queries import get_authorized_users +from dojo.utils import get_system_setting, get_visible_scan_types, is_finding_groups_enabled + +labels = get_labels() + + +class FindingFilterHelper(FilterSet): + title = CharFilter(lookup_expr="icontains") + date = DateRangeFilter(field_name="date", label="Date Discovered") + on = DateFilter(field_name="date", lookup_expr="exact", label="Discovered On") + before = DateFilter(field_name="date", lookup_expr="lt", label="Discovered Before") + after = DateFilter(field_name="date", lookup_expr="gt", label="Discovered After") + last_reviewed = DateRangeFilter() + last_status_update = DateRangeFilter() + cwe = MultipleChoiceFilter(choices=[]) + vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") + severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) + duplicate = ReportBooleanFilter() + is_mitigated = ReportBooleanFilter() + fix_available = ReportBooleanFilter() + mitigation = CharFilter(lookup_expr="icontains") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") + mitigated = DateRangeFilter(field_name="mitigated", label="Mitigated Date") + mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", label="Mitigated On", method="filter_mitigated_on") + mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt", label="Mitigated Before") + mitigated_after = DateTimeFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After", method="filter_mitigated_after") + planned_remediation_date = DateRangeOmniFilter() + planned_remediation_version = CharFilter(lookup_expr="icontains", label=_("Planned remediation version")) + file_path = CharFilter(lookup_expr="icontains") + param = CharFilter(lookup_expr="icontains") + payload = CharFilter(lookup_expr="icontains") + test__test_type = ModelMultipleChoiceFilter(queryset=Test_Type.objects.all(), label="Test Type") + service = CharFilter(lookup_expr="icontains") + test__engagement__version = CharFilter(lookup_expr="icontains", label="Engagement Version") + test__version = CharFilter(lookup_expr="icontains", label="Test Version") + risk_acceptance = ReportRiskAcceptanceFilter(label="Risk Accepted") + effort_for_fixing = MultipleChoiceFilter(choices=EFFORT_FOR_FIXING_CHOICES) + test_import_finding_action__test_import = NumberFilter(widget=HiddenInput()) + status = FindingStatusFilter(label="Status") + test__engagement__product__lifecycle = MultipleChoiceFilter( + choices=Product.LIFECYCLE_CHOICES, + label=labels.ASSET_LIFECYCLE_LABEL) + if settings.V3_FEATURE_LOCATIONS: + location_status = MultipleChoiceFilter( + field_name="locations__status", + choices=FindingLocationStatus.choices, + help_text="Status of the Location from the Findings relationship", + ) + endpoints__host = CharFilter( + field_name="locations__location__url__host", method="filter_endpoints_host", label="Endpoint Host", + ) + endpoints = NumberFilter(field_name="locations__location", method="filter_endpoints", widget=HiddenInput()) + + def filter_endpoints_host(self, queryset, name, value): + return filter_endpoints_host_base( + queryset, + name, + value, + endpoint_id=self.data.get("endpoints"), + statuses=self.data.getlist("location_status"), + ) + + def filter_endpoints(self, queryset, name, value): + return filter_endpoints_base( + queryset, + name, + value, + statuses=self.data.getlist("location_status"), + host=self.data.get("endpoints__host"), + ) + else: + # TODO: Delete this after the move to Locations + endpoints__host = CharFilter(lookup_expr="icontains", label="Endpoint Host") + endpoints = NumberFilter(widget=HiddenInput()) + + has_component = BooleanFilter( + field_name="component_name", + lookup_expr="isnull", + exclude=True, + label="Has Component") + has_notes = BooleanFilter( + field_name="notes", + lookup_expr="isnull", + exclude=True, + label="Has notes") + + if is_finding_groups_enabled(): + has_finding_group = BooleanFilter( + field_name="finding_group", + lookup_expr="isnull", + exclude=True, + label="Is Grouped") + + if get_system_setting("enable_jira"): + has_jira_issue = BooleanFilter( + field_name="jira_issue", + lookup_expr="isnull", + exclude=True, + label="Has JIRA") + jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation", label="JIRA Creation") + jira_change = DateRangeFilter(field_name="jira_issue__jira_change", label="JIRA Updated") + jira_issue__jira_key = CharFilter(field_name="jira_issue__jira_key", lookup_expr="icontains", label="JIRA issue") + + if is_finding_groups_enabled(): + has_jira_group_issue = BooleanFilter( + field_name="finding_group__jira_issue", + lookup_expr="isnull", + exclude=True, + label="Has Group JIRA") + has_any_jira_issue = FindingHasJIRAFilter( + label="Has Any JIRA Issue", + help_text="Matches JIRA issues linked to the finding itself or to the finding's group.", + ) + + outside_of_sla = FindingSLAFilter(label="Outside of SLA") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + epss_score = PercentageFilter(field_name="epss_score", label="EPSS score") + epss_score_range = PercentageRangeFilter( + field_name="epss_score", + label="EPSS score range", + help_text=( + "The range of EPSS score percentages to filter on; the left input is a lower bound, " + "the right is an upper bound. Leaving one empty will skip that bound (e.g., leaving " + "the lower bound input empty will filter only on the upper bound -- filtering on " + '"less than or equal").' + )) + epss_percentile = PercentageFilter(field_name="epss_percentile", label="EPSS percentile") + epss_percentile_range = PercentageRangeFilter( + field_name="epss_percentile", + label="EPSS percentile range", + help_text=( + "The range of EPSS percentiles to filter on; the left input is a lower bound, the right " + "is an upper bound. Leaving one empty will skip that bound (e.g., leaving the lower bound " + 'input empty will filter only on the upper bound -- filtering on "less than or equal").' + )) + kev_date = DateFilter(field_name="kev_date", lookup_expr="exact", label="Added to KEV On") + kev_before = DateFilter(field_name="kev_date", lookup_expr="lt", label="Added to KEV Before") + kev_after = DateFilter(field_name="kev_date", lookup_expr="gt", label="Added to KEV After") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("numerical_severity", "numerical_severity"), + ("date", "date"), + ("mitigated", "mitigated"), + ("fix_available", "fix_available"), + ("risk_acceptance__created__date", + "risk_acceptance__created__date"), + ("last_reviewed", "last_reviewed"), + ("planned_remediation_date", "planned_remediation_date"), + ("planned_remediation_version", "planned_remediation_version"), + ("title", "title"), + ("test__engagement__product__name", + "test__engagement__product__name"), + ("service", "service"), + ("sla_age_days", "sla_age_days"), + ("epss_score", "epss_score"), + ("epss_percentile", "epss_percentile"), + ("known_exploited", "known_exploited"), + ("ransomware_used", "ransomware_used"), + ("kev_date", "kev_date"), + ), + field_labels={ + "numerical_severity": "Severity", + "date": "Date", + "risk_acceptance__created__date": "Acceptance Date", + "mitigated": "Mitigated Date", + "fix_available": "Fix Available", + "title": "Finding Name", + "test__engagement__product__name": labels.ASSET_FILTERS_NAME_LABEL, + "epss_score": "EPSS Score", + "epss_percentile": "EPSS Percentile", + "known_exploited": "Known Exploited", + "ransomware_used": "Ransomware Used", + "kev_date": "Date added to KEV", + "sla_age_days": "SLA age (days)", + "planned_remediation_date": "Planned Remediation", + "planned_remediation_version": "Planned remediation version", + }, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if "test__test_type" in self.form.fields: + self.form.fields["test__test_type"].queryset = get_visible_scan_types() + + def set_date_fields(self, *args: list, **kwargs: dict): + date_input_widget = forms.DateInput(attrs={"class": "datepicker", "placeholder": "YYYY-MM-DD"}, format="%Y-%m-%d") + self.form.fields["on"].widget = date_input_widget + self.form.fields["before"].widget = date_input_widget + self.form.fields["after"].widget = date_input_widget + self.form.fields["kev_date"].widget = date_input_widget + self.form.fields["kev_before"].widget = date_input_widget + self.form.fields["kev_after"].widget = date_input_widget + self.form.fields["mitigated_on"].widget = date_input_widget + self.form.fields["mitigated_before"].widget = date_input_widget + self.form.fields["mitigated_after"].widget = date_input_widget + self.form.fields["cwe"].choices = cwe_options(self.queryset) + + def filter_mitigated_after(self, queryset, name, value): + if value.hour == 0 and value.minute == 0 and value.second == 0: + value = value.replace(hour=23, minute=59, second=59) + + return queryset.filter(mitigated__gt=value) + + def filter_mitigated_on(self, queryset, name, value): + if value.hour == 0 and value.minute == 0 and value.second == 0: + # we have a simple date without a time, lets get a range from this morning to tonight at 23:59:59:999 + nextday = value + timedelta(days=1) + return queryset.filter(mitigated__gte=value, mitigated__lt=nextday) + + return queryset.filter(mitigated=value) + + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + + +def get_finding_group_queryset_for_context(pid=None, eid=None, tid=None): + """ + Helper function to build finding group queryset based on context hierarchy. + Context priority: test > engagement > product > global + + Args: + pid: Product ID (least specific) + eid: Engagement ID + tid: Test ID (most specific) + + Returns: + QuerySet of Finding_Group filtered by context + + """ + if tid is not None: + # Most specific: filter by test + return Finding_Group.objects.filter(test_id=tid).only("id", "name") + if eid is not None: + # Filter by engagement's tests + return Finding_Group.objects.filter(test__engagement_id=eid).only("id", "name") + if pid is not None: + # Filter by product's tests + return Finding_Group.objects.filter(test__engagement__product_id=pid).only("id", "name") + # Global: return all (authorization will be applied separately) + return Finding_Group.objects.all().only("id", "name") + + +class FindingFilterWithoutObjectLookups(FindingFilterHelper, FindingTagStringFilter): + test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) + test__engagement__product = NumberFilter(widget=HiddenInput()) + reporter = CharFilter( + field_name="reporter__username", + lookup_expr="iexact", + label="Reporter Username", + help_text="Search for Reporter names that are an exact match") + reporter_contains = CharFilter( + field_name="reporter__username", + lookup_expr="icontains", + label="Reporter Username Contains", + help_text="Search for Reporter names that contain a given pattern") + reviewers = CharFilter( + field_name="reviewers__username", + lookup_expr="iexact", + label="Reviewer Username", + help_text="Search for Reviewer names that are an exact match") + reviewers_contains = CharFilter( + field_name="reviewers__username", + lookup_expr="icontains", + label="Reviewer Username Contains", + help_text="Search for Reviewer usernames that contain a given pattern") + test__engagement__product__prod_type__name = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="iexact", + label=labels.ORG_FILTERS_NAME_LABEL, + help_text=labels.ORG_FILTERS_NAME_HELP) + test__engagement__product__prod_type__name_contains = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="icontains", + label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) + test__engagement__product__name = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="iexact", + label=labels.ASSET_FILTERS_NAME_LABEL, + help_text=labels.ASSET_FILTERS_NAME_HELP) + test__engagement__product__name_contains = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="icontains", + label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) + test__engagement__name = CharFilter( + field_name="test__engagement__name", + lookup_expr="iexact", + label="Engagement Name", + help_text="Search for Engagement names that are an exact match") + test__engagement__name_contains = CharFilter( + field_name="test__engagement__name", + lookup_expr="icontains", + label="Engagement name Contains", + help_text="Search for Engagement names that contain a given pattern") + test__name = CharFilter( + field_name="test__title", + lookup_expr="iexact", + label="Test Name", + help_text="Search for Test names that are an exact match") + test__name_contains = CharFilter( + field_name="test__title", + lookup_expr="icontains", + label="Test name Contains", + help_text="Search for Test names that contain a given pattern") + + if is_finding_groups_enabled(): + finding_group__name = CharFilter( + field_name="finding_group__name", + lookup_expr="iexact", + label="Finding Group Name", + help_text="Search for Finding Group names that are an exact match") + finding_group__name_contains = CharFilter( + field_name="finding_group__name", + lookup_expr="icontains", + label="Finding Group Name Contains", + help_text="Search for Finding Group names that contain a given pattern") + + class Meta: + model = Finding + fields = get_finding_filterset_fields(filter_string_matching=True) + + exclude = ["url", "description", "mitigation", "impact", + "endpoints", "references", + "thread_id", "notes", "scanner_confidence", + "numerical_severity", "line", "duplicate_finding", + "hash_code", "reviewers", "created", "files", + "sla_start_date", "sla_expiration_date", "cvssv3", + "severity_justification", "steps_to_reproduce"] + + def __init__(self, *args, **kwargs): + self.user = None + self.pid = None + self.eid = None + self.tid = None + if "user" in kwargs: + self.user = kwargs.pop("user") + + if "pid" in kwargs: + self.pid = kwargs.pop("pid") + if "eid" in kwargs: + self.eid = kwargs.pop("eid") + if "tid" in kwargs: + self.tid = kwargs.pop("tid") + super().__init__(*args, **kwargs) + # Set some date fields + self.set_date_fields(*args, **kwargs) + # Don't show the product/engagement/test filter fields when in specific context + if self.tid or self.eid or self.pid: + if "test__engagement__product__name" in self.form.fields: + del self.form.fields["test__engagement__product__name"] + if "test__engagement__product__name_contains" in self.form.fields: + del self.form.fields["test__engagement__product__name_contains"] + if "test__engagement__product__prod_type__name" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type__name"] + if "test__engagement__product__prod_type__name_contains" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type__name_contains"] + # Also hide engagement and test fields if in test or engagement context + if self.tid: + if "test__engagement__name" in self.form.fields: + del self.form.fields["test__engagement__name"] + if "test__engagement__name_contains" in self.form.fields: + del self.form.fields["test__engagement__name_contains"] + if "test__name" in self.form.fields: + del self.form.fields["test__name"] + if "test__name_contains" in self.form.fields: + del self.form.fields["test__name_contains"] + elif self.eid: + if "test__engagement__name" in self.form.fields: + del self.form.fields["test__engagement__name"] + if "test__engagement__name_contains" in self.form.fields: + del self.form.fields["test__engagement__name_contains"] + + +class FindingFilter(FindingFilterHelper, FindingTagFilter): + reporter = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none()) + reviewers = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none()) + test__engagement__product__prod_type = ModelMultipleChoiceFilter( + queryset=Product_Type.objects.none(), + label=labels.ORG_FILTERS_LABEL) + test__engagement__product = ModelMultipleChoiceFilter( + queryset=Product.objects.none(), + label=labels.ASSET_FILTERS_LABEL) + test__engagement = ModelMultipleChoiceFilter( + queryset=Engagement.objects.none(), + label="Engagement") + test = ModelMultipleChoiceFilter( + queryset=Test.objects.none(), + label="Test") + + if is_finding_groups_enabled(): + finding_group = ModelMultipleChoiceFilter( + queryset=Finding_Group.objects.none(), + label="Finding Group") + + class Meta: + model = Finding + fields = get_finding_filterset_fields() + + exclude = ["url", "description", "mitigation", "impact", + "endpoints", "references", + "thread_id", "notes", "scanner_confidence", + "numerical_severity", "line", "duplicate_finding", + "hash_code", "reviewers", "created", "files", + "sla_start_date", "sla_expiration_date", "cvssv3", + "severity_justification", "steps_to_reproduce"] + + def __init__(self, *args, **kwargs): + self.user = None + self.pid = None + self.eid = None + self.tid = None + if "user" in kwargs: + self.user = kwargs.pop("user") + + if "pid" in kwargs: + self.pid = kwargs.pop("pid") + if "eid" in kwargs: + self.eid = kwargs.pop("eid") + if "tid" in kwargs: + self.tid = kwargs.pop("tid") + super().__init__(*args, **kwargs) + # Set some date fields + self.set_date_fields(*args, **kwargs) + # Don't show the product filter on the product finding view + self.set_related_object_fields(*args, **kwargs) + + def set_related_object_fields(self, *args: list, **kwargs: dict): + # Use helper to get contextual finding group queryset + finding_group_query = get_finding_group_queryset_for_context( + pid=self.pid, + eid=self.eid, + tid=self.tid, + ) + + # Filter by most specific context: test > engagement > product + if self.tid is not None: + # Test context: filter finding groups by test + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] + if "test__engagement" in self.form.fields: + del self.form.fields["test__engagement"] + if "test" in self.form.fields: + del self.form.fields["test"] + elif self.eid is not None: + # Engagement context: filter finding groups by engagement + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] + if "test__engagement" in self.form.fields: + del self.form.fields["test__engagement"] + # Filter tests by engagement - get_authorized_tests doesn't support engagement param + engagement = Engagement.objects.filter(id=self.eid).select_related("product").first() + if engagement: + self.form.fields["test"].queryset = get_authorized_tests("view", product=engagement.product).filter(engagement_id=self.eid).prefetch_related("test_type") + elif self.pid is not None: + # Product context: filter finding groups by product + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] + # TODO: add authorized check to be sure + if "test__engagement" in self.form.fields: + self.form.fields["test__engagement"].queryset = Engagement.objects.filter( + product_id=self.pid, + ).all() + if "test" in self.form.fields: + self.form.fields["test"].queryset = get_authorized_tests("view", product=self.pid).prefetch_related("test_type") + else: + # Global context: show all authorized finding groups + self.form.fields[ + "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") + self.form.fields["test__engagement"].queryset = get_authorized_engagements("view") + if "test" in self.form.fields: + del self.form.fields["test"] + + if self.form.fields.get("test__engagement__product"): + self.form.fields["test__engagement__product"].queryset = get_authorized_products("view") + if self.form.fields.get("finding_group", None): + self.form.fields["finding_group"].queryset = get_authorized_finding_groups_for_queryset("view", finding_group_query, user=self.user) + self.form.fields["reporter"].queryset = get_authorized_users("view") + self.form.fields["reviewers"].queryset = self.form.fields["reporter"].queryset + + +class FindingGroupsFilter(FilterSet): + name = CharFilter(lookup_expr="icontains", label="Name") + severity = ChoiceFilter( + choices=[ + ("Low", "Low"), + ("Medium", "Medium"), + ("High", "High"), + ("Critical", "Critical"), + ], + label="Min Severity", + ) + engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") + product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label=labels.ASSET_LABEL) + + class Meta: + model = Finding + fields = ["name", "severity", "engagement", "product"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) + self.pid = kwargs.pop("pid", None) + super().__init__(*args, **kwargs) + self.set_related_object_fields() + + def set_related_object_fields(self): + if self.pid is not None: + self.form.fields["engagement"].queryset = Engagement.objects.filter(product_id=self.pid) + if "product" in self.form.fields: + del self.form.fields["product"] + else: + self.form.fields["product"].queryset = get_authorized_products("view") + self.form.fields["engagement"].queryset = get_authorized_engagements("view") + + +class AcceptedFindingFilter(FindingFilter): + risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date") + risk_acceptance__owner = ModelMultipleChoiceFilter( + queryset=Dojo_User.objects.none(), + label="Risk Acceptance Owner") + risk_acceptance = ModelMultipleChoiceFilter( + queryset=Risk_Acceptance.objects.none(), + label="Accepted By") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["risk_acceptance__owner"].queryset = get_authorized_users("view") + self.form.fields["risk_acceptance"].queryset = get_authorized_risk_acceptances("edit") + + +class AcceptedFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups): + risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date") + risk_acceptance__owner = CharFilter( + field_name="risk_acceptance__owner__username", + lookup_expr="iexact", + label="Risk Acceptance Owner Username", + help_text="Search for Risk Acceptance Owners username that are an exact match") + risk_acceptance__owner_contains = CharFilter( + field_name="risk_acceptance__owner__username", + lookup_expr="icontains", + label="Risk Acceptance Owner Username Contains", + help_text="Search for Risk Acceptance Owners username that contain a given pattern") + risk_acceptance__name = CharFilter( + field_name="risk_acceptance__name", + lookup_expr="iexact", + label="Risk Acceptance Name", + help_text="Search for Risk Acceptance name that are an exact match") + risk_acceptance__name_contains = CharFilter( + field_name="risk_acceptance__name", + lookup_expr="icontains", + label="Risk Acceptance Name", + help_text="Search for Risk Acceptance name contain a given pattern") + + +class SimilarFindingHelper(FilterSet): + hash_code = MultipleChoiceFilter() + vulnerability_ids = CharFilter(method=custom_vulnerability_id_filter, label="Vulnerability Ids") + + def update_data(self, data: dict, *args: list, **kwargs: dict): + # if filterset is bound, use initial values as defaults + # because of this, we can't rely on the self.form.has_changed + self.has_changed = True + if not data and self.finding: + # get a mutable copy of the QueryDict + data = data.copy() + + data["vulnerability_ids"] = ",".join(self.finding.vulnerability_ids) + data["cwe"] = self.finding.cwe + data["file_path"] = self.finding.file_path + data["line"] = self.finding.line + data["unique_id_from_tool"] = self.finding.unique_id_from_tool + data["test__test_type"] = self.finding.test.test_type + data["test__engagement__product"] = self.finding.test.engagement.product + data["test__engagement__product__prod_type"] = self.finding.test.engagement.product.prod_type + + self.has_changed = False + + def set_hash_codes(self, *args: list, **kwargs: dict): + if self.finding and self.finding.hash_code: + self.form.fields["hash_code"] = forms.MultipleChoiceField(choices=[(self.finding.hash_code, self.finding.hash_code[:24] + "...")], required=False, initial=[]) + + def filter_queryset(self, *args: list, **kwargs: dict): + queryset = super().filter_queryset(*args, **kwargs) + queryset = get_authorized_findings_for_queryset("view", queryset, self.user) + return queryset.exclude(pk=self.finding.pk) + + +class SimilarFindingFilter(FindingFilter, SimilarFindingHelper): + class Meta(FindingFilter.Meta): + model = Finding + # slightly different fields from FindingFilter, but keep the same ordering for UI consistency + fields = get_finding_filterset_fields(similar=True) + + def __init__(self, data=None, *args, **kwargs): + self.user = None + if "user" in kwargs: + self.user = kwargs.pop("user") + self.finding = None + if "finding" in kwargs: + self.finding = kwargs.pop("finding") + self.update_data(data, *args, **kwargs) + super().__init__(data, *args, **kwargs) + self.set_hash_codes(*args, **kwargs) + + +class SimilarFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups, SimilarFindingHelper): + class Meta(FindingFilterWithoutObjectLookups.Meta): + model = Finding + # slightly different fields from FindingFilter, but keep the same ordering for UI consistency + fields = get_finding_filterset_fields(similar=True, filter_string_matching=True) + + def __init__(self, data=None, *args, **kwargs): + self.user = None + if "user" in kwargs: + self.user = kwargs.pop("user") + self.finding = None + if "finding" in kwargs: + self.finding = kwargs.pop("finding") + self.update_data(data, *args, **kwargs) + super().__init__(data, *args, **kwargs) + self.set_hash_codes(*args, **kwargs) + + +class TemplateFindingFilter(DojoFilter): + title = CharFilter(lookup_expr="icontains") + cwe = MultipleChoiceFilter(choices=[]) + severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) + + tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + queryset=Finding.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") + + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Finding.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("cwe", "cwe"), + ("title", "title"), + ("numerical_severity", "numerical_severity"), + ), + field_labels={ + "numerical_severity": "Severity", + }, + ) + + class Meta: + model = Finding_Template + exclude = ["description", "mitigation", "impact", + "references", "numerical_severity"] + + not_test__tags = ModelMultipleChoiceFilter( + field_name="test__tags__name", + to_field_name="name", + exclude=True, + label="Test without tags", + queryset=Test.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_test__engagement__tags = ModelMultipleChoiceFilter( + field_name="test__engagement__tags__name", + to_field_name="name", + exclude=True, + label="Engagement without tags", + queryset=Engagement.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_test__engagement__product__tags = ModelMultipleChoiceFilter( + field_name="test__engagement__product__tags__name", + to_field_name="name", + exclude=True, + label=labels.ASSET_FILTERS_WITHOUT_TAGS_LABEL, + queryset=Product.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["cwe"].choices = cwe_options(self.queryset) + + +class MetricsFindingFilter(FindingFilter): + start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) + end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) + date = MetricsDateRangeFilter() + vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") + + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Endpoint.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + + def __init__(self, *args, **kwargs): + if args[0]: + if args[0].get("start_date", "") or args[0].get("end_date", ""): + args[0]._mutable = True + args[0]["date"] = 8 + args[0]._mutable = False + + super().__init__(*args, **kwargs) + + class Meta(FindingFilter.Meta): + model = Finding + fields = get_finding_filterset_fields(metrics=True) + + +class MetricsFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups): + start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) + end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) + date = MetricsDateRangeFilter() + vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") + + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Endpoint.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + + def __init__(self, *args, **kwargs): + if args[0]: + if args[0].get("start_date", "") or args[0].get("end_date", ""): + args[0]._mutable = True + args[0]["date"] = 8 + args[0]._mutable = False + + super().__init__(*args, **kwargs) + + class Meta(FindingFilterWithoutObjectLookups.Meta): + model = Finding + fields = get_finding_filterset_fields(metrics=True, filter_string_matching=True) + + +class ReportFindingFilterHelper(FilterSet): + title = CharFilter(lookup_expr="icontains", label="Name") + date = DateFromToRangeFilter(field_name="date", label="Date Discovered") + date_recent = DateRangeFilter(field_name="date", label="Relative Date") + severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) + active = ReportBooleanFilter() + is_mitigated = ReportBooleanFilter() + mitigated = DateRangeFilter(label="Mitigated Date") + verified = ReportBooleanFilter() + false_p = ReportBooleanFilter(label="False Positive") + risk_acceptance = ReportRiskAcceptanceFilter(label="Risk Accepted") + duplicate = ReportBooleanFilter() + out_of_scope = ReportBooleanFilter() + outside_of_sla = FindingSLAFilter(label="Outside of SLA") + file_path = CharFilter(lookup_expr="icontains") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") + + o = OrderingFilter( + fields=( + ("title", "title"), + ("date", "date"), + ("fix_available", "fix_available"), + ("numerical_severity", "numerical_severity"), + ("epss_score", "epss_score"), + ("epss_percentile", "epss_percentile"), + ("test__engagement__product__name", "test__engagement__product__name"), + ), + ) + + class Meta: + model = Finding + # exclude sonarqube issue as by default it will show all without checking permissions + exclude = ["date", "cwe", "url", "description", "mitigation", "impact", + "references", "sonarqube_issue", "duplicate_finding", + "thread_id", "notes", "inherited_tags", "endpoints", + "numerical_severity", "reporter", "last_reviewed", + "jira_creation", "jira_change", "files"] + + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + + def manage_kwargs(self, kwargs): + self.prod_type = None + self.product = None + self.engagement = None + self.test = None + if "prod_type" in kwargs: + self.prod_type = kwargs.pop("prod_type") + if "product" in kwargs: + self.product = kwargs.pop("product") + if "engagement" in kwargs: + self.engagement = kwargs.pop("engagement") + if "test" in kwargs: + self.test = kwargs.pop("test") + + @property + def qs(self): + parent = super().qs + return get_authorized_findings_for_queryset("view", parent) + + +class ReportFindingFilter(ReportFindingFilterHelper, FindingTagFilter): + test__engagement__product = ModelMultipleChoiceFilter( + queryset=Product.objects.none(), label=labels.ASSET_FILTERS_LABEL) + test__engagement__product__prod_type = ModelMultipleChoiceFilter( + queryset=Product_Type.objects.none(), + label=labels.ORG_FILTERS_LABEL) + test__engagement__product__lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES, label=labels.ASSET_LIFECYCLE_LABEL) + test__engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") + duplicate_finding = ModelChoiceFilter(queryset=Finding.objects.filter(original_finding__isnull=False).distinct()) + + def __init__(self, *args, **kwargs): + self.manage_kwargs(kwargs) + super().__init__(*args, **kwargs) + + # duplicate_finding queryset needs to restricted in line with permissions + # and inline with report scope to avoid a dropdown with 100K entries + duplicate_finding_query_set = self.form.fields["duplicate_finding"].queryset + duplicate_finding_query_set = get_authorized_findings_for_queryset("view", duplicate_finding_query_set) + + if self.test: + duplicate_finding_query_set = duplicate_finding_query_set.filter(test=self.test) + del self.form.fields["test__tags"] + del self.form.fields["test__engagement__tags"] + del self.form.fields["test__engagement__product__tags"] + if self.engagement: + duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement=self.engagement) + del self.form.fields["test__engagement__tags"] + del self.form.fields["test__engagement__product__tags"] + elif self.product: + duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement__product=self.product) + del self.form.fields["test__engagement__product"] + del self.form.fields["test__engagement__product__tags"] + elif self.prod_type: + duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement__product__prod_type=self.prod_type) + del self.form.fields["test__engagement__product__prod_type"] + + self.form.fields["duplicate_finding"].queryset = duplicate_finding_query_set + + if "test__engagement__product__prod_type" in self.form.fields: + self.form.fields[ + "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") + if "test__engagement__product" in self.form.fields: + self.form.fields[ + "test__engagement__product"].queryset = get_authorized_products("view") + if "test__engagement" in self.form.fields: + self.form.fields["test__engagement"].queryset = get_authorized_engagements("view") + + +class ReportFindingFilterWithoutObjectLookups(ReportFindingFilterHelper, FindingTagStringFilter): + test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) + test__engagement__product = NumberFilter(widget=HiddenInput()) + test__engagement = NumberFilter(widget=HiddenInput()) + test = NumberFilter(widget=HiddenInput()) + endpoint = NumberFilter(widget=HiddenInput()) + reporter = CharFilter( + field_name="reporter__username", + lookup_expr="iexact", + label="Reporter Username", + help_text="Search for Reporter names that are an exact match") + reporter_contains = CharFilter( + field_name="reporter__username", + lookup_expr="icontains", + label="Reporter Username Contains", + help_text="Search for Reporter names that contain a given pattern") + reviewers = CharFilter( + field_name="reviewers__username", + lookup_expr="iexact", + label="Reviewer Username", + help_text="Search for Reviewer names that are an exact match") + reviewers_contains = CharFilter( + field_name="reviewers__username", + lookup_expr="icontains", + label="Reviewer Username Contains", + help_text="Search for Reviewer usernames that contain a given pattern") + last_reviewed_by = CharFilter( + field_name="last_reviewed_by__username", + lookup_expr="iexact", + label="Last Reviewed By Username", + help_text="Search for Last Reviewed By names that are an exact match") + last_reviewed_by_contains = CharFilter( + field_name="last_reviewed_by__username", + lookup_expr="icontains", + label="Last Reviewed By Username Contains", + help_text="Search for Last Reviewed By usernames that contain a given pattern") + review_requested_by = CharFilter( + field_name="review_requested_by__username", + lookup_expr="iexact", + label="Review Requested By Username", + help_text="Search for Review Requested By names that are an exact match") + review_requested_by_contains = CharFilter( + field_name="review_requested_by__username", + lookup_expr="icontains", + label="Review Requested By Username Contains", + help_text="Search for Review Requested By usernames that contain a given pattern") + mitigated_by = CharFilter( + field_name="mitigated_by__username", + lookup_expr="iexact", + label="Mitigator Username", + help_text="Search for Mitigator names that are an exact match") + mitigated_by_contains = CharFilter( + field_name="mitigated_by__username", + lookup_expr="icontains", + label="Mitigator Username Contains", + help_text="Search for Mitigator usernames that contain a given pattern") + defect_review_requested_by = CharFilter( + field_name="defect_review_requested_by__username", + lookup_expr="iexact", + label="Requester of Defect Review Username", + help_text="Search for Requester of Defect Review names that are an exact match") + defect_review_requested_by_contains = CharFilter( + field_name="defect_review_requested_by__username", + lookup_expr="icontains", + label="Requester of Defect Review Username Contains", + help_text="Search for Requester of Defect Review usernames that contain a given pattern") + test__engagement__product__prod_type__name = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="iexact", + label=labels.ORG_FILTERS_NAME_LABEL, + help_text=labels.ORG_FILTERS_NAME_HELP) + test__engagement__product__prod_type__name_contains = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="icontains", + label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) + test__engagement__product__name = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="iexact", + label=labels.ASSET_FILTERS_NAME_LABEL, + help_text=labels.ASSET_FILTERS_NAME_HELP) + test__engagement__product__name_contains = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="icontains", + label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) + test__engagement__name = CharFilter( + field_name="test__engagement__name", + lookup_expr="iexact", + label="Engagement Name", + help_text="Search for Engagement names that are an exact match") + test__engagement__name_contains = CharFilter( + field_name="test__engagement__name", + lookup_expr="icontains", + label="Engagement name Contains", + help_text="Search for Engagement names that contain a given pattern") + test__name = CharFilter( + field_name="test__title", + lookup_expr="iexact", + label="Test Name", + help_text="Search for Test names that are an exact match") + test__name_contains = CharFilter( + field_name="test__title", + lookup_expr="icontains", + label="Test name Contains", + help_text="Search for Test names that contain a given pattern") + + def __init__(self, *args, **kwargs): + self.manage_kwargs(kwargs) + super().__init__(*args, **kwargs) + + product_type_refs = [ + "test__engagement__product__prod_type__name", + "test__engagement__product__prod_type__name_contains", + ] + product_refs = [ + "test__engagement__product__name", + "test__engagement__product__name_contains", + "test__engagement__product__tags", + "test__engagement__product__tags_contains", + "not_test__engagement__product__tags", + "not_test__engagement__product__tags_contains", + ] + engagement_refs = [ + "test__engagement__name", + "test__engagement__name_contains", + "test__engagement__tags", + "test__engagement__tags_contains", + "not_test__engagement__tags", + "not_test__engagement__tags_contains", + ] + test_refs = [ + "test__name", + "test__name_contains", + "test__tags", + "test__tags_contains", + "not_test__tags", + "not_test__tags_contains", + ] + + if self.test: + self.delete_tags_from_form(product_type_refs) + self.delete_tags_from_form(product_refs) + self.delete_tags_from_form(engagement_refs) + self.delete_tags_from_form(test_refs) + elif self.engagement: + self.delete_tags_from_form(product_type_refs) + self.delete_tags_from_form(product_refs) + self.delete_tags_from_form(engagement_refs) + elif self.product: + self.delete_tags_from_form(product_type_refs) + self.delete_tags_from_form(product_refs) + elif self.prod_type: + self.delete_tags_from_form(product_type_refs) diff --git a/dojo/finding/ui/forms.py b/dojo/finding/ui/forms.py new file mode 100644 index 00000000000..71ed786f1c2 --- /dev/null +++ b/dojo/finding/ui/forms.py @@ -0,0 +1,1086 @@ +import tagulous +from django import forms +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from tagulous.forms import TagField + +from dojo.endpoint.utils import validate_endpoints_to_add +from dojo.finding.queries import get_authorized_findings +from dojo.jira import services as jira_services +from dojo.location.models import Location +from dojo.location.utils import validate_locations_to_add +from dojo.models import ( + EFFORT_FOR_FIXING_CHOICES, + SEVERITY_CHOICES, + Dojo_User, + Endpoint, + Finding, + Finding_Group, + Finding_Template, + Notes, + Risk_Acceptance, + Test, +) +from dojo.user.queries import get_authorized_users, get_authorized_users_for_product_and_product_type +from dojo.utils import get_system_setting, is_finding_groups_enabled +from dojo.validators import cvss3_validator, cvss4_validator, tag_validator +from dojo.widgets import TableCheckboxWidget + +CVSS_CALCULATOR_URLS = { + "https://www.first.org/cvss/calculator/3-0": "CVSS3 Calculator by FIRST", + "https://www.first.org/cvss/calculator/4-0": "CVSS4 Calculator by FIRST", + "https://www.metaeffekt.com/security/cvss/calculator/": "CVSS2/3/4 Calculator by Metaeffekt", + } + + +vulnerability_ids_field = forms.CharField(max_length=5000, + required=False, + label="Vulnerability Ids", + help_text="Ids of vulnerabilities in security advisories associated with this finding. Can be Common Vulnerabilities and Exposures (CVE) or from other sources." + "You may enter one vulnerability id per line.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + +EFFORT_FOR_FIXING_INVALID_CHOICE = _("Select valid choice: Low,Medium,High") + + +class BulletListDisplayWidget(forms.Widget): + def __init__(self, urls_dict=None, *args, **kwargs): + self.urls_dict = urls_dict or {} + super().__init__(*args, **kwargs) + + def render(self, name, value, attrs=None, renderer=None): + if not self.urls_dict: + return "" + + html = '
    ' + for url, text in self.urls_dict.items(): + html += f'
  • {text}
  • ' + html += "
" + return mark_safe(html) + + +def hide_cvss_fields_if_disabled(form_instance): + """Hide CVSS fields based on system settings.""" + enable_cvss3 = get_system_setting("enable_cvss3_display", True) + enable_cvss4 = get_system_setting("enable_cvss4_display", True) + + # Hide CVSS3 fields if disabled + if not enable_cvss3: + if "cvssv3" in form_instance.fields: + del form_instance.fields["cvssv3"] + if "cvssv3_score" in form_instance.fields: + del form_instance.fields["cvssv3_score"] + + # Hide CVSS4 fields if disabled + if not enable_cvss4: + if "cvssv4" in form_instance.fields: + del form_instance.fields["cvssv4"] + if "cvssv4_score" in form_instance.fields: + del form_instance.fields["cvssv4_score"] + + # If both are disabled, hide all CVSS related fields + if not enable_cvss3 and not enable_cvss4: + if "cvss_info" in form_instance.fields: + del form_instance.fields["cvss_info"] + + +class EditFindingGroupForm(forms.ModelForm): + name = forms.CharField(max_length=255, required=True, label="Finding Group Name") + jira_issue = forms.CharField(max_length=255, required=False, label="Linked JIRA Issue", + help_text="Leave empty and check push to jira to create a new JIRA issue for this finding group.") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["push_to_jira"] = forms.BooleanField() + self.fields["push_to_jira"].required = False + self.fields["push_to_jira"].help_text = "Checking this will overwrite content of your JIRA issue, or create one." + + self.fields["push_to_jira"].label = "Push to JIRA" + + if hasattr(self.instance, "has_jira_issue") and self.instance.has_jira_issue: + jira_url = jira_services.get_url(self.instance) + self.fields["jira_issue"].initial = jira_url + self.fields["push_to_jira"].widget.attrs["checked"] = "checked" + + class Meta: + model = Finding_Group + fields = ["name"] + + +class DeleteFindingGroupForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Finding_Group + fields = ["id"] + + +class MergeFindings(forms.ModelForm): + FINDING_ACTION = (("", "Select an Action"), ("inactive", "Inactive"), ("delete", "Delete")) + + append_description = forms.BooleanField(label="Append Description", initial=True, required=False, + help_text="Description in all findings will be appended into the merged finding.") + + add_endpoints = forms.BooleanField(label="Add Endpoints", initial=True, required=False, + help_text="Endpoints in all findings will be merged into the merged finding.") + + dynamic_raw = forms.BooleanField(label="Dynamic Scanner Raw Requests", initial=True, required=False, + help_text="Dynamic scanner raw requests in all findings will be merged into the merged finding.") + + tag_finding = forms.BooleanField(label="Add Tags", initial=True, required=False, + help_text="Tags in all findings will be merged into the merged finding.") + + mark_tag_finding = forms.BooleanField(label="Tag Merged Finding", initial=True, required=False, + help_text="Creates a tag titled 'merged' for the finding that will be merged. If the 'Finding Action' is set to 'inactive' the inactive findings will be tagged with 'merged-inactive'.") + + append_reference = forms.BooleanField(label="Append Reference", initial=True, required=False, + help_text="Reference in all findings will be appended into the merged finding.") + + finding_action = forms.ChoiceField( + required=True, + choices=FINDING_ACTION, + label="Finding Action", + help_text="The action to take on the merged finding. Set the findings to inactive or delete the findings.") + + def __init__(self, *args, **kwargs): + _ = kwargs.pop("finding") + findings = kwargs.pop("findings") + super().__init__(*args, **kwargs) + + self.fields["finding_to_merge_into"] = forms.ModelChoiceField( + queryset=findings, initial=0, required="False", label="Finding to Merge Into", help_text="Findings selected below will be merged into this finding.") + + # Exclude the finding to merge into from the findings to merge into + self.fields["findings_to_merge"] = forms.ModelMultipleChoiceField( + queryset=findings, required=True, label="Findings to Merge", + widget=forms.widgets.SelectMultiple(attrs={"size": 10}), + help_text=("Select the findings to merge.")) + self.field_order = ["finding_to_merge_into", "findings_to_merge", "append_description", "add_endpoints", "append_reference"] + + class Meta: + model = Finding + fields = ["append_description", "add_endpoints", "append_reference"] + + +class AddFindingsRiskAcceptanceForm(forms.ModelForm): + + accepted_findings = forms.ModelMultipleChoiceField( + queryset=Finding.objects.none(), + required=True, + label="", + widget=TableCheckboxWidget(attrs={"size": 25}), + ) + + class Meta: + model = Risk_Acceptance + fields = ["accepted_findings"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["accepted_findings"].queryset = get_authorized_findings("edit") + + +class AddFindingForm(forms.ModelForm): + title = forms.CharField(max_length=1000) + date = forms.DateField(required=True, + widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + cwe = forms.IntegerField(required=False) + vulnerability_ids = vulnerability_ids_field + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) + description = forms.CharField(widget=forms.Textarea) + severity = forms.ChoiceField( + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + request = forms.CharField(widget=forms.Textarea, required=False) + response = forms.CharField(widget=forms.Textarea, required=False) + endpoints = forms.ModelMultipleChoiceField(Location.objects.none(), required=False, label="Systems / Endpoints") + endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + references = forms.CharField(widget=forms.Textarea, required=False) + publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.ChoiceField( + required=False, + choices=EFFORT_FOR_FIXING_CHOICES, + error_messages={ + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + + # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit + field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", + "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "verified", "false_p", "duplicate", "out_of_scope", + "risk_accepted", "under_defect_review") + + def __init__(self, *args, **kwargs): + req_resp = kwargs.pop("req_resp") + + product = None + if "product" in kwargs: + product = kwargs.pop("product") + + super().__init__(*args, **kwargs) + + if settings.V3_FEATURE_LOCATIONS and product: + self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) + # TODO: Delete this after the move to Locations + elif product: + self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) + else: + self.fields["endpoints"].queryset = Endpoint.objects.none() + + if req_resp: + self.fields["request"].initial = req_resp[0] + self.fields["response"].initial = req_resp[1] + + self.endpoints_to_add_list = [] + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + if ((cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]): + msg = "Duplicate findings cannot be verified or active" + raise forms.ValidationError(msg) + if cleaned_data["false_p"] and cleaned_data["verified"]: + msg = "False positive findings cannot be verified." + raise forms.ValidationError(msg) + if cleaned_data["active"] and "risk_accepted" in cleaned_data and cleaned_data["risk_accepted"]: + msg = "Active findings cannot be risk accepted." + raise forms.ValidationError(msg) + + if settings.V3_FEATURE_LOCATIONS: + endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) + else: + # TODO: Delete this after the move to Locations + endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) + + if errors: + raise forms.ValidationError(errors) + self.endpoints_to_add_list = endpoints_to_add_list + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Finding + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date") + + +class AdHocFindingForm(forms.ModelForm): + title = forms.CharField(max_length=1000) + date = forms.DateField(required=True, + widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + cwe = forms.IntegerField(required=False) + vulnerability_ids = vulnerability_ids_field + + cvss_info = forms.CharField( + label="CVSS", + widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), + required=False, + disabled=True) + + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) + description = forms.CharField(widget=forms.Textarea) + severity = forms.ChoiceField( + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + request = forms.CharField(widget=forms.Textarea, required=False) + response = forms.CharField(widget=forms.Textarea, required=False) + endpoints = forms.ModelMultipleChoiceField(queryset=Location.objects.all(), required=False, + label="Systems / Endpoints") + endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + references = forms.CharField(widget=forms.Textarea, required=False) + publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.ChoiceField( + required=False, + choices=EFFORT_FOR_FIXING_CHOICES, + error_messages={ + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + + # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit + field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", + "impact", "request", "response", "steps_to_reproduce", "severity_justification", "endpoints", "endpoints_to_add", "references", + "active", "verified", "false_p", "duplicate", "out_of_scope", "risk_accepted", "under_defect_review", "sla_start_date", "sla_expiration_date") + + def __init__(self, *args, **kwargs): + req_resp = kwargs.pop("req_resp") + + product = None + if "product" in kwargs: + product = kwargs.pop("product") + + super().__init__(*args, **kwargs) + + if settings.V3_FEATURE_LOCATIONS and product: + self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) + # TODO: Delete this after the move to Locations + elif product: + self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) + else: + self.fields["endpoints"].queryset = Endpoint.objects.none() + + if req_resp: + self.fields["request"].initial = req_resp[0] + self.fields["response"].initial = req_resp[1] + + self.endpoints_to_add_list = [] + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + if ((cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]): + msg = "Duplicate findings cannot be verified or active" + raise forms.ValidationError(msg) + if cleaned_data["false_p"] and cleaned_data["verified"]: + msg = "False positive findings cannot be verified." + raise forms.ValidationError(msg) + + if settings.V3_FEATURE_LOCATIONS: + endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) + else: + # TODO: Delete this after the move to Locations + endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) + + self.endpoints_to_add_list = endpoints_to_add_list + + if errors: + raise forms.ValidationError(errors) + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Finding + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date", + "sla_expiration_date") + + +class PromoteFindingForm(forms.ModelForm): + title = forms.CharField(max_length=1000) + date = forms.DateField(required=True, + widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + cwe = forms.IntegerField(required=False) + vulnerability_ids = vulnerability_ids_field + + cvss_info = forms.CharField( + label="CVSS", + widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), + required=False, + disabled=True) + + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) + description = forms.CharField(widget=forms.Textarea) + severity = forms.ChoiceField( + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + endpoints = forms.ModelMultipleChoiceField(Location.objects.none(), required=False, label="Systems / Endpoints") + endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + references = forms.CharField(widget=forms.Textarea, required=False) + + # the onyl reliable way without hacking internal fields to get predicatble ordering is to make it explicit + field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", + "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", + "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", + "false_p", "duplicate", "out_of_scope", "risk_accept", "under_defect_review") + + def __init__(self, *args, **kwargs): + product = None + if "product" in kwargs: + product = kwargs.pop("product") + + super().__init__(*args, **kwargs) + + if settings.V3_FEATURE_LOCATIONS and product: + self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) + # TODO: Delete this after the move to Locations + elif product: + self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) + else: + self.fields["endpoints"].queryset = Endpoint.objects.none() + + self.endpoints_to_add_list = [] + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + + if settings.V3_FEATURE_LOCATIONS: + endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) + else: + # TODO: Delete this after the move to Locations + endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) + + if errors: + raise forms.ValidationError(errors) + self.endpoints_to_add_list = endpoints_to_add_list + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Finding + exclude = ("reporter", "url", "numerical_severity", "active", "false_p", "verified", "endpoint_status", "cve", "inherited_tags", + "duplicate", "out_of_scope", "under_review", "reviewers", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "planned_remediation_date", "planned_remediation_version", "effort_for_fixing") + + +class FindingForm(forms.ModelForm): + title = forms.CharField(max_length=1000) + group = forms.ModelChoiceField(required=False, queryset=Finding_Group.objects.none(), help_text="The Finding Group to which this finding belongs, leave empty to remove the finding from the group. Groups can only be created via Bulk Edit for now.") + date = forms.DateField(required=True, + widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + cwe = forms.IntegerField(required=False) + vulnerability_ids = vulnerability_ids_field + + cvss_info = forms.CharField( + label="CVSS", + widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), + required=False, + disabled=True) + + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) + + description = forms.CharField(widget=forms.Textarea) + severity = forms.ChoiceField( + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + request = forms.CharField(widget=forms.Textarea, required=False) + response = forms.CharField(widget=forms.Textarea, required=False) + endpoints = forms.ModelMultipleChoiceField(queryset=Location.objects.none(), required=False, label="Systems / Endpoints") + endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + references = forms.CharField(widget=forms.Textarea, required=False) + + mitigated = forms.DateField(required=False, help_text="Date and time when the flaw has been fixed", widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + mitigated_by = forms.ModelChoiceField(required=False, queryset=Dojo_User.objects.none()) + + publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.ChoiceField( + required=False, + choices=EFFORT_FOR_FIXING_CHOICES, + error_messages={ + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + + # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit + field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", + "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", + "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", "false_p", "duplicate", + "out_of_scope", "risk_accept", "under_defect_review") + + def __init__(self, *args, **kwargs): + req_resp = None + if "req_resp" in kwargs: + req_resp = kwargs.pop("req_resp") + + self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \ + else False + + super().__init__(*args, **kwargs) + + if settings.V3_FEATURE_LOCATIONS: + self.fields["endpoints"].queryset = Location.objects.filter(products__product=self.instance.test.engagement.product) + if self.instance and self.instance.pk: + self.fields["endpoints"].initial = Location.objects.filter(findings__finding=self.instance) + else: + # TODO: Delete this after the move to Locations + self.fields["endpoints"].queryset = Endpoint.objects.filter(product=self.instance.test.engagement.product) + if self.instance and self.instance.pk: + self.fields["endpoints"].initial = self.instance.endpoints.all() + + self.fields["mitigated_by"].queryset = get_authorized_users("edit") + + # do not show checkbox if finding is not accepted and simple risk acceptance is disabled + # if checked, always show to allow unaccept also with full risk acceptance enabled + # when adding from template, we don't have access to the test. quickfix for now to just hide simple risk acceptance + if not hasattr(self.instance, "test") or (not self.instance.risk_accepted and not self.instance.test.engagement.product.enable_simple_risk_acceptance): + del self.fields["risk_accepted"] + elif self.instance.risk_accepted: + self.fields["risk_accepted"].help_text = "Uncheck to unaccept the risk. Use full risk acceptance from the dropdown menu if you need advanced settings such as an expiry date." + elif self.instance.test.engagement.product.enable_simple_risk_acceptance: + self.fields["risk_accepted"].help_text = "Check to accept the risk. Use full risk acceptance from the dropdown menu if you need advanced settings such as an expiry date." + + # self.fields['tags'].widget.choices = t + if req_resp: + self.fields["request"].initial = req_resp[0] + self.fields["response"].initial = req_resp[1] + + if self.instance.duplicate: + self.fields["duplicate"].help_text = "Original finding that is being duplicated here (readonly). Use view finding page to manage duplicate relationships. Unchecking duplicate here will reset this findings duplicate status, but will trigger deduplication logic." + else: + self.fields["duplicate"].help_text = "You can mark findings as duplicate only from the view finding page." + + self.fields["sla_start_date"].disabled = True + self.fields["sla_expiration_date"].disabled = True + + if self.can_edit_mitigated_data: + if hasattr(self, "instance"): + self.fields["mitigated"].initial = self.instance.mitigated + self.fields["mitigated_by"].initial = self.instance.mitigated_by + else: + del self.fields["mitigated"] + del self.fields["mitigated_by"] + + if not is_finding_groups_enabled() or not hasattr(self.instance, "test"): + del self.fields["group"] + else: + self.fields["group"].queryset = self.instance.test.finding_group_set.all() + self.fields["group"].initial = self.instance.finding_group + + self.endpoints_to_add_list = [] + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + + if (cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]: + msg = "Duplicate findings cannot be verified or active" + raise forms.ValidationError(msg) + if cleaned_data["false_p"] and cleaned_data["verified"]: + msg = "False positive findings cannot be verified." + raise forms.ValidationError(msg) + if cleaned_data["active"] and "risk_accepted" in cleaned_data and cleaned_data["risk_accepted"]: + msg = "Active findings cannot be risk accepted." + raise forms.ValidationError(msg) + + if settings.V3_FEATURE_LOCATIONS: + endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) + else: + # TODO: Delete this after the move to Locations + endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) + + self.endpoints_to_add_list = endpoints_to_add_list + + if errors: + raise forms.ValidationError(errors) + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + def _post_clean(self): + super()._post_clean() + + if self.can_edit_mitigated_data: + opts = self.instance._meta + try: + opts.get_field("mitigated").save_form_data(self.instance, self.cleaned_data.get("mitigated")) + opts.get_field("mitigated_by").save_form_data(self.instance, self.cleaned_data.get("mitigated_by")) + except forms.ValidationError as e: + self._update_errors(e) + + class Meta: + model = Finding + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "sonarqube_issue", + "endpoints", "endpoint_status") + + +class ApplyFindingTemplateForm(forms.Form): + + title = forms.CharField(max_length=1000, required=True) + + cwe = forms.IntegerField(label="CWE", required=False) + vulnerability_ids = vulnerability_ids_field + cvssv3 = forms.CharField(label="CVSSv3", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") + cvssv4 = forms.CharField(label="CVSSv4", max_length=255, required=False) + cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") + + severity = forms.ChoiceField(required=False, choices=SEVERITY_CHOICES, error_messages={"required": "Select valid choice: In Progress, On Hold, Completed", "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) + + description = forms.CharField(widget=forms.Textarea) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + references = forms.CharField(widget=forms.Textarea, required=False) + + # Remediation planning fields + fix_available = forms.BooleanField(required=False) + fix_version = forms.CharField(max_length=100, required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.CharField(max_length=99, required=False) + + # Technical details fields + steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) + severity_justification = forms.CharField(widget=forms.Textarea, required=False) + component_name = forms.CharField(max_length=500, required=False) + component_version = forms.CharField(max_length=100, required=False) + + # Notes field + notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") + + # Endpoints field + endpoints = forms.CharField(max_length=5000, required=False, + help_text="Endpoint URLs (one per line)", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + + tags = TagField(required=False, help_text="Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.", initial=Finding.tags.tag_model.objects.all().order_by("name")) + + def __init__(self, template=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") + self.template = template + if template: + # Populate vulnerability_ids field initial value + self.fields["vulnerability_ids"].initial = "\n".join(template.vulnerability_ids) + + # Populate CVSS fields from template + if hasattr(template, "cvssv3"): + self.fields["cvssv3"].initial = template.cvssv3 + if hasattr(template, "cvssv4"): + self.fields["cvssv4"].initial = template.cvssv4 + if hasattr(template, "cvssv3_score"): + self.fields["cvssv3_score"].initial = template.cvssv3_score + if hasattr(template, "cvssv4_score"): + self.fields["cvssv4_score"].initial = template.cvssv4_score + + # Populate all other new fields from template + for field_name in ["fix_available", "fix_version", "planned_remediation_version", + "effort_for_fixing", "steps_to_reproduce", "severity_justification", + "component_name", "component_version", "notes"]: + if hasattr(template, field_name): + value = getattr(template, field_name) + if value is not None: + self.fields[field_name].initial = value + + # Populate endpoints + if hasattr(template, "endpoints"): + endpoints_value = template.endpoints + if endpoints_value: + if isinstance(endpoints_value, list): + self.fields["endpoints"].initial = "\n".join(endpoints_value) + else: + self.fields["endpoints"].initial = endpoints_value + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + + if "title" in cleaned_data: + if len(cleaned_data["title"]) <= 0: + msg = "The title is required." + raise forms.ValidationError(msg) + else: + msg = "The title is required." + raise forms.ValidationError(msg) + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + fields = ["title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "severity", "description", "mitigation", "impact", "references", "tags", + "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", + "steps_to_reproduce", "severity_justification", "component_name", "component_version", + "notes", "endpoints"] + order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "severity", "description", "impact", "steps_to_reproduce", "severity_justification", + "mitigation", "fix_available", "fix_version", "planned_remediation_version", + "effort_for_fixing", "component_name", "component_version", "references", "notes", + "endpoints", "tags") + + +class FindingTemplateForm(forms.ModelForm): + title = forms.CharField(max_length=1000, required=True) + + cwe = forms.IntegerField(label="CWE", required=False) + vulnerability_ids = vulnerability_ids_field + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") + severity = forms.ChoiceField( + required=False, + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) + + # Remediation planning fields + fix_available = forms.BooleanField(required=False) + fix_version = forms.CharField(max_length=100, required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.CharField(max_length=99, required=False) + + # Technical details fields + steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) + severity_justification = forms.CharField(widget=forms.Textarea, required=False) + component_name = forms.CharField(max_length=500, required=False) + component_version = forms.CharField(max_length=100, required=False) + + # Notes field + notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") + + # Endpoints field + endpoints = forms.CharField(max_length=5000, required=False, + help_text="Endpoint URLs (one per line)", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + + field_order = ["title", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "description", "impact", "steps_to_reproduce", "severity_justification", "mitigation", + "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", + "component_name", "component_version", "references", "notes", "endpoints", "tags"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + class Meta: + model = Finding_Template + order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "severity", "description", "impact", + "steps_to_reproduce", "severity_justification", "mitigation", "fix_available", "fix_version", + "planned_remediation_version", "effort_for_fixing", "component_name", "component_version", + "references", "notes", "endpoints", "tags") + exclude = ("numerical_severity", "is_mitigated", "last_used", "endpoint_status", "cve", "vulnerability_ids_text") + + def clean_cvssv3(self): + value = self.cleaned_data.get("cvssv3") + if value: + try: + cvss3_validator(value) + except ValidationError as e: + raise forms.ValidationError(e.messages) + return value + + def clean_cvssv4(self): + value = self.cleaned_data.get("cvssv4") + if value: + try: + cvss4_validator(value) + except ValidationError as e: + raise forms.ValidationError(e.messages) + return value + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + +class DeleteFindingTemplateForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Finding_Template + fields = ["id"] + + +class FindingBulkUpdateForm(forms.ModelForm): + status = forms.BooleanField(required=False) + risk_acceptance = forms.BooleanField(required=False) + risk_accept = forms.BooleanField(required=False) + risk_unaccept = forms.BooleanField(required=False) + + date = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "datepicker"})) + planned_remediation_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "datepicker"})) + planned_remediation_version = forms.CharField(required=False, max_length=99, widget=forms.TextInput(attrs={"class": "form-control"})) + finding_group = forms.BooleanField(required=False) + finding_group_create = forms.BooleanField(required=False) + finding_group_create_name = forms.CharField(required=False) + finding_group_add = forms.BooleanField(required=False) + add_to_finding_group_id = forms.CharField(required=False) + finding_group_remove = forms.BooleanField(required=False) + finding_group_by = forms.BooleanField(required=False) + finding_group_by_option = forms.CharField(required=False) + + push_to_jira = forms.BooleanField(required=False) + # unlink_from_jira = forms.BooleanField(required=False) + push_to_github = forms.BooleanField(required=False) + tags = TagField(required=False, autocomplete_tags=Finding.tags.tag_model.objects.all().order_by("name")) + notes = forms.CharField(required=False, max_length=1024, widget=forms.TextInput(attrs={"class": "form-control"})) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["severity"].required = False + # we need to defer initialization to prevent multiple initializations if other forms are shown + self.fields["tags"].widget.tag_options = tagulous.models.options.TagOptions(autocomplete_settings={"width": "200px", "defer": True}) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + def clean(self): + cleaned_data = super().clean() + + if (cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]: + msg = "Duplicate findings cannot be verified or active" + raise forms.ValidationError(msg) + if cleaned_data["false_p"] and cleaned_data["verified"]: + msg = "False positive findings cannot be verified." + raise forms.ValidationError(msg) + if cleaned_data["active"] and cleaned_data.get("risk_acceptance") and cleaned_data.get("risk_accept"): + msg = "Active findings cannot be risk accepted." + raise forms.ValidationError(msg) + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Finding + fields = ("severity", "date", "planned_remediation_date", "active", "verified", "false_p", "duplicate", "out_of_scope", + "under_review", "is_mitigated") + + +class CloseFindingForm(forms.ModelForm): + entry = forms.CharField( + required=True, max_length=2400, + widget=forms.Textarea, label="Notes:", + error_messages={"required": ("The reason for closing a finding is " + "required, please use the text area " + "below to provide documentation.")}) + + mitigated = forms.DateField(required=False, help_text="Date and time when the flaw has been fixed", widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + mitigated_by = forms.ModelChoiceField(required=False, queryset=Dojo_User.objects.none()) + false_p = forms.BooleanField(initial=False, required=False, label="False Positive") + out_of_scope = forms.BooleanField(initial=False, required=False, label="Out of Scope") + duplicate = forms.BooleanField(initial=False, required=False, label="Duplicate") + + def __init__(self, *args, **kwargs): + queryset = kwargs.pop("missing_note_types") + # must pop custom kwargs before calling parent __init__ to avoid unexpected kwarg errors + self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \ + else False + super().__init__(*args, **kwargs) + if len(queryset) == 0: + self.fields["note_type"].widget = forms.HiddenInput() + else: + self.fields["note_type"] = forms.ModelChoiceField(queryset=queryset, label="Note Type", required=True) + + if self.can_edit_mitigated_data: + self.fields["mitigated_by"].queryset = get_authorized_users("edit") + self.fields["mitigated"].initial = self.instance.mitigated + self.fields["mitigated_by"].initial = self.instance.mitigated_by + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + def _post_clean(self): + super()._post_clean() + + if self.can_edit_mitigated_data: + opts = self.instance._meta + if not self.cleaned_data.get("active"): + try: + opts.get_field("mitigated").save_form_data(self.instance, self.cleaned_data.get("mitigated")) + opts.get_field("mitigated_by").save_form_data(self.instance, self.cleaned_data.get("mitigated_by")) + except forms.ValidationError as e: + self._update_errors(e) + + class Meta: + model = Notes + fields = ["note_type", "entry", "mitigated", "mitigated_by", "false_p", "out_of_scope", "duplicate"] + + +class EditPlannedRemediationDateFindingForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + finding = None + if "finding" in kwargs: + finding = kwargs.pop("finding") + + super().__init__(*args, **kwargs) + + self.fields["planned_remediation_date"].required = True + self.fields["planned_remediation_date"].widget = forms.DateInput(attrs={"class": "datepicker"}) + + if finding is not None: + self.fields["planned_remediation_date"].initial = finding.planned_remediation_date + + class Meta: + model = Finding + fields = ["planned_remediation_date"] + + +class DefectFindingForm(forms.ModelForm): + CLOSE_CHOICES = (("Close Finding", "Close Finding"), ("Not Fixed", "Not Fixed")) + defect_choice = forms.ChoiceField(required=True, choices=CLOSE_CHOICES) + + entry = forms.CharField( + required=True, max_length=2400, + widget=forms.Textarea, label="Notes:", + error_messages={"required": ("The reason for closing a finding is " + "required, please use the text area " + "below to provide documentation.")}) + + class Meta: + model = Notes + fields = ["entry"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + +class ClearFindingReviewForm(forms.ModelForm): + entry = forms.CharField( + required=True, max_length=2400, + help_text="Please provide a message.", + widget=forms.Textarea, label="Notes:", + error_messages={"required": ("The reason for clearing a review is " + "required, please use the text area " + "below to provide documentation.")}) + + class Meta: + model = Finding + fields = ["active", "verified", "false_p", "out_of_scope", "duplicate", "is_mitigated"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + +class ReviewFindingForm(forms.Form): + reviewers = forms.MultipleChoiceField( + help_text=( + "Select all users who can review Finding. Only users with " + "at least write permission to this finding can be selected"), + required=False, + ) + entry = forms.CharField( + required=True, max_length=2400, + help_text="Please provide a message for reviewers.", + widget=forms.Textarea, label="Notes:", + error_messages={"required": ("The reason for requesting a review is " + "required, please use the text area " + "below to provide documentation.")}) + allow_all_reviewers = forms.BooleanField( + required=False, + label="Allow All Eligible Reviewers", + help_text=("Checking this box will allow any user in the drop down " + "above to provide a review for this finding")) + + def __init__(self, *args, **kwargs): + finding = kwargs.pop("finding", None) + user = kwargs.pop("user", None) + super().__init__(*args, **kwargs) + # Get the list of users + if finding is not None: + users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, "edit") + else: + users = get_authorized_users("edit").filter(is_active=True) + # Remove the current user + if user is not None: + users = users.exclude(id=user.id) + # Save a copy of the original query to be used in the validator + self.reviewer_queryset = users + # Set the users in the form + self.fields["reviewers"].choices = self._get_choices(self.reviewer_queryset) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + @staticmethod + def _get_choices(queryset): + return [(item.pk, item.get_full_name()) for item in queryset] + + def clean(self): + cleaned_data = super().clean() + if cleaned_data.get("allow_all_reviewers", False): + cleaned_data["reviewers"] = [user.id for user in self.reviewer_queryset] + if len(cleaned_data.get("reviewers", [])) == 0: + msg = "Please select at least one user from the reviewers list" + raise ValidationError(msg) + return cleaned_data + + class Meta: + fields = ["reviewers", "entry", "allow_all_reviewers"] + + +class DeleteFindingForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Finding + fields = ["id"] + + +class CopyFindingForm(forms.Form): + test = forms.ModelChoiceField( + required=True, + queryset=Test.objects.none(), + error_messages={"required": "*"}) + + def __init__(self, *args, **kwargs): + authorized_lists = kwargs.pop("tests", None) + super().__init__(*args, **kwargs) + self.fields["test"].queryset = authorized_lists + + +class FindingFormID(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Finding + fields = ("id",) diff --git a/dojo/finding/views.py b/dojo/finding/views.py index 192b46c2ab2..c3a17f01425 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -32,7 +32,13 @@ import dojo.risk_acceptance.helper as ra_helper from dojo.authorization.authorization import user_has_global_permission_or_403, user_has_permission_or_403 from dojo.celery_dispatch import dojo_dispatch_task -from dojo.filters import ( +from dojo.finding.deduplication import ( + _fetch_fp_candidates_for_batch, + do_false_positive_history_batch, + match_finding_to_existing_findings, +) +from dojo.finding.queries import get_authorized_findings, get_authorized_findings_for_queryset, prefetch_for_findings +from dojo.finding.ui.filters import ( AcceptedFindingFilter, AcceptedFindingFilterWithoutObjectLookups, FindingFilter, @@ -41,13 +47,7 @@ SimilarFindingFilterWithoutObjectLookups, TemplateFindingFilter, ) -from dojo.finding.deduplication import ( - _fetch_fp_candidates_for_batch, - do_false_positive_history_batch, - match_finding_to_existing_findings, -) -from dojo.finding.queries import get_authorized_findings, get_authorized_findings_for_queryset, prefetch_for_findings -from dojo.forms import ( +from dojo.finding.ui.forms import ( ApplyFindingTemplateForm, ClearFindingReviewForm, CloseFindingForm, @@ -59,11 +59,13 @@ FindingBulkUpdateForm, FindingForm, FindingTemplateForm, + MergeFindings, + ReviewFindingForm, +) +from dojo.forms import ( GITHUBFindingForm, JIRAFindingForm, - MergeFindings, NoteForm, - ReviewFindingForm, TypedNoteForm, ) from dojo.jira import services as jira_services diff --git a/dojo/finding_group/views.py b/dojo/finding_group/views.py index c77ddefccd5..6c6bb2907e9 100644 --- a/dojo/finding_group/views.py +++ b/dojo/finding_group/views.py @@ -13,12 +13,12 @@ from django.views.decorators.http import require_POST from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.filters import ( +from dojo.finding.queries import prefetch_for_findings +from dojo.finding.ui.filters import ( FindingFilter, FindingFilterWithoutObjectLookups, FindingGroupsFilter, ) -from dojo.finding.queries import prefetch_for_findings from dojo.forms import DeleteFindingGroupForm, EditFindingGroupForm, FindingBulkUpdateForm from dojo.jira import services as jira_services from dojo.models import Engagement, Finding, Finding_Group, GITHUB_PKey, Product diff --git a/dojo/forms.py b/dojo/forms.py index c2428988d4b..8d81d1510e4 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -5,7 +5,6 @@ from datetime import date, datetime from pathlib import Path -import tagulous from crispy_forms.bootstrap import InlineCheckboxes, InlineRadios from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout @@ -38,7 +37,6 @@ GITHUBFindingForm, GITHUBForm, ) -from dojo.jira import services as jira_services from dojo.jira.forms import ( # noqa: F401 backward compat JIRA_TEMPLATE_CHOICES, AdvancedJIRAForm, @@ -56,7 +54,6 @@ from dojo.location.models import Location from dojo.location.utils import validate_locations_to_add from dojo.models import ( - EFFORT_FOR_FIXING_CHOICES, SEVERITY_CHOICES, Announcement, Answered_Survey, @@ -76,7 +73,6 @@ FileUpload, Finding, Finding_Group, - Finding_Template, General_Survey, Note_Type, Notes, @@ -89,7 +85,6 @@ Risk_Acceptance, SLA_Configuration, System_Settings, - Test, Test_Type, TextAnswer, TextQuestion, @@ -102,7 +97,7 @@ from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types from dojo.tools.factory import get_choices_sorted, requires_file, requires_tool_type -from dojo.user.queries import get_authorized_users, get_authorized_users_for_product_and_product_type +from dojo.user.queries import get_authorized_users from dojo.user.utils import get_configuration_permissions_fields from dojo.utils import ( get_password_requirements_string, @@ -110,8 +105,7 @@ is_finding_groups_enabled, is_scan_file_too_large, ) -from dojo.validators import ImporterFileExtensionValidator, cvss3_validator, cvss4_validator, tag_validator -from dojo.widgets import TableCheckboxWidget +from dojo.validators import ImporterFileExtensionValidator, tag_validator logger = logging.getLogger(__name__) @@ -124,38 +118,6 @@ ("duplicate", "Duplicate"), ("out_of_scope", "Out of Scope")) -CVSS_CALCULATOR_URLS = { - "https://www.first.org/cvss/calculator/3-0": "CVSS3 Calculator by FIRST", - "https://www.first.org/cvss/calculator/4-0": "CVSS4 Calculator by FIRST", - "https://www.metaeffekt.com/security/cvss/calculator/": "CVSS2/3/4 Calculator by Metaeffekt", - } - - -vulnerability_ids_field = forms.CharField(max_length=5000, - required=False, - label="Vulnerability Ids", - help_text="Ids of vulnerabilities in security advisories associated with this finding. Can be Common Vulnerabilities and Exposures (CVE) or from other sources." - "You may enter one vulnerability id per line.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - -EFFORT_FOR_FIXING_INVALID_CHOICE = _("Select valid choice: Low,Medium,High") - - -class BulletListDisplayWidget(forms.Widget): - def __init__(self, urls_dict=None, *args, **kwargs): - self.urls_dict = urls_dict or {} - super().__init__(*args, **kwargs) - - def render(self, name, value, attrs=None, renderer=None): - if not self.urls_dict: - return "" - - html = '
    ' - for url, text in self.urls_dict.items(): - html += f'
  • {text}
  • ' - html += "
" - return mark_safe(html) - class MultipleSelectWithPop(forms.SelectMultiple): def render(self, name, *args, **kwargs): @@ -271,36 +233,16 @@ class Meta: fields = ["id"] -class EditFindingGroupForm(forms.ModelForm): - name = forms.CharField(max_length=255, required=True, label="Finding Group Name") - jira_issue = forms.CharField(max_length=255, required=False, label="Linked JIRA Issue", - help_text="Leave empty and check push to jira to create a new JIRA issue for this finding group.") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["push_to_jira"] = forms.BooleanField() - self.fields["push_to_jira"].required = False - self.fields["push_to_jira"].help_text = "Checking this will overwrite content of your JIRA issue, or create one." - - self.fields["push_to_jira"].label = "Push to JIRA" - - if hasattr(self.instance, "has_jira_issue") and self.instance.has_jira_issue: - jira_url = jira_services.get_url(self.instance) - self.fields["jira_issue"].initial = jira_url - self.fields["push_to_jira"].widget.attrs["checked"] = "checked" - - class Meta: - model = Finding_Group - fields = ["name"] - - -class DeleteFindingGroupForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Finding_Group - fields = ["id"] +# Re-exported for external consumers (finding_group/test/engagement/product views + unittests). +# The remaining finding forms live only in dojo.finding.ui.forms and are imported there by finding's own views. +from dojo.finding.ui.forms import ( # noqa: E402, F401 -- backward compat + AddFindingForm, + AddFindingsRiskAcceptanceForm, + AdHocFindingForm, + DeleteFindingGroupForm, + EditFindingGroupForm, + FindingBulkUpdateForm, +) class Authorize_User_For_ProductTypesForm(forms.Form): @@ -690,53 +632,6 @@ def clean(self): raise ValidationError(msg) -class MergeFindings(forms.ModelForm): - FINDING_ACTION = (("", "Select an Action"), ("inactive", "Inactive"), ("delete", "Delete")) - - append_description = forms.BooleanField(label="Append Description", initial=True, required=False, - help_text="Description in all findings will be appended into the merged finding.") - - add_endpoints = forms.BooleanField(label="Add Endpoints", initial=True, required=False, - help_text="Endpoints in all findings will be merged into the merged finding.") - - dynamic_raw = forms.BooleanField(label="Dynamic Scanner Raw Requests", initial=True, required=False, - help_text="Dynamic scanner raw requests in all findings will be merged into the merged finding.") - - tag_finding = forms.BooleanField(label="Add Tags", initial=True, required=False, - help_text="Tags in all findings will be merged into the merged finding.") - - mark_tag_finding = forms.BooleanField(label="Tag Merged Finding", initial=True, required=False, - help_text="Creates a tag titled 'merged' for the finding that will be merged. If the 'Finding Action' is set to 'inactive' the inactive findings will be tagged with 'merged-inactive'.") - - append_reference = forms.BooleanField(label="Append Reference", initial=True, required=False, - help_text="Reference in all findings will be appended into the merged finding.") - - finding_action = forms.ChoiceField( - required=True, - choices=FINDING_ACTION, - label="Finding Action", - help_text="The action to take on the merged finding. Set the findings to inactive or delete the findings.") - - def __init__(self, *args, **kwargs): - _ = kwargs.pop("finding") - findings = kwargs.pop("findings") - super().__init__(*args, **kwargs) - - self.fields["finding_to_merge_into"] = forms.ModelChoiceField( - queryset=findings, initial=0, required="False", label="Finding to Merge Into", help_text="Findings selected below will be merged into this finding.") - - # Exclude the finding to merge into from the findings to merge into - self.fields["findings_to_merge"] = forms.ModelMultipleChoiceField( - queryset=findings, required=True, label="Findings to Merge", - widget=forms.widgets.SelectMultiple(attrs={"size": 10}), - help_text=("Select the findings to merge.")) - self.field_order = ["finding_to_merge_into", "findings_to_merge", "append_description", "add_endpoints", "append_reference"] - - class Meta: - model = Finding - fields = ["append_description", "add_endpoints", "append_reference"] - - class EditRiskAcceptanceForm(forms.ModelForm): # unfortunately django forces us to repeat many things here. choices, default, required etc. recommendation = forms.ChoiceField(choices=Risk_Acceptance.TREATMENT_CHOICES, initial=Risk_Acceptance.TREATMENT_ACCEPT, widget=forms.RadioSelect, label="Security Recommendation") @@ -832,24 +727,6 @@ class Meta: fields = ["path"] -class AddFindingsRiskAcceptanceForm(forms.ModelForm): - - accepted_findings = forms.ModelMultipleChoiceField( - queryset=Finding.objects.none(), - required=True, - label="", - widget=TableCheckboxWidget(attrs={"size": 25}), - ) - - class Meta: - model = Risk_Acceptance - fields = ["accepted_findings"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["accepted_findings"].queryset = get_authorized_findings("edit") - - class CheckForm(forms.ModelForm): options = (("Pass", "Pass"), ("Fail", "Fail"), ("N/A", "N/A")) session_management = forms.ChoiceField(choices=options) @@ -895,715 +772,6 @@ class Meta: from dojo.test.ui.forms import TestForm # noqa: E402, F401 -- backward compat -class AddFindingForm(forms.ModelForm): - title = forms.CharField(max_length=1000) - date = forms.DateField(required=True, - widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) - vulnerability_ids = vulnerability_ids_field - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) - description = forms.CharField(widget=forms.Textarea) - severity = forms.ChoiceField( - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - request = forms.CharField(widget=forms.Textarea, required=False) - response = forms.CharField(widget=forms.Textarea, required=False) - endpoints = forms.ModelMultipleChoiceField(Location.objects.none(), required=False, label="Systems / Endpoints") - endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - references = forms.CharField(widget=forms.Textarea, required=False) - publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.ChoiceField( - required=False, - choices=EFFORT_FOR_FIXING_CHOICES, - error_messages={ - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - - # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", - "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "verified", "false_p", "duplicate", "out_of_scope", - "risk_accepted", "under_defect_review") - - def __init__(self, *args, **kwargs): - req_resp = kwargs.pop("req_resp") - - product = None - if "product" in kwargs: - product = kwargs.pop("product") - - super().__init__(*args, **kwargs) - - if settings.V3_FEATURE_LOCATIONS and product: - self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) - # TODO: Delete this after the move to Locations - elif product: - self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) - else: - self.fields["endpoints"].queryset = Endpoint.objects.none() - - if req_resp: - self.fields["request"].initial = req_resp[0] - self.fields["response"].initial = req_resp[1] - - self.endpoints_to_add_list = [] - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - if ((cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]): - msg = "Duplicate findings cannot be verified or active" - raise forms.ValidationError(msg) - if cleaned_data["false_p"] and cleaned_data["verified"]: - msg = "False positive findings cannot be verified." - raise forms.ValidationError(msg) - if cleaned_data["active"] and "risk_accepted" in cleaned_data and cleaned_data["risk_accepted"]: - msg = "Active findings cannot be risk accepted." - raise forms.ValidationError(msg) - - if settings.V3_FEATURE_LOCATIONS: - endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) - else: - # TODO: Delete this after the move to Locations - endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) - - if errors: - raise forms.ValidationError(errors) - self.endpoints_to_add_list = endpoints_to_add_list - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", - "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date") - - -class AdHocFindingForm(forms.ModelForm): - title = forms.CharField(max_length=1000) - date = forms.DateField(required=True, - widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) - vulnerability_ids = vulnerability_ids_field - - cvss_info = forms.CharField( - label="CVSS", - widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), - required=False, - disabled=True) - - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) - description = forms.CharField(widget=forms.Textarea) - severity = forms.ChoiceField( - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - request = forms.CharField(widget=forms.Textarea, required=False) - response = forms.CharField(widget=forms.Textarea, required=False) - endpoints = forms.ModelMultipleChoiceField(queryset=Location.objects.all(), required=False, - label="Systems / Endpoints") - endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - references = forms.CharField(widget=forms.Textarea, required=False) - publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.ChoiceField( - required=False, - choices=EFFORT_FOR_FIXING_CHOICES, - error_messages={ - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - - # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", - "impact", "request", "response", "steps_to_reproduce", "severity_justification", "endpoints", "endpoints_to_add", "references", - "active", "verified", "false_p", "duplicate", "out_of_scope", "risk_accepted", "under_defect_review", "sla_start_date", "sla_expiration_date") - - def __init__(self, *args, **kwargs): - req_resp = kwargs.pop("req_resp") - - product = None - if "product" in kwargs: - product = kwargs.pop("product") - - super().__init__(*args, **kwargs) - - if settings.V3_FEATURE_LOCATIONS and product: - self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) - # TODO: Delete this after the move to Locations - elif product: - self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) - else: - self.fields["endpoints"].queryset = Endpoint.objects.none() - - if req_resp: - self.fields["request"].initial = req_resp[0] - self.fields["response"].initial = req_resp[1] - - self.endpoints_to_add_list = [] - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - if ((cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]): - msg = "Duplicate findings cannot be verified or active" - raise forms.ValidationError(msg) - if cleaned_data["false_p"] and cleaned_data["verified"]: - msg = "False positive findings cannot be verified." - raise forms.ValidationError(msg) - - if settings.V3_FEATURE_LOCATIONS: - endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) - else: - # TODO: Delete this after the move to Locations - endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) - - self.endpoints_to_add_list = endpoints_to_add_list - - if errors: - raise forms.ValidationError(errors) - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", - "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date", - "sla_expiration_date") - - -class PromoteFindingForm(forms.ModelForm): - title = forms.CharField(max_length=1000) - date = forms.DateField(required=True, - widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) - vulnerability_ids = vulnerability_ids_field - - cvss_info = forms.CharField( - label="CVSS", - widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), - required=False, - disabled=True) - - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) - description = forms.CharField(widget=forms.Textarea) - severity = forms.ChoiceField( - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - endpoints = forms.ModelMultipleChoiceField(Location.objects.none(), required=False, label="Systems / Endpoints") - endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - references = forms.CharField(widget=forms.Textarea, required=False) - - # the onyl reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", - "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", - "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", - "false_p", "duplicate", "out_of_scope", "risk_accept", "under_defect_review") - - def __init__(self, *args, **kwargs): - product = None - if "product" in kwargs: - product = kwargs.pop("product") - - super().__init__(*args, **kwargs) - - if settings.V3_FEATURE_LOCATIONS and product: - self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) - # TODO: Delete this after the move to Locations - elif product: - self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) - else: - self.fields["endpoints"].queryset = Endpoint.objects.none() - - self.endpoints_to_add_list = [] - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - - if settings.V3_FEATURE_LOCATIONS: - endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) - else: - # TODO: Delete this after the move to Locations - endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) - - if errors: - raise forms.ValidationError(errors) - self.endpoints_to_add_list = endpoints_to_add_list - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Finding - exclude = ("reporter", "url", "numerical_severity", "active", "false_p", "verified", "endpoint_status", "cve", "inherited_tags", - "duplicate", "out_of_scope", "under_review", "reviewers", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "planned_remediation_date", "planned_remediation_version", "effort_for_fixing") - - -class FindingForm(forms.ModelForm): - title = forms.CharField(max_length=1000) - group = forms.ModelChoiceField(required=False, queryset=Finding_Group.objects.none(), help_text="The Finding Group to which this finding belongs, leave empty to remove the finding from the group. Groups can only be created via Bulk Edit for now.") - date = forms.DateField(required=True, - widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) - vulnerability_ids = vulnerability_ids_field - - cvss_info = forms.CharField( - label="CVSS", - widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), - required=False, - disabled=True) - - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) - - description = forms.CharField(widget=forms.Textarea) - severity = forms.ChoiceField( - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - request = forms.CharField(widget=forms.Textarea, required=False) - response = forms.CharField(widget=forms.Textarea, required=False) - endpoints = forms.ModelMultipleChoiceField(queryset=Location.objects.none(), required=False, label="Systems / Endpoints") - endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - references = forms.CharField(widget=forms.Textarea, required=False) - - mitigated = forms.DateField(required=False, help_text="Date and time when the flaw has been fixed", widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - mitigated_by = forms.ModelChoiceField(required=False, queryset=Dojo_User.objects.none()) - - publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.ChoiceField( - required=False, - choices=EFFORT_FOR_FIXING_CHOICES, - error_messages={ - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - - # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", - "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", - "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", "false_p", "duplicate", - "out_of_scope", "risk_accept", "under_defect_review") - - def __init__(self, *args, **kwargs): - req_resp = None - if "req_resp" in kwargs: - req_resp = kwargs.pop("req_resp") - - self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \ - else False - - super().__init__(*args, **kwargs) - - if settings.V3_FEATURE_LOCATIONS: - self.fields["endpoints"].queryset = Location.objects.filter(products__product=self.instance.test.engagement.product) - if self.instance and self.instance.pk: - self.fields["endpoints"].initial = Location.objects.filter(findings__finding=self.instance) - else: - # TODO: Delete this after the move to Locations - self.fields["endpoints"].queryset = Endpoint.objects.filter(product=self.instance.test.engagement.product) - if self.instance and self.instance.pk: - self.fields["endpoints"].initial = self.instance.endpoints.all() - - self.fields["mitigated_by"].queryset = get_authorized_users("edit") - - # do not show checkbox if finding is not accepted and simple risk acceptance is disabled - # if checked, always show to allow unaccept also with full risk acceptance enabled - # when adding from template, we don't have access to the test. quickfix for now to just hide simple risk acceptance - if not hasattr(self.instance, "test") or (not self.instance.risk_accepted and not self.instance.test.engagement.product.enable_simple_risk_acceptance): - del self.fields["risk_accepted"] - elif self.instance.risk_accepted: - self.fields["risk_accepted"].help_text = "Uncheck to unaccept the risk. Use full risk acceptance from the dropdown menu if you need advanced settings such as an expiry date." - elif self.instance.test.engagement.product.enable_simple_risk_acceptance: - self.fields["risk_accepted"].help_text = "Check to accept the risk. Use full risk acceptance from the dropdown menu if you need advanced settings such as an expiry date." - - # self.fields['tags'].widget.choices = t - if req_resp: - self.fields["request"].initial = req_resp[0] - self.fields["response"].initial = req_resp[1] - - if self.instance.duplicate: - self.fields["duplicate"].help_text = "Original finding that is being duplicated here (readonly). Use view finding page to manage duplicate relationships. Unchecking duplicate here will reset this findings duplicate status, but will trigger deduplication logic." - else: - self.fields["duplicate"].help_text = "You can mark findings as duplicate only from the view finding page." - - self.fields["sla_start_date"].disabled = True - self.fields["sla_expiration_date"].disabled = True - - if self.can_edit_mitigated_data: - if hasattr(self, "instance"): - self.fields["mitigated"].initial = self.instance.mitigated - self.fields["mitigated_by"].initial = self.instance.mitigated_by - else: - del self.fields["mitigated"] - del self.fields["mitigated_by"] - - if not is_finding_groups_enabled() or not hasattr(self.instance, "test"): - del self.fields["group"] - else: - self.fields["group"].queryset = self.instance.test.finding_group_set.all() - self.fields["group"].initial = self.instance.finding_group - - self.endpoints_to_add_list = [] - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - - if (cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]: - msg = "Duplicate findings cannot be verified or active" - raise forms.ValidationError(msg) - if cleaned_data["false_p"] and cleaned_data["verified"]: - msg = "False positive findings cannot be verified." - raise forms.ValidationError(msg) - if cleaned_data["active"] and "risk_accepted" in cleaned_data and cleaned_data["risk_accepted"]: - msg = "Active findings cannot be risk accepted." - raise forms.ValidationError(msg) - - if settings.V3_FEATURE_LOCATIONS: - endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) - else: - # TODO: Delete this after the move to Locations - endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) - - self.endpoints_to_add_list = endpoints_to_add_list - - if errors: - raise forms.ValidationError(errors) - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - def _post_clean(self): - super()._post_clean() - - if self.can_edit_mitigated_data: - opts = self.instance._meta - try: - opts.get_field("mitigated").save_form_data(self.instance, self.cleaned_data.get("mitigated")) - opts.get_field("mitigated_by").save_form_data(self.instance, self.cleaned_data.get("mitigated_by")) - except forms.ValidationError as e: - self._update_errors(e) - - class Meta: - model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", - "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "sonarqube_issue", - "endpoints", "endpoint_status") - - -class ApplyFindingTemplateForm(forms.Form): - - title = forms.CharField(max_length=1000, required=True) - - cwe = forms.IntegerField(label="CWE", required=False) - vulnerability_ids = vulnerability_ids_field - cvssv3 = forms.CharField(label="CVSSv3", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") - cvssv4 = forms.CharField(label="CVSSv4", max_length=255, required=False) - cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") - - severity = forms.ChoiceField(required=False, choices=SEVERITY_CHOICES, error_messages={"required": "Select valid choice: In Progress, On Hold, Completed", "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - - description = forms.CharField(widget=forms.Textarea) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - references = forms.CharField(widget=forms.Textarea, required=False) - - # Remediation planning fields - fix_available = forms.BooleanField(required=False) - fix_version = forms.CharField(max_length=100, required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.CharField(max_length=99, required=False) - - # Technical details fields - steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) - severity_justification = forms.CharField(widget=forms.Textarea, required=False) - component_name = forms.CharField(max_length=500, required=False) - component_version = forms.CharField(max_length=100, required=False) - - # Notes field - notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") - - # Endpoints field - endpoints = forms.CharField(max_length=5000, required=False, - help_text="Endpoint URLs (one per line)", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - - tags = TagField(required=False, help_text="Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.", initial=Finding.tags.tag_model.objects.all().order_by("name")) - - def __init__(self, template=None, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") - self.template = template - if template: - # Populate vulnerability_ids field initial value - self.fields["vulnerability_ids"].initial = "\n".join(template.vulnerability_ids) - - # Populate CVSS fields from template - if hasattr(template, "cvssv3"): - self.fields["cvssv3"].initial = template.cvssv3 - if hasattr(template, "cvssv4"): - self.fields["cvssv4"].initial = template.cvssv4 - if hasattr(template, "cvssv3_score"): - self.fields["cvssv3_score"].initial = template.cvssv3_score - if hasattr(template, "cvssv4_score"): - self.fields["cvssv4_score"].initial = template.cvssv4_score - - # Populate all other new fields from template - for field_name in ["fix_available", "fix_version", "planned_remediation_version", - "effort_for_fixing", "steps_to_reproduce", "severity_justification", - "component_name", "component_version", "notes"]: - if hasattr(template, field_name): - value = getattr(template, field_name) - if value is not None: - self.fields[field_name].initial = value - - # Populate endpoints - if hasattr(template, "endpoints"): - endpoints_value = template.endpoints - if endpoints_value: - if isinstance(endpoints_value, list): - self.fields["endpoints"].initial = "\n".join(endpoints_value) - else: - self.fields["endpoints"].initial = endpoints_value - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - - if "title" in cleaned_data: - if len(cleaned_data["title"]) <= 0: - msg = "The title is required." - raise forms.ValidationError(msg) - else: - msg = "The title is required." - raise forms.ValidationError(msg) - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - fields = ["title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", - "severity", "description", "mitigation", "impact", "references", "tags", - "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", - "steps_to_reproduce", "severity_justification", "component_name", "component_version", - "notes", "endpoints"] - order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", - "severity", "description", "impact", "steps_to_reproduce", "severity_justification", - "mitigation", "fix_available", "fix_version", "planned_remediation_version", - "effort_for_fixing", "component_name", "component_version", "references", "notes", - "endpoints", "tags") - - -class FindingTemplateForm(forms.ModelForm): - title = forms.CharField(max_length=1000, required=True) - - cwe = forms.IntegerField(label="CWE", required=False) - vulnerability_ids = vulnerability_ids_field - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") - severity = forms.ChoiceField( - required=False, - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - - # Remediation planning fields - fix_available = forms.BooleanField(required=False) - fix_version = forms.CharField(max_length=100, required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.CharField(max_length=99, required=False) - - # Technical details fields - steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) - severity_justification = forms.CharField(widget=forms.Textarea, required=False) - component_name = forms.CharField(max_length=500, required=False) - component_version = forms.CharField(max_length=100, required=False) - - # Notes field - notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") - - # Endpoints field - endpoints = forms.CharField(max_length=5000, required=False, - help_text="Endpoint URLs (one per line)", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - - field_order = ["title", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", - "description", "impact", "steps_to_reproduce", "severity_justification", "mitigation", - "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", - "component_name", "component_version", "references", "notes", "endpoints", "tags"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - class Meta: - model = Finding_Template - order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "severity", "description", "impact", - "steps_to_reproduce", "severity_justification", "mitigation", "fix_available", "fix_version", - "planned_remediation_version", "effort_for_fixing", "component_name", "component_version", - "references", "notes", "endpoints", "tags") - exclude = ("numerical_severity", "is_mitigated", "last_used", "endpoint_status", "cve", "vulnerability_ids_text") - - def clean_cvssv3(self): - value = self.cleaned_data.get("cvssv3") - if value: - try: - cvss3_validator(value) - except ValidationError as e: - raise forms.ValidationError(e.messages) - return value - - def clean_cvssv4(self): - value = self.cleaned_data.get("cvssv4") - if value: - try: - cvss4_validator(value) - except ValidationError as e: - raise forms.ValidationError(e.messages) - return value - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - -class DeleteFindingTemplateForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Finding_Template - fields = ["id"] - - -class FindingBulkUpdateForm(forms.ModelForm): - status = forms.BooleanField(required=False) - risk_acceptance = forms.BooleanField(required=False) - risk_accept = forms.BooleanField(required=False) - risk_unaccept = forms.BooleanField(required=False) - - date = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "datepicker"})) - planned_remediation_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "datepicker"})) - planned_remediation_version = forms.CharField(required=False, max_length=99, widget=forms.TextInput(attrs={"class": "form-control"})) - finding_group = forms.BooleanField(required=False) - finding_group_create = forms.BooleanField(required=False) - finding_group_create_name = forms.CharField(required=False) - finding_group_add = forms.BooleanField(required=False) - add_to_finding_group_id = forms.CharField(required=False) - finding_group_remove = forms.BooleanField(required=False) - finding_group_by = forms.BooleanField(required=False) - finding_group_by_option = forms.CharField(required=False) - - push_to_jira = forms.BooleanField(required=False) - # unlink_from_jira = forms.BooleanField(required=False) - push_to_github = forms.BooleanField(required=False) - tags = TagField(required=False, autocomplete_tags=Finding.tags.tag_model.objects.all().order_by("name")) - notes = forms.CharField(required=False, max_length=1024, widget=forms.TextInput(attrs={"class": "form-control"})) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["severity"].required = False - # we need to defer initialization to prevent multiple initializations if other forms are shown - self.fields["tags"].widget.tag_options = tagulous.models.options.TagOptions(autocomplete_settings={"width": "200px", "defer": True}) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - def clean(self): - cleaned_data = super().clean() - - if (cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]: - msg = "Duplicate findings cannot be verified or active" - raise forms.ValidationError(msg) - if cleaned_data["false_p"] and cleaned_data["verified"]: - msg = "False positive findings cannot be verified." - raise forms.ValidationError(msg) - if cleaned_data["active"] and cleaned_data.get("risk_acceptance") and cleaned_data.get("risk_accept"): - msg = "Active findings cannot be risk accepted." - raise forms.ValidationError(msg) - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Finding - fields = ("severity", "date", "planned_remediation_date", "active", "verified", "false_p", "duplicate", "out_of_scope", - "under_review", "is_mitigated") - - class EditEndpointForm(forms.ModelForm): class Meta: model = Endpoint @@ -1767,170 +935,6 @@ class Meta: fields = ["id"] -class CloseFindingForm(forms.ModelForm): - entry = forms.CharField( - required=True, max_length=2400, - widget=forms.Textarea, label="Notes:", - error_messages={"required": ("The reason for closing a finding is " - "required, please use the text area " - "below to provide documentation.")}) - - mitigated = forms.DateField(required=False, help_text="Date and time when the flaw has been fixed", widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - mitigated_by = forms.ModelChoiceField(required=False, queryset=Dojo_User.objects.none()) - false_p = forms.BooleanField(initial=False, required=False, label="False Positive") - out_of_scope = forms.BooleanField(initial=False, required=False, label="Out of Scope") - duplicate = forms.BooleanField(initial=False, required=False, label="Duplicate") - - def __init__(self, *args, **kwargs): - queryset = kwargs.pop("missing_note_types") - # must pop custom kwargs before calling parent __init__ to avoid unexpected kwarg errors - self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \ - else False - super().__init__(*args, **kwargs) - if len(queryset) == 0: - self.fields["note_type"].widget = forms.HiddenInput() - else: - self.fields["note_type"] = forms.ModelChoiceField(queryset=queryset, label="Note Type", required=True) - - if self.can_edit_mitigated_data: - self.fields["mitigated_by"].queryset = get_authorized_users("edit") - self.fields["mitigated"].initial = self.instance.mitigated - self.fields["mitigated_by"].initial = self.instance.mitigated_by - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - def _post_clean(self): - super()._post_clean() - - if self.can_edit_mitigated_data: - opts = self.instance._meta - if not self.cleaned_data.get("active"): - try: - opts.get_field("mitigated").save_form_data(self.instance, self.cleaned_data.get("mitigated")) - opts.get_field("mitigated_by").save_form_data(self.instance, self.cleaned_data.get("mitigated_by")) - except forms.ValidationError as e: - self._update_errors(e) - - class Meta: - model = Notes - fields = ["note_type", "entry", "mitigated", "mitigated_by", "false_p", "out_of_scope", "duplicate"] - - -class EditPlannedRemediationDateFindingForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - finding = None - if "finding" in kwargs: - finding = kwargs.pop("finding") - - super().__init__(*args, **kwargs) - - self.fields["planned_remediation_date"].required = True - self.fields["planned_remediation_date"].widget = forms.DateInput(attrs={"class": "datepicker"}) - - if finding is not None: - self.fields["planned_remediation_date"].initial = finding.planned_remediation_date - - class Meta: - model = Finding - fields = ["planned_remediation_date"] - - -class DefectFindingForm(forms.ModelForm): - CLOSE_CHOICES = (("Close Finding", "Close Finding"), ("Not Fixed", "Not Fixed")) - defect_choice = forms.ChoiceField(required=True, choices=CLOSE_CHOICES) - - entry = forms.CharField( - required=True, max_length=2400, - widget=forms.Textarea, label="Notes:", - error_messages={"required": ("The reason for closing a finding is " - "required, please use the text area " - "below to provide documentation.")}) - - class Meta: - model = Notes - fields = ["entry"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - -class ClearFindingReviewForm(forms.ModelForm): - entry = forms.CharField( - required=True, max_length=2400, - help_text="Please provide a message.", - widget=forms.Textarea, label="Notes:", - error_messages={"required": ("The reason for clearing a review is " - "required, please use the text area " - "below to provide documentation.")}) - - class Meta: - model = Finding - fields = ["active", "verified", "false_p", "out_of_scope", "duplicate", "is_mitigated"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - -class ReviewFindingForm(forms.Form): - reviewers = forms.MultipleChoiceField( - help_text=( - "Select all users who can review Finding. Only users with " - "at least write permission to this finding can be selected"), - required=False, - ) - entry = forms.CharField( - required=True, max_length=2400, - help_text="Please provide a message for reviewers.", - widget=forms.Textarea, label="Notes:", - error_messages={"required": ("The reason for requesting a review is " - "required, please use the text area " - "below to provide documentation.")}) - allow_all_reviewers = forms.BooleanField( - required=False, - label="Allow All Eligible Reviewers", - help_text=("Checking this box will allow any user in the drop down " - "above to provide a review for this finding")) - - def __init__(self, *args, **kwargs): - finding = kwargs.pop("finding", None) - user = kwargs.pop("user", None) - super().__init__(*args, **kwargs) - # Get the list of users - if finding is not None: - users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, "edit") - else: - users = get_authorized_users("edit").filter(is_active=True) - # Remove the current user - if user is not None: - users = users.exclude(id=user.id) - # Save a copy of the original query to be used in the validator - self.reviewer_queryset = users - # Set the users in the form - self.fields["reviewers"].choices = self._get_choices(self.reviewer_queryset) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - @staticmethod - def _get_choices(queryset): - return [(item.pk, item.get_full_name()) for item in queryset] - - def clean(self): - cleaned_data = super().clean() - if cleaned_data.get("allow_all_reviewers", False): - cleaned_data["reviewers"] = [user.id for user in self.reviewer_queryset] - if len(cleaned_data.get("reviewers", [])) == 0: - msg = "Please select at least one user from the reviewers list" - raise ValidationError(msg) - return cleaned_data - - class Meta: - fields = ["reviewers", "entry", "allow_all_reviewers"] - - class WeeklyMetricsForm(forms.Form): dates = forms.ChoiceField() @@ -2208,36 +1212,6 @@ class CustomReportOptionsForm(forms.Form): report_type = forms.ChoiceField(choices=(("HTML", "HTML"),)) -class DeleteFindingForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Finding - fields = ["id"] - - -class CopyFindingForm(forms.Form): - test = forms.ModelChoiceField( - required=True, - queryset=Test.objects.none(), - error_messages={"required": "*"}) - - def __init__(self, *args, **kwargs): - authorized_lists = kwargs.pop("tests", None) - super().__init__(*args, **kwargs) - self.fields["test"].queryset = authorized_lists - - -class FindingFormID(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Finding - fields = ("id",) - - class Benchmark_Product_SummaryForm(forms.ModelForm): class Meta: @@ -2951,28 +1925,3 @@ def set_permission(self, codename): else: msg = "Neither user or group are set" raise Exception(msg) - - -def hide_cvss_fields_if_disabled(form_instance): - """Hide CVSS fields based on system settings.""" - enable_cvss3 = get_system_setting("enable_cvss3_display", True) - enable_cvss4 = get_system_setting("enable_cvss4_display", True) - - # Hide CVSS3 fields if disabled - if not enable_cvss3: - if "cvssv3" in form_instance.fields: - del form_instance.fields["cvssv3"] - if "cvssv3_score" in form_instance.fields: - del form_instance.fields["cvssv3_score"] - - # Hide CVSS4 fields if disabled - if not enable_cvss4: - if "cvssv4" in form_instance.fields: - del form_instance.fields["cvssv4"] - if "cvssv4_score" in form_instance.fields: - del form_instance.fields["cvssv4_score"] - - # If both are disabled, hide all CVSS related fields - if not enable_cvss3 and not enable_cvss4: - if "cvss_info" in form_instance.fields: - del form_instance.fields["cvss_info"] diff --git a/dojo/metrics/utils.py b/dojo/metrics/utils.py index f72e5d71063..ff12b94f83d 100644 --- a/dojo/metrics/utils.py +++ b/dojo/metrics/utils.py @@ -20,11 +20,13 @@ from dojo.filters import ( MetricsEndpointFilter, MetricsEndpointFilterWithoutObjectLookups, - MetricsFindingFilter, - MetricsFindingFilterWithoutObjectLookups, ) from dojo.finding.helper import ACCEPTED_FINDINGS_QUERY, CLOSED_FINDINGS_QUERY, OPEN_FINDINGS_QUERY from dojo.finding.queries import get_authorized_findings +from dojo.finding.ui.filters import ( + MetricsFindingFilter, + MetricsFindingFilterWithoutObjectLookups, +) from dojo.models import Endpoint_Status, Finding, Product_Type from dojo.utils import ( get_system_setting, diff --git a/dojo/product/ui/views.py b/dojo/product/ui/views.py index a5ef6acbd62..5d6924ae033 100644 --- a/dojo/product/ui/views.py +++ b/dojo/product/ui/views.py @@ -38,6 +38,8 @@ from dojo.filters import ( MetricsEndpointFilter, MetricsEndpointFilterWithoutObjectLookups, +) +from dojo.finding.ui.filters import ( MetricsFindingFilter, MetricsFindingFilterWithoutObjectLookups, ) diff --git a/dojo/reports/views.py b/dojo/reports/views.py index 6b372ad8df2..a6c53a10532 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -21,10 +21,12 @@ EndpointFilter, EndpointFilterWithoutObjectLookups, EndpointReportFilter, +) +from dojo.finding.queries import get_authorized_findings +from dojo.finding.ui.filters import ( ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.finding.queries import get_authorized_findings from dojo.finding.views import BaseListFindings from dojo.forms import ReportOptionsForm from dojo.labels import get_labels diff --git a/dojo/reports/widgets.py b/dojo/reports/widgets.py index aa88d9a4884..2620ecf23d2 100644 --- a/dojo/reports/widgets.py +++ b/dojo/reports/widgets.py @@ -15,6 +15,8 @@ from dojo.filters import ( EndpointFilter, EndpointFilterWithoutObjectLookups, +) +from dojo.finding.ui.filters import ( ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) diff --git a/dojo/search/views.py b/dojo/search/views.py index e7022ede68d..13dd70e1ed1 100644 --- a/dojo/search/views.py +++ b/dojo/search/views.py @@ -12,8 +12,8 @@ from dojo.endpoint.queries import get_authorized_endpoints from dojo.endpoint.views import prefetch_for_endpoints from dojo.engagement.queries import get_authorized_engagements -from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups from dojo.finding.queries import get_authorized_findings, get_authorized_vulnerability_ids, prefetch_for_findings +from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups from dojo.forms import FindingBulkUpdateForm, SimpleSearchForm from dojo.location.queries import get_authorized_locations, prefetch_for_locations from dojo.models import Engagement, Finding, Finding_Template, Languages, Product, Test diff --git a/dojo/test/ui/views.py b/dojo/test/ui/views.py index 7d56d4e6587..55ef772e8a0 100644 --- a/dojo/test/ui/views.py +++ b/dojo/test/ui/views.py @@ -25,8 +25,8 @@ import dojo.finding.helper as finding_helper from dojo.authorization.authorization import user_has_permission_or_403 from dojo.engagement.queries import get_authorized_engagements -from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter from dojo.finding.queries import prefetch_for_findings +from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter from dojo.finding.views import find_available_notetypes from dojo.forms import ( AddFindingForm, diff --git a/unittests/test_filter_finding_mitigation.py b/unittests/test_filter_finding_mitigation.py index 31b3fd5ce95..568c6d3d4ed 100644 --- a/unittests/test_filter_finding_mitigation.py +++ b/unittests/test_filter_finding_mitigation.py @@ -3,7 +3,8 @@ from django.test import TestCase from django.utils import timezone -from dojo.filters import ApiFindingFilter, FindingFilterHelper +from dojo.filters import ApiFindingFilter +from dojo.finding.ui.filters import FindingFilterHelper from dojo.models import ( Dojo_User, Engagement, diff --git a/unittests/test_finding_group_filter_context.py b/unittests/test_finding_group_filter_context.py index f9811aa5942..6af9a7028b0 100644 --- a/unittests/test_finding_group_filter_context.py +++ b/unittests/test_finding_group_filter_context.py @@ -1,6 +1,6 @@ from django.utils.timezone import now -from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups +from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups from dojo.models import ( Dojo_User, Engagement, diff --git a/unittests/test_test_type_active_toggle.py b/unittests/test_test_type_active_toggle.py index 1d0e8a55644..dd2e98d3f04 100644 --- a/unittests/test_test_type_active_toggle.py +++ b/unittests/test_test_type_active_toggle.py @@ -1,7 +1,7 @@ from django.test import TestCase -from dojo.filters import FindingFilter +from dojo.finding.ui.filters import FindingFilter from dojo.models import Test_Type from dojo.utils import get_visible_scan_types From dc204973f33e92a20328800f9474309ac2b5b60f Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 22:34:53 +0200 Subject: [PATCH 3/5] refactor(finding): move views + urls into dojo/finding/ui/ [finding Phase 5] --- dojo/api_v2/views.py | 2 +- dojo/engagement/ui/views.py | 2 +- dojo/finding/{ => ui}/urls.py | 2 +- dojo/finding/{ => ui}/views.py | 0 dojo/reports/views.py | 2 +- dojo/test/ui/views.py | 2 +- dojo/urls.py | 2 +- unittests/test_apply_finding_template.py | 2 +- unittests/test_false_positive_history_logic.py | 2 +- 9 files changed, 8 insertions(+), 8 deletions(-) rename dojo/finding/{ => ui}/urls.py (99%) rename dojo/finding/{ => ui}/views.py (100%) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index bdcaa185f5d..f9f6e60c019 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -67,7 +67,7 @@ ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.finding.views import ( +from dojo.finding.ui.views import ( duplicate_cluster, reset_finding_duplicate_status_internal, set_finding_as_original_internal, diff --git a/dojo/engagement/ui/views.py b/dojo/engagement/ui/views.py index 4c0978dea56..475ff9cf5f9 100644 --- a/dojo/engagement/ui/views.py +++ b/dojo/engagement/ui/views.py @@ -54,7 +54,7 @@ ) from dojo.engagement.ui.forms import DeleteEngagementForm, EngForm from dojo.finding.helper import NOT_ACCEPTED_FINDINGS_QUERY -from dojo.finding.views import find_available_notetypes +from dojo.finding.ui.views import find_available_notetypes from dojo.forms import ( AddFindingsRiskAcceptanceForm, CheckForm, diff --git a/dojo/finding/urls.py b/dojo/finding/ui/urls.py similarity index 99% rename from dojo/finding/urls.py rename to dojo/finding/ui/urls.py index fda259ee895..7389f49ef94 100644 --- a/dojo/finding/urls.py +++ b/dojo/finding/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.finding import views +from dojo.finding.ui import views urlpatterns = [ # CRUD operations diff --git a/dojo/finding/views.py b/dojo/finding/ui/views.py similarity index 100% rename from dojo/finding/views.py rename to dojo/finding/ui/views.py diff --git a/dojo/reports/views.py b/dojo/reports/views.py index a6c53a10532..ff9049738e9 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -27,7 +27,7 @@ ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.finding.views import BaseListFindings +from dojo.finding.ui.views import BaseListFindings from dojo.forms import ReportOptionsForm from dojo.labels import get_labels from dojo.location.models import Location diff --git a/dojo/test/ui/views.py b/dojo/test/ui/views.py index 55ef772e8a0..b89c53cd4e3 100644 --- a/dojo/test/ui/views.py +++ b/dojo/test/ui/views.py @@ -27,7 +27,7 @@ from dojo.engagement.queries import get_authorized_engagements from dojo.finding.queries import prefetch_for_findings from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter -from dojo.finding.views import find_available_notetypes +from dojo.finding.ui.views import find_available_notetypes from dojo.forms import ( AddFindingForm, FindingBulkUpdateForm, diff --git a/dojo/urls.py b/dojo/urls.py index c7f6ac68e52..a87b31a3913 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -58,7 +58,7 @@ from dojo.endpoint.urls import urlpatterns as endpoint_urls from dojo.engagement.api.urls import add_engagement_urls from dojo.engagement.ui.urls import urlpatterns as eng_urls -from dojo.finding.urls import urlpatterns as finding_urls +from dojo.finding.ui.urls import urlpatterns as finding_urls from dojo.finding_group.urls import urlpatterns as finding_group_urls from dojo.github.ui.urls import urlpatterns as github_urls from dojo.home.urls import urlpatterns as home_urls diff --git a/unittests/test_apply_finding_template.py b/unittests/test_apply_finding_template.py index 51404069ac3..468dacbd36f 100644 --- a/unittests/test_apply_finding_template.py +++ b/unittests/test_apply_finding_template.py @@ -13,8 +13,8 @@ Product_Member, Role, ) -from dojo.finding import views from dojo.finding.helper import save_endpoints_template, save_vulnerability_ids_template +from dojo.finding.ui import views from dojo.models import ( Dojo_User, Engagement, diff --git a/unittests/test_false_positive_history_logic.py b/unittests/test_false_positive_history_logic.py index 8748239bedd..5975348e14e 100644 --- a/unittests/test_false_positive_history_logic.py +++ b/unittests/test_false_positive_history_logic.py @@ -6,7 +6,7 @@ from django.conf import settings from dojo.finding.deduplication import do_false_positive_history_batch -from dojo.finding.views import EditFinding +from dojo.finding.ui.views import EditFinding from dojo.location.models import Location, LocationFindingReference from dojo.models import ( Endpoint, From 9989d68a5a143fd8f3bee9824dd48e30b7c1375e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 23:01:24 +0200 Subject: [PATCH 4/5] refactor(finding): extract API layer into dojo/finding/api/ [finding Phase 6,7,8,9] --- dojo/api_v2/serializers.py | 677 +--------------- dojo/api_v2/views.py | 777 ------------------ dojo/filters.py | 224 ------ dojo/finding/api/__init__.py | 1 + dojo/finding/api/filters.py | 247 ++++++ dojo/finding/api/serializer.py | 737 +++++++++++++++++ dojo/finding/api/urls.py | 7 + dojo/finding/api/views.py | 833 ++++++++++++++++++++ dojo/test/api/serializer.py | 4 +- dojo/urls.py | 6 +- unittests/test_filter_finding_mitigation.py | 2 +- unittests/test_rest_framework.py | 3 +- 12 files changed, 1856 insertions(+), 1662 deletions(-) create mode 100644 dojo/finding/api/__init__.py create mode 100644 dojo/finding/api/filters.py create mode 100644 dojo/finding/api/serializer.py create mode 100644 dojo/finding/api/urls.py create mode 100644 dojo/finding/api/views.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 6e57a8b370b..3287619e1a8 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -23,23 +23,14 @@ from rest_framework.exceptions import ValidationError as RestFrameworkValidationError from rest_framework.fields import DictField -import dojo.finding.helper as finding_helper import dojo.risk_acceptance.helper as ra_helper -from dojo.authorization.authorization import user_has_permission -from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import -from dojo.finding.helper import ( - save_endpoints_template, - save_vulnerability_ids, - save_vulnerability_ids_template, -) from dojo.finding.queries import get_authorized_findings from dojo.importers.auto_create_context import AutoCreateContextManager from dojo.importers.base_importer import BaseImporter from dojo.importers.default_importer import DefaultImporter from dojo.importers.default_reimporter import DefaultReImporter -from dojo.jira import services as jira_services -from dojo.location.models import Location, LocationFindingReference +from dojo.location.models import Location from dojo.models import ( IMPORT_ACTIONS, SEVERITIES, @@ -58,7 +49,6 @@ FileUpload, Finding, Finding_Group, - Finding_Template, Language_Type, Languages, Network_Locations, @@ -67,7 +57,6 @@ Notes, Product, Product_API_Scan_Configuration, - Product_Type, Regulation, Risk_Acceptance, SLA_Configuration, @@ -75,16 +64,13 @@ Sonarqube_Issue_Transition, System_Settings, Test, - Test_Type, Tool_Configuration, Tool_Product_Settings, Tool_Type, User, UserContactInfo, - Vulnerability_Id, get_current_date, ) -from dojo.notifications.helper import async_create_notification from dojo.product_announcements import ( LargeScanSizeProductAnnouncement, ScanTypeProductAnnouncement, @@ -94,7 +80,6 @@ requires_file, requires_tool_type, ) -from dojo.user.queries import get_authorized_users from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import is_scan_file_too_large from dojo.validators import ImporterFileExtensionValidator, tag_validator @@ -950,14 +935,6 @@ class Meta: fields = "__all__" -class FindingGroupSerializer(serializers.ModelSerializer): - jira_issue = JIRAIssueSerializer(read_only=True, allow_null=True) - - class Meta: - model = Finding_Group - fields = ("id", "name", "test", "jira_issue") - - from dojo.test.api.serializer import TestSerializer # noqa: E402 -- backward compat re-export @@ -1070,550 +1047,6 @@ class Meta: fields = "__all__" -class FindingMetaSerializer(serializers.ModelSerializer): - class Meta: - model = DojoMeta - fields = ("name", "value") - - -class FindingProdTypeSerializer(serializers.ModelSerializer): - class Meta: - model = Product_Type - fields = ["id", "name"] - - -class FindingProductSerializer(serializers.ModelSerializer): - prod_type = FindingProdTypeSerializer(required=False) - - class Meta: - model = Product - fields = ["id", "name", "prod_type"] - - -class FindingEngagementSerializer(serializers.ModelSerializer): - product = FindingProductSerializer(required=False) - - class Meta: - model = Engagement - fields = [ - "id", - "name", - "description", - "product", - "target_start", - "target_end", - "branch_tag", - "engagement_type", - "build_id", - "commit_hash", - "version", - "created", - "updated", - ] - - -class FindingEnvironmentSerializer(serializers.ModelSerializer): - class Meta: - model = Development_Environment - fields = ["id", "name"] - - -class FindingTestTypeSerializer(serializers.ModelSerializer): - class Meta: - model = Test_Type - fields = ["id", "name"] - - -class FindingTestSerializer(serializers.ModelSerializer): - engagement = FindingEngagementSerializer(required=False) - environment = FindingEnvironmentSerializer(required=False) - test_type = FindingTestTypeSerializer(required=False) - - class Meta: - model = Test - fields = [ - "id", - "title", - "test_type", - "engagement", - "environment", - "branch_tag", - "build_id", - "commit_hash", - "version", - ] - - -class FindingRelatedFieldsSerializer(serializers.Serializer): - test = serializers.SerializerMethodField() - jira = serializers.SerializerMethodField() - - @extend_schema_field(FindingTestSerializer) - def get_test(self, obj): - return FindingTestSerializer(read_only=True).to_representation( - obj.test, - ) - - @extend_schema_field(JIRAIssueSerializer) - def get_jira(self, obj): - issue = jira_services.get_issue(obj) - if issue is None: - return None - return JIRAIssueSerializer(read_only=True).to_representation(issue) - - -class VulnerabilityIdSerializer(serializers.ModelSerializer): - class Meta: - model = Vulnerability_Id - fields = ["vulnerability_id"] - - -class FindingSerializer(serializers.ModelSerializer): - mitigated = serializers.DateTimeField(required=False, allow_null=True) - mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) - tags = TagListSerializerField(required=False) - request_response = serializers.SerializerMethodField() - accepted_risks = serializers.SerializerMethodField() - push_to_jira = serializers.BooleanField(default=False) - found_by = serializers.PrimaryKeyRelatedField( - queryset=Test_Type.objects.all(), many=True, - ) - age = serializers.IntegerField(read_only=True) - sla_days_remaining = serializers.IntegerField(read_only=True, allow_null=True) - finding_meta = FindingMetaSerializer(read_only=True, many=True) - related_fields = serializers.SerializerMethodField(allow_null=True) - # for backwards compatibility - jira_creation = serializers.SerializerMethodField(read_only=True, allow_null=True) - jira_change = serializers.SerializerMethodField(read_only=True, allow_null=True) - display_status = serializers.SerializerMethodField() - finding_groups = FindingGroupSerializer( - source="finding_group_set", many=True, read_only=True, - ) - vulnerability_ids = VulnerabilityIdSerializer( - source="vulnerability_id_set", many=True, required=False, - ) - reporter = serializers.PrimaryKeyRelatedField( - required=False, queryset=User.objects.all(), - ) - endpoints = serializers.PrimaryKeyRelatedField( - source="locations", - many=True, - required=False, - queryset=LocationFindingReference.objects.all(), - ) - - class Meta: - model = Finding - exclude = ( - "cve", - "inherited_tags", - ) - - # TODO: Delete this after the move to Locations - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not settings.V3_FEATURE_LOCATIONS: - self.fields["endpoints"] = serializers.PrimaryKeyRelatedField( - many=True, required=False, queryset=Endpoint.objects.all(), - ) - - @extend_schema_field(RiskAcceptanceSerializer(many=True)) - def get_accepted_risks(self, obj): - request = self.context.get("request") - if request is None: - return [] - if not user_has_permission(request.user, obj, "edit"): - return [] - return RiskAcceptanceSerializer( - obj.risk_acceptance_set.all(), many=True, - ).data - - @extend_schema_field(serializers.DateTimeField()) - def get_jira_creation(self, obj): - return jira_services.get_creation(obj) - - @extend_schema_field(serializers.DateTimeField()) - def get_jira_change(self, obj): - return jira_services.get_change(obj) - - @extend_schema_field(FindingRelatedFieldsSerializer) - def get_related_fields(self, obj): - request = self.context.get("request", None) - if request is None: - return None - - query_params = request.query_params - if query_params.get("related_fields", "false") == "true": - return FindingRelatedFieldsSerializer( - required=False, - ).to_representation(obj) - return None - - def get_display_status(self, obj) -> str: - return obj.status() - - def process_risk_acceptance(self, data): - is_risk_accepted = data.get("risk_accepted") - # Do not take any action if the `risk_accepted` was not passed - if not isinstance(is_risk_accepted, bool): - return - # Determine how to proceed based on the value of `risk_accepted` - if is_risk_accepted and not self.instance.risk_accepted and self.instance.test.engagement.product.enable_simple_risk_acceptance and not data.get("active", False): - ra_helper.simple_risk_accept(self.context["request"].user, self.instance) - elif not is_risk_accepted and self.instance.risk_accepted: # turning off risk_accepted - ra_helper.risk_unaccept(self.context["request"].user, self.instance) - - # Overriding this to push add Push to JIRA functionality - def update(self, instance, validated_data): - # push_all_issues already checked in api views.py - push_to_jira = validated_data.pop("push_to_jira") - - # Save vulnerability ids and pop them - parsed_vulnerability_ids = [] - if (vulnerability_ids := validated_data.pop("vulnerability_id_set", None)): - logger.debug("VULNERABILITY_ID_SET: %s", vulnerability_ids) - parsed_vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_ids) - logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) - validated_data["cve"] = parsed_vulnerability_ids[0] - - # Save the reporter on the finding - if reporter_id := validated_data.get("reporter"): - instance.reporter = reporter_id - - # Persist vulnerability IDs first so model save computes hash including them (if there is no hash yet) - # we can't pass unsaved_vulnerabilitiy_ids to super.update() - if parsed_vulnerability_ids: - save_vulnerability_ids(instance, parsed_vulnerability_ids) - - # Get found_by from validated_data - found_by = validated_data.pop("found_by", None) - # Handle updates to found_by data - if found_by: - instance.found_by.set(found_by) - # If there is no argument entered for found_by, the user would like to clear out the values on the Finding's found_by field - # Findings still maintain original found_by value associated with their test - # In the event the user does not supply the found_by field at all, we do not modify it - elif isinstance(found_by, list) and len(found_by) == 0: - instance.found_by.clear() - - locations = None - if settings.V3_FEATURE_LOCATIONS: - locations = validated_data.pop("locations", None) - - instance = super().update( - instance, validated_data, - ) - - if settings.V3_FEATURE_LOCATIONS and locations is not None: - for location_ref in instance.locations.all(): - location_ref.location.disassociate_from_finding(instance) - for location_ref in locations: - location_ref.location.associate_with_finding(instance) - - if push_to_jira or jira_services.is_keep_in_sync(instance): - # Push synchronously so that we can see jira errors in real time - success, message = jira_services.push(instance, force_sync=True) - if not success: - raise serializers.ValidationError(message) - - return instance - - def validate(self, data): - # Enforce mitigated metadata editability (only when non-null values are provided) - attempting_to_set_mitigated = any( - (field in data) and (data.get(field) is not None) - for field in ["mitigated", "mitigated_by"] - ) - user = getattr(self.context.get("request", None), "user", None) - if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user): - errors = {} - if ("mitigated" in data) and (data.get("mitigated") is not None): - errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] - if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): - errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] - if errors: - raise serializers.ValidationError(errors) - - if self.context["request"].method == "PATCH": - is_active = data.get("active", self.instance.active) - is_verified = data.get("verified", self.instance.verified) - is_duplicate = data.get("duplicate", self.instance.duplicate) - is_false_p = data.get("false_p", self.instance.false_p) - is_risk_accepted = data.get( - "risk_accepted", self.instance.risk_accepted, - ) - else: - is_active = data.get("active", True) - is_verified = data.get("verified", False) - is_duplicate = data.get("duplicate", False) - is_false_p = data.get("false_p", False) - is_risk_accepted = data.get("risk_accepted", False) - - if (is_active or is_verified) and is_duplicate: - msg = "Duplicate findings cannot be verified or active" - raise serializers.ValidationError(msg) - if is_false_p and is_verified: - msg = "False positive findings cannot be verified." - raise serializers.ValidationError(msg) - - if is_risk_accepted and not self.instance.risk_accepted: - if ( - not self.instance.test.engagement.product.enable_simple_risk_acceptance - ): - msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." - raise serializers.ValidationError(msg) - - if is_active and is_risk_accepted: - msg = "Active findings cannot be risk accepted." - raise serializers.ValidationError(msg) - - # assuming we made it past the validations,call risk acceptance properly to make sure notes, etc get created - # doing it here instead of in update because update doesn't know if the value changed - self.process_risk_acceptance(data) - - return data - - def validate_severity(self, value: str) -> str: - if value not in SEVERITIES: - msg = f"Severity must be one of the following: {SEVERITIES}" - raise serializers.ValidationError(msg) - return value - - def build_relational_field(self, field_name, relation_info): - if field_name == "notes": - return NoteSerializer, {"many": True, "read_only": True} - return super().build_relational_field(field_name, relation_info) - - @extend_schema_field(BurpRawRequestResponseSerializer) - def get_request_response(self, obj): - # Not necessarily Burp scan specific - these are just any request/response pairs - burp_req_resp = obj.burprawrequestresponse_set.all() - var = settings.MAX_REQRESP_FROM_API - if var > -1: - burp_req_resp = burp_req_resp[:var] - burp_list = [] - for burp in burp_req_resp: - request = burp.get_request() - response = burp.get_response() - burp_list.append({"request": request, "response": response}) - serialized_burps = BurpRawRequestResponseSerializer( - {"req_resp": burp_list}, - ) - return serialized_burps.data - - -class FindingCreateSerializer(serializers.ModelSerializer): - mitigated = serializers.DateTimeField(required=False, allow_null=True) - mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) - notes = serializers.PrimaryKeyRelatedField( - read_only=True, allow_null=True, required=False, many=True, - ) - test = serializers.PrimaryKeyRelatedField(queryset=Test.objects.all()) - thread_id = serializers.IntegerField(default=0) - found_by = serializers.PrimaryKeyRelatedField( - queryset=Test_Type.objects.all(), many=True, - ) - url = serializers.CharField(allow_null=True, default=None) - tags = TagListSerializerField(required=False) - push_to_jira = serializers.BooleanField(default=False) - vulnerability_ids = VulnerabilityIdSerializer( - source="vulnerability_id_set", many=True, required=False, - ) - reporter = serializers.PrimaryKeyRelatedField( - required=False, queryset=User.objects.all(), - ) - - class Meta: - model = Finding - exclude = ( - "cve", - "inherited_tags", - ) - extra_kwargs = { - "active": {"required": True}, - "verified": {"required": True}, - } - - # Overriding this to push add Push to JIRA functionality - def create(self, validated_data): - logger.debug("Creating finding with validated data: %s", validated_data) - push_to_jira = validated_data.pop("push_to_jira", False) - notes = validated_data.pop("notes", None) - found_by = validated_data.pop("found_by", None) - reviewers = validated_data.pop("reviewers", None) - # Process the vulnerability IDs specially - parsed_vulnerability_ids = [] - if (vulnerability_ids := validated_data.pop("vulnerability_id_set", None)): - logger.debug("VULNERABILITY_ID_SET: %s", vulnerability_ids) - parsed_vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_ids) - logger.debug("PARSED_VULNERABILITY_IDST: %s", parsed_vulnerability_ids) - logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) - validated_data["cve"] = parsed_vulnerability_ids[0] - # validated_data["unsaved_vulnerability_ids"] = parsed_vulnerability_ids - - # super.create() doesn't accept unsaved_vulnerability_ids or dedupe_option=False, so call save directly. - new_finding = Finding(**validated_data) - new_finding.unsaved_vulnerability_ids = parsed_vulnerability_ids or [] - new_finding.save() - - logger.debug(f"New finding CVE: {new_finding.cve}") - - # Deal with all of the many to many things - if notes: - new_finding.notes.set(notes) - if found_by: - new_finding.found_by.set(found_by) - if reviewers: - new_finding.reviewers.set(reviewers) - if parsed_vulnerability_ids: - save_vulnerability_ids(new_finding, parsed_vulnerability_ids) - - if push_to_jira: - jira_services.push(new_finding) - - # Create a notification - dojo_dispatch_task( - async_create_notification, - event="finding_added", - title=_("Addition of %s") % new_finding.title, - finding_id=new_finding.id, - description=_('Finding "%s" was added by %s') % (new_finding.title, new_finding.reporter), - url=reverse("view_finding", args=(new_finding.id,)), - icon="exclamation-triangle", - ) - - return new_finding - - def validate(self, data): - # Ensure mitigated fields are only set when editable is enabled (ignore nulls) - attempting_to_set_mitigated = any( - (field in data) and (data.get(field) is not None) - for field in ["mitigated", "mitigated_by"] - ) - user = getattr(getattr(self.context, "request", None), "user", None) - if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user): - errors = {} - if ("mitigated" in data) and (data.get("mitigated") is not None): - errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] - if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): - errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] - if errors: - raise serializers.ValidationError(errors) - - if "reporter" not in data: - request = self.context["request"] - data["reporter"] = request.user - - if (data.get("active") or data.get("verified")) and data.get( - "duplicate", - ): - msg = "Duplicate findings cannot be verified or active" - raise serializers.ValidationError(msg) - if data.get("false_p") and data.get("verified"): - msg = "False positive findings cannot be verified." - raise serializers.ValidationError(msg) - - if "risk_accepted" in data and data.get("risk_accepted"): - test = data.get("test") - # test = Test.objects.get(id=test_id) - if not test.engagement.product.enable_simple_risk_acceptance: - msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." - raise serializers.ValidationError(msg) - - if ( - data.get("active") - and "risk_accepted" in data - and data.get("risk_accepted") - ): - msg = "Active findings cannot be risk accepted." - raise serializers.ValidationError(msg) - - return data - - def validate_severity(self, value: str) -> str: - if value not in SEVERITIES: - msg = f"Severity must be one of the following: {SEVERITIES}" - raise serializers.ValidationError(msg) - return value - - -class FindingTemplateSerializer(serializers.ModelSerializer): - tags = TagListSerializerField(required=False) - vulnerability_ids = serializers.SerializerMethodField() - endpoints = serializers.SerializerMethodField() - - class Meta: - model = Finding_Template - exclude = ("cve", "vulnerability_ids_text") - - @extend_schema_field(serializers.ListField(child=serializers.CharField())) - def get_vulnerability_ids(self, obj): - """Return vulnerability IDs as a list of strings.""" - return obj.vulnerability_ids - - @extend_schema_field(serializers.ListField(child=serializers.CharField())) - def get_endpoints(self, obj): - """Return endpoints as a list of URL strings.""" - return obj.endpoints if hasattr(obj, "endpoints") else [] - - def create(self, validated_data): - - # Handle vulnerability_ids if provided as list - vulnerability_ids = None - if "vulnerability_ids" in self.initial_data: - vulnerability_ids = self.initial_data.get("vulnerability_ids", []) - if isinstance(vulnerability_ids, str): - # If it's a string, split by newlines - vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] - elif not isinstance(vulnerability_ids, list): - vulnerability_ids = [] - - # Handle endpoints if provided as list - endpoint_urls = None - if "endpoints" in self.initial_data: - endpoint_urls = self.initial_data.get("endpoints", []) - if isinstance(endpoint_urls, str): - # If it's a string, split by newlines - endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] - elif not isinstance(endpoint_urls, list): - endpoint_urls = [] - - new_finding_template = super().create( - validated_data, - ) - - # Save vulnerability IDs using helper - if vulnerability_ids: - save_vulnerability_ids_template(new_finding_template, vulnerability_ids) - - # Save endpoints using helper - if endpoint_urls: - save_endpoints_template(new_finding_template, endpoint_urls) - - return new_finding_template - - def update(self, instance, validated_data): - # Handle vulnerability_ids if provided - if "vulnerability_ids" in self.initial_data: - vulnerability_ids = self.initial_data.get("vulnerability_ids", []) - if isinstance(vulnerability_ids, str): - vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] - elif not isinstance(vulnerability_ids, list): - vulnerability_ids = [] - save_vulnerability_ids_template(instance, vulnerability_ids) - - # Handle endpoints if provided - if "endpoints" in self.initial_data: - endpoint_urls = self.initial_data.get("endpoints", []) - if isinstance(endpoint_urls, str): - endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] - elif not isinstance(endpoint_urls, list): - endpoint_urls = [] - save_endpoints_template(instance, endpoint_urls) - - return super().update(instance, validated_data) - - class CommonImportScanSerializer(serializers.Serializer): scan_date = serializers.DateField( required=False, @@ -2266,87 +1699,6 @@ class Meta: fields = "__all__" -class FindingToNotesSerializer(serializers.Serializer): - finding_id = serializers.PrimaryKeyRelatedField( - queryset=Finding.objects.all(), many=False, allow_null=True, - ) - notes = NoteSerializer(many=True) - - -class FindingToFilesSerializer(serializers.Serializer): - finding_id = serializers.PrimaryKeyRelatedField( - queryset=Finding.objects.all(), many=False, allow_null=True, - ) - files = FileSerializer(many=True) - - def to_representation(self, data): - finding = data.get("finding_id") - files = data.get("files") - new_files = [{ - "id": file.id, - "file": "{site_url}/{file_access_url}".format( - site_url=settings.SITE_URL, - file_access_url=file.get_accessible_url( - finding, finding.id, - ), - ), - "title": file.title, - } for file in files] - return {"finding_id": finding.id, "files": new_files} - - -class FindingCloseSerializer(serializers.ModelSerializer): - is_mitigated = serializers.BooleanField(required=False) - mitigated = serializers.DateTimeField(required=False) - false_p = serializers.BooleanField(required=False) - out_of_scope = serializers.BooleanField(required=False) - duplicate = serializers.BooleanField(required=False) - mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Dojo_User.objects.all()) - note = serializers.CharField(required=False, allow_blank=True) - note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) - - class Meta: - model = Finding - fields = ( - "is_mitigated", - "mitigated", - "false_p", - "out_of_scope", - "duplicate", - "mitigated_by", - "note", - "note_type", - ) - - def validate(self, data): - request = self.context.get("request") - request_user = getattr(request, "user", None) - - mitigated_by_user = data.get("mitigated_by") - if mitigated_by_user is not None: - # Require permission to edit mitigated metadata - if not (request_user and finding_helper.can_edit_mitigated_data(request_user)): - raise serializers.ValidationError({ - "mitigated_by": ["Not allowed to set mitigated_by."], - }) - - # Ensure selected user is authorized (Finding_Edit) - authorized_users = get_authorized_users("edit", user=request_user) - if not authorized_users.filter(id=mitigated_by_user.id).exists(): - raise serializers.ValidationError({ - "mitigated_by": [ - "Selected user is not authorized to be set as mitigated_by.", - ], - }) - - return data - - -class FindingVerifySerializer(serializers.Serializer): - note = serializers.CharField(required=False, allow_blank=True) - note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) - - class ReportGenerateOptionSerializer(serializers.Serializer): include_finding_notes = serializers.BooleanField(default=False) include_finding_images = serializers.BooleanField(default=False) @@ -2368,6 +1720,29 @@ class ExecutiveSummarySerializer(serializers.Serializer): total_findings = serializers.IntegerField() +# Finding serializers live in dojo/finding/api/serializer.py. FindingSerializer and +# FindingToNotesSerializer are re-exported here because ReportGenerateSerializer +# (below) still references them. The remaining finding serializers are re-exported so +# they remain discoverable as members of this module by the prefetcher +# (dojo/api_v2/prefetch/prefetcher.py inspects this module to build its model->serializer +# map); changing that membership would silently change prefetch responses. +from dojo.finding.api.serializer import ( # noqa: E402 -- backward compat + FindingCloseSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingCreateSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingEngagementSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingEnvironmentSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingGroupSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingMetaSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingProdTypeSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingProductSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingSerializer, + FindingTemplateSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingTestTypeSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingToNotesSerializer, + VulnerabilityIdSerializer, # noqa: F401 -- backward compat / prefetcher discovery +) + + class ReportGenerateSerializer(serializers.Serializer): executive_summary = ExecutiveSummarySerializer(many=False, allow_null=True) product_type = ProductTypeSerializer(many=False, read_only=True) @@ -2424,10 +1799,6 @@ class CeleryQueueTaskDetailSerializer(serializers.Serializer): latest_expires = serializers.CharField(allow_null=True, read_only=True) -class FindingNoteSerializer(serializers.Serializer): - note_id = serializers.IntegerField() - - from dojo.notifications.api.serializer import NotificationsSerializer # noqa: E402, F401 -- backward compat diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index f9f6e60c019..cbe7353292c 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -1,11 +1,9 @@ -import base64 import logging import mimetypes from datetime import datetime from pathlib import Path import pghistory -import tagulous from crum import get_current_user from dateutil.relativedelta import relativedelta from django.conf import settings @@ -16,7 +14,6 @@ from django.db.models.functions import Coalesce from django.db.models.query import QuerySet as DjangoQuerySet from django.http import FileResponse -from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend @@ -24,7 +21,6 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( OpenApiParameter, - OpenApiResponse, extend_schema, extend_schema_view, ) @@ -36,7 +32,6 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.response import Response -import dojo.finding.helper as finding_helper from dojo.api_v2 import ( mixins as dojo_mixins, ) @@ -55,9 +50,7 @@ ApiAppAnalysisFilter, ApiDojoMetaFilter, ApiEndpointFilter, - ApiFindingFilter, ApiRiskAcceptanceFilter, - ApiTemplateFindingFilter, ApiUserFilter, ) from dojo.finding.queries import ( @@ -67,11 +60,6 @@ ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.finding.ui.views import ( - duplicate_cluster, - reset_finding_duplicate_status_internal, - set_finding_as_original_internal, -) from dojo.importers.auto_create_context import AutoCreateContextManager from dojo.jira import services as jira_services from dojo.labels import get_labels @@ -84,9 +72,7 @@ DojoMeta, Endpoint, Endpoint_Status, - FileUpload, Finding, - Finding_Template, Language_Type, Languages, Network_Locations, @@ -118,7 +104,6 @@ prefetch_related_findings_for_report, report_url_resolver, ) -from dojo.risk_acceptance import api as ra_api from dojo.risk_acceptance.helper import remove_finding_from_risk_acceptance from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_tests @@ -126,7 +111,6 @@ from dojo.user.authentication import reset_token_for_user from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import ( - generate_file_response, get_celery_queue_details, get_celery_queue_length, get_celery_worker_status, @@ -450,767 +434,6 @@ def get_queryset(self): return get_authorized_app_analysis("view") -# Authorization: configuration -class FindingTemplatesViewSet( - DojoModelViewSet, -): - serializer_class = serializers.FindingTemplateSerializer - queryset = Finding_Template.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiTemplateFindingFilter - permission_classes = (permissions.UserHasConfigurationPermissionStaff,) - - def get_queryset(self): - return Finding_Template.objects.all().order_by("id") - - -# Authorization: object-based -@extend_schema_view( - list=extend_schema( - parameters=[ - OpenApiParameter( - "related_fields", - OpenApiTypes.BOOL, - OpenApiParameter.QUERY, - required=False, - description="Expand finding external relations (engagement, environment, product, \ - product_type, test, test_type)", - ), - OpenApiParameter( - "prefetch", - OpenApiTypes.STR, - OpenApiParameter.QUERY, - required=False, - description="List of fields for which to prefetch model instances and add those to the response", - ), - ], - ), - retrieve=extend_schema( - parameters=[ - OpenApiParameter( - "related_fields", - OpenApiTypes.BOOL, - OpenApiParameter.QUERY, - required=False, - description="Expand finding external relations (engagement, environment, product, \ - product_type, test, test_type)", - ), - OpenApiParameter( - "prefetch", - OpenApiTypes.STR, - OpenApiParameter.QUERY, - required=False, - description="List of fields for which to prefetch model instances and add those to the response", - ), - ], - ), -) -class FindingViewSet( - prefetch.PrefetchListMixin, - prefetch.PrefetchRetrieveMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.CreateModelMixin, - ra_api.AcceptedFindingsMixin, - viewsets.GenericViewSet, - dojo_mixins.DeletePreviewModelMixin, -): - serializer_class = serializers.FindingSerializer - queryset = Finding.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiFindingFilter - permission_classes = ( - IsAuthenticated, - permissions.UserHasFindingPermission, - ) - - # Overriding mixins.UpdateModeMixin perform_update() method to grab push_to_jira - # data and add that as a parameter to .save() - def perform_update(self, serializer): - # IF JIRA is enabled and this product has a JIRA configuration - push_to_jira = serializer.validated_data.get("push_to_jira") - jira_project = jira_services.get_project(serializer.instance) - if get_system_setting("enable_jira") and jira_project: - push_to_jira = push_to_jira or jira_project.push_all_issues - - serializer.save(push_to_jira=push_to_jira) - - def get_queryset(self): - if settings.V3_FEATURE_LOCATIONS: - findings = get_authorized_findings( - "view", - ).prefetch_related( - "locations__location__url", - "reviewers", - "found_by", - "notes", - "risk_acceptance_set", - "test", - "tags", - "jira_issue", - "finding_group_set", - "files", - "burprawrequestresponse_set", - "status_finding", - "finding_meta", - "test__test_type", - "test__engagement", - "test__environment", - "test__engagement__product", - "test__engagement__product__prod_type", - ) - else: - # TODO: Delete this after the move to Locations - findings = get_authorized_findings( - "view", - ).prefetch_related( - "endpoints", - "reviewers", - "found_by", - "notes", - "risk_acceptance_set", - "test", - "tags", - "jira_issue", - "finding_group_set", - "files", - "burprawrequestresponse_set", - "status_finding", - "finding_meta", - "test__test_type", - "test__engagement", - "test__environment", - "test__engagement__product", - "test__engagement__product__prod_type", - ) - - return findings.distinct() - - def get_serializer_class(self): - if self.request and self.request.method == "POST": - return serializers.FindingCreateSerializer - return serializers.FindingSerializer - - @extend_schema( - methods=["POST"], - request=serializers.FindingCloseSerializer, - responses={status.HTTP_200_OK: serializers.FindingCloseSerializer}, - ) - @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def close(self, request, pk=None): - finding = self.get_object() - - if request.method == "POST": - finding_close = serializers.FindingCloseSerializer( - data=request.data, - context={"request": request}, - ) - if finding_close.is_valid(): - # Remove the prefetched tags to avoid issues with delegating to celery - finding.tags._remove_prefetched_objects() - # Use shared helper to perform close operations - finding_helper.close_finding( - finding=finding, - user=request.user, - is_mitigated=finding_close.validated_data["is_mitigated"], - mitigated=(finding_close.validated_data.get("mitigated") if finding_helper.can_edit_mitigated_data(request.user) else timezone.now()), - mitigated_by=finding_close.validated_data.get("mitigated_by") or (request.user if not finding_helper.can_edit_mitigated_data(request.user) else None), - false_p=finding_close.validated_data.get("false_p", False), - out_of_scope=finding_close.validated_data.get("out_of_scope", False), - duplicate=finding_close.validated_data.get("duplicate", False), - note_entry=finding_close.validated_data.get("note"), - note_type=finding_close.validated_data.get("note_type"), - ) - else: - return Response( - finding_close.errors, status=status.HTTP_400_BAD_REQUEST, - ) - serialized_finding = serializers.FindingCloseSerializer(finding, context={"request": request}) - return Response(serialized_finding.data) - - @extend_schema( - methods=["POST"], - request=serializers.FindingVerifySerializer, - responses={status.HTTP_200_OK: serializers.FindingSerializer}, - ) - @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def verify(self, request, pk=None): - finding = self.get_object() - - serializer = serializers.FindingVerifySerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - # Remove prefetched tags to keep queryset state in sync - finding.tags._remove_prefetched_objects() - - finding_helper.verify_finding( - finding=finding, - user=request.user, - note_entry=serializer.validated_data.get("note"), - note_type=serializer.validated_data.get("note_type"), - ) - - serialized_finding = serializers.FindingSerializer(finding, context={"request": request}) - return Response(serialized_finding.data) - - @extend_schema( - methods=["GET"], - responses={status.HTTP_200_OK: serializers.TagSerializer}, - ) - @extend_schema( - methods=["POST"], - request=serializers.TagSerializer, - responses={status.HTTP_201_CREATED: serializers.TagSerializer}, - ) - @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def tags(self, request, pk=None): - finding = self.get_object() - - if request.method == "POST": - new_tags = serializers.TagSerializer(data=request.data) - if new_tags.is_valid(): - all_tags = finding.tags - all_tags = serializers.TagSerializer({"tags": all_tags}).data[ - "tags" - ] - for tag in new_tags.validated_data["tags"]: - for sub_tag in tagulous.utils.parse_tags(tag): - if sub_tag not in all_tags: - all_tags.append(sub_tag) - - new_tags = tagulous.utils.render_tags(all_tags) - - finding.tags = new_tags - finding.save() - else: - return Response( - new_tags.errors, status=status.HTTP_400_BAD_REQUEST, - ) - tags = finding.tags - serialized_tags = serializers.TagSerializer({"tags": tags}) - return Response(serialized_tags.data) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.BurpRawRequestResponseSerializer, - }, - ) - @extend_schema( - methods=["POST"], - request=serializers.BurpRawRequestResponseSerializer, - responses={ - status.HTTP_201_CREATED: serializers.BurpRawRequestResponseSerializer, - }, - ) - @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def request_response(self, request, pk=None): - finding = self.get_object() - - if request.method == "POST": - burps = serializers.BurpRawRequestResponseSerializer( - data=request.data, many=isinstance(request.data, list), - ) - if burps.is_valid(): - for pair in burps.validated_data["req_resp"]: - burp_rr = BurpRawRequestResponse( - finding=finding, - burpRequestBase64=base64.b64encode( - pair["request"].encode("utf-8"), - ), - burpResponseBase64=base64.b64encode( - pair["response"].encode("utf-8"), - ), - ) - burp_rr.clean() - burp_rr.save() - else: - return Response( - burps.errors, status=status.HTTP_400_BAD_REQUEST, - ) - # Not necessarily Burp scan specific - these are just any request/response pairs - burp_req_resp = BurpRawRequestResponse.objects.filter(finding=finding) - var = settings.MAX_REQRESP_FROM_API - if var > -1: - burp_req_resp = burp_req_resp[:var] - - burp_list = [] - for burp in burp_req_resp: - request = burp.get_request() - response = burp.get_response() - burp_list.append({"request": request, "response": response}) - serialized_burps = serializers.BurpRawRequestResponseSerializer( - {"req_resp": burp_list}, - ) - return Response(serialized_burps.data) - - @extend_schema( - methods=["GET"], - responses={status.HTTP_200_OK: serializers.FindingToNotesSerializer}, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewNoteOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.NoteSerializer}, - ) - @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission)) - def notes(self, request, pk=None): - finding = self.get_object() - if request.method == "POST": - new_note = serializers.AddNewNoteOptionSerializer( - data=request.data, - ) - if new_note.is_valid(): - entry = new_note.validated_data["entry"] - private = new_note.validated_data.get("private", False) - note_type = new_note.validated_data.get("note_type", None) - else: - return Response( - new_note.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - if finding.notes: - notes = finding.notes.filter(note_type=note_type).first() - if notes and note_type and note_type.is_single: - return Response("Only one instance of this note_type allowed on a finding.", status=status.HTTP_400_BAD_REQUEST) - - author = request.user - note = Notes( - entry=entry, - author=author, - private=private, - note_type=note_type, - ) - note.save() - # Add an entry to the note history - history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) - note.history.add(history) - # Now add the note to the object - finding.last_reviewed = note.date - finding.last_reviewed_by = author - finding.save(update_fields=["last_reviewed", "last_reviewed_by", "updated"]) - finding.notes.add(note) - # Determine if we need to send any notifications for user mentioned - process_tag_notifications( - request=request, - note=note, - parent_url=request.build_absolute_uri( - reverse("view_finding", args=(finding.id,)), - ), - parent_title=f"Finding: {finding.title}", - ) - - if finding.has_jira_issue: - jira_services.add_comment(finding, note) - elif finding.has_jira_group_issue: - jira_services.add_comment(finding.finding_group, note) - - serialized_note = serializers.NoteSerializer( - {"author": author, "entry": entry, "private": private}, - ) - return Response( - serialized_note.data, status=status.HTTP_201_CREATED, - ) - notes = finding.notes.all() - - serialized_notes = serializers.FindingToNotesSerializer( - {"finding_id": finding, "notes": notes}, - ) - return Response(serialized_notes.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={status.HTTP_200_OK: serializers.FindingToFilesSerializer}, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewFileOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.FileSerializer}, - ) - @action( - detail=True, methods=["get", "post"], parser_classes=(MultiPartParser,), permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def files(self, request, pk=None): - finding = self.get_object() - if request.method == "POST": - new_file = serializers.FileSerializer(data=request.data) - if new_file.is_valid(): - title = new_file.validated_data["title"] - file = new_file.validated_data["file"] - else: - return Response( - new_file.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - file = FileUpload(title=title, file=file) - file.save() - finding.files.add(file) - - serialized_file = serializers.FileSerializer(file) - return Response( - serialized_file.data, status=status.HTTP_201_CREATED, - ) - - files = finding.files.all() - serialized_files = serializers.FindingToFilesSerializer( - {"finding_id": finding, "files": files}, - ) - return Response(serialized_files.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.RawFileSerializer, - }, - ) - @action( - detail=True, - methods=["get"], - url_path=r"files/download/(?P\d+)", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def download_file(self, request, file_id, pk=None): - finding = self.get_object() - # Get the file object - file_object_qs = finding.files.filter(id=file_id) - file_object = ( - file_object_qs.first() if len(file_object_qs) > 0 else None - ) - if file_object is None: - return Response( - {"error": "File ID not associated with Finding"}, - status=status.HTTP_404_NOT_FOUND, - ) - # send file - return generate_file_response(file_object) - - @extend_schema( - request=serializers.FindingNoteSerializer, - responses={status.HTTP_204_NO_CONTENT: ""}, - ) - @action(detail=True, methods=["patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission)) - def remove_note(self, request, pk=None): - """Remove Note From Finding Note""" - finding = self.get_object() - notes = finding.notes.all() - if request.data["note_id"]: - note = get_object_or_404(Notes.objects, id=request.data["note_id"]) - if note not in notes: - return Response( - {"error": "Selected Note is not assigned to this Finding"}, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - return Response( - {"error": "('note_id') parameter missing"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if ( - note.author.username == request.user.username - or request.user.is_superuser - ): - finding.notes.remove(note) - note.delete() - else: - return Response( - {"error": "Delete Failed, You are not the Note's author"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - return Response( - {"Success": "Selected Note has been Removed successfully"}, - status=status.HTTP_204_NO_CONTENT, - ) - - @extend_schema( - methods=["PUT", "PATCH"], - request=serializers.TagSerializer, - responses={status.HTTP_204_NO_CONTENT: ""}, - ) - @action(detail=True, methods=["put", "patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def remove_tags(self, request, pk=None): - """Remove Tag(s) from finding list of tags""" - finding = self.get_object() - delete_tags = serializers.TagSerializer(data=request.data) - if delete_tags.is_valid(): - all_tags = finding.tags - all_tags = serializers.TagSerializer({"tags": all_tags}).data[ - "tags" - ] - - # serializer turns it into a string, but we need a list - del_tags = delete_tags.validated_data["tags"] - if len(del_tags) < 1: - return Response( - {"error": "Empty Tag List Not Allowed"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - for tag in del_tags: - if tag not in all_tags: - return Response( - { - "error": f"'{tag}' is not a valid tag in list '{all_tags}'", - }, - status=status.HTTP_400_BAD_REQUEST, - ) - all_tags.remove(tag) - new_tags = tagulous.utils.render_tags(all_tags) - finding.tags = new_tags - finding.save() - return Response( - {"success": "Tag(s) Removed"}, - status=status.HTTP_204_NO_CONTENT, - ) - return Response( - delete_tags.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - @extend_schema( - responses={ - status.HTTP_200_OK: serializers.FindingSerializer(many=True), - }, - ) - @action( - detail=True, - methods=["get"], - url_path=r"duplicate", - filter_backends=[], - pagination_class=None, - permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def get_duplicate_cluster(self, request, pk): - finding = self.get_object() - result = duplicate_cluster(request, finding) - serializer = serializers.FindingSerializer( - instance=result, many=True, context={"request": request}, - ) - return Response(serializer.data, status=status.HTTP_200_OK) - - @extend_schema( - request=OpenApiTypes.NONE, - responses={status.HTTP_204_NO_CONTENT: ""}, - ) - @action(detail=True, methods=["post"], url_path=r"duplicate/reset", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def reset_finding_duplicate_status(self, request, pk): - self.get_object() - checked_duplicate_id = reset_finding_duplicate_status_internal( - request.user, pk, - ) - if checked_duplicate_id is None: - return Response(status=status.HTTP_400_BAD_REQUEST) - return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - request=OpenApiTypes.NONE, - parameters=[ - OpenApiParameter( - "new_fid", OpenApiTypes.INT, OpenApiParameter.PATH, - ), - ], - responses={status.HTTP_204_NO_CONTENT: ""}, - ) - @action( - detail=True, methods=["post"], url_path=r"original/(?P\d+)", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def set_finding_as_original(self, request, pk, new_fid): - self.get_object() - success = set_finding_as_original_internal(request.user, pk, new_fid) - if not success: - return Response(status=status.HTTP_400_BAD_REQUEST) - return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - request=serializers.ReportGenerateOptionSerializer, - responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, - ) - @action( - detail=False, methods=["post"], - # IsAuthenticated only: report generation requires View permission, - # enforced by the permission-filtered get_queryset(). The viewset's - # permission_classes would check Edit (POST), which is too restrictive. - permission_classes=[IsAuthenticated], - ) - def generate_report(self, request): - findings = self.get_queryset() - options = {} - # prepare post data - report_options = serializers.ReportGenerateOptionSerializer( - data=request.data, - ) - if report_options.is_valid(): - options["include_finding_notes"] = report_options.validated_data[ - "include_finding_notes" - ] - options["include_finding_images"] = report_options.validated_data[ - "include_finding_images" - ] - options[ - "include_executive_summary" - ] = report_options.validated_data["include_executive_summary"] - options[ - "include_table_of_contents" - ] = report_options.validated_data["include_table_of_contents"] - else: - return Response( - report_options.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - data = report_generate(request, findings, options) - report = serializers.ReportGenerateSerializer(data) - return Response(report.data) - - def _get_metadata(self, request, finding): - metadata = DojoMeta.objects.filter(finding=finding) - serializer = serializers.FindingMetaSerializer( - instance=metadata, many=True, - ) - return Response(serializer.data, status=status.HTTP_200_OK) - - def _edit_metadata(self, request, finding): - metadata_name = request.query_params.get("name", None) - if metadata_name is None: - return Response( - "Metadata name is required", status=status.HTTP_400_BAD_REQUEST, - ) - - try: - DojoMeta.objects.update_or_create( - name=metadata_name, - finding=finding, - defaults={ - "name": request.data.get("name"), - "value": request.data.get("value"), - }, - ) - - return Response(data=request.data, status=status.HTTP_200_OK) - except IntegrityError: - return Response( - "Update failed because the new name already exists", - status=status.HTTP_400_BAD_REQUEST, - ) - - def _add_metadata(self, request, finding): - metadata_data = serializers.FindingMetaSerializer(data=request.data) - - if metadata_data.is_valid(): - name = metadata_data.validated_data["name"] - value = metadata_data.validated_data["value"] - - metadata = DojoMeta(finding=finding, name=name, value=value) - try: - metadata.validate_unique() - metadata.save() - except ValidationError: - return Response( - "Create failed probably because the name of the metadata already exists", - status=status.HTTP_400_BAD_REQUEST, - ) - - return Response(data=metadata_data.data, status=status.HTTP_200_OK) - return Response( - metadata_data.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - def _remove_metadata(self, request, finding): - name = request.query_params.get("name", None) - if name is None: - return Response( - "A metadata name must be provided", - status=status.HTTP_400_BAD_REQUEST, - ) - - metadata = get_object_or_404( - DojoMeta.objects, finding=finding, name=name, - ) - metadata.delete() - - return Response("Metadata deleted", status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.FindingMetaSerializer(many=True), - status.HTTP_404_NOT_FOUND: OpenApiResponse( - description="Returned if finding does not exist", - ), - }, - ) - @extend_schema( - methods=["DELETE"], - parameters=[ - OpenApiParameter( - "name", - OpenApiTypes.INT, - OpenApiParameter.QUERY, - required=True, - description="name of the metadata to retrieve. If name is empty, return all the \ - metadata associated with the finding", - ), - ], - responses={ - status.HTTP_200_OK: OpenApiResponse( - description="Returned if the metadata was correctly deleted", - ), - status.HTTP_404_NOT_FOUND: OpenApiResponse( - description="Returned if finding does not exist", - ), - status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description="Returned if there was a problem with the metadata information", - ), - }, - ) - @extend_schema( - methods=["PUT"], - request=serializers.FindingMetaSerializer, - responses={ - status.HTTP_200_OK: serializers.FindingMetaSerializer, - status.HTTP_404_NOT_FOUND: OpenApiResponse( - description="Returned if finding does not exist", - ), - status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description="Returned if there was a problem with the metadata information", - ), - }, - ) - @extend_schema( - methods=["POST"], - request=serializers.FindingMetaSerializer, - responses={ - status.HTTP_200_OK: serializers.FindingMetaSerializer, - status.HTTP_404_NOT_FOUND: OpenApiResponse( - description="Returned if finding does not exist", - ), - status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description="Returned if there was a problem with the metadata information", - ), - }, - ) - @action( - detail=True, - methods=["post", "put", "delete", "get"], - filter_backends=[], - pagination_class=None, - permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def metadata(self, request, pk=None): - finding = self.get_object() - - if request.method == "GET": - return self._get_metadata(request, finding) - if request.method == "POST": - return self._add_metadata(request, finding) - if request.method in {"PUT", "PATCH"}: - return self._edit_metadata(request, finding) - if request.method == "DELETE": - return self._remove_metadata(request, finding) - - return Response( - {"error", "unsupported method"}, status=status.HTTP_400_BAD_REQUEST, - ) - - # Authorization: configuration from dojo.jira.api.views import ( # noqa: E402, F401 backward compat JiraInstanceViewSet, diff --git a/dojo/filters.py b/dojo/filters.py index ba68d7a180a..ffd44138885 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -17,7 +17,6 @@ BooleanFilter, CharFilter, DateFilter, - DateTimeFilter, FilterSet, ModelMultipleChoiceFilter, MultipleChoiceFilter, @@ -27,8 +26,6 @@ ) from django_filters import rest_framework as filters from django_filters.filters import ChoiceFilter -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field from polymorphic.base import ManagerInheritanceWarning # from tagulous.forms import TagWidget @@ -60,7 +57,6 @@ Engagement, Engagement_Survey, Finding, - Finding_Template, Note_Type, Product, Product_Type, @@ -1016,199 +1012,6 @@ def filter(self, qs, value): return super().filter(qs, value) -class ApiFindingFilter(DojoFilter): - # BooleanFilter - active = BooleanFilter(field_name="active") - duplicate = BooleanFilter(field_name="duplicate") - dynamic_finding = BooleanFilter(field_name="dynamic_finding") - false_p = BooleanFilter(field_name="false_p") - is_mitigated = BooleanFilter(field_name="is_mitigated") - out_of_scope = BooleanFilter(field_name="out_of_scope") - static_finding = BooleanFilter(field_name="static_finding") - under_defect_review = BooleanFilter(field_name="under_defect_review") - under_review = BooleanFilter(field_name="under_review") - verified = BooleanFilter(field_name="verified") - has_jira = BooleanFilter(field_name="jira_issue", lookup_expr="isnull", exclude=True) - fix_available = BooleanFilter(field_name="fix_available") - mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") - # CharFilter - component_version = CharFilter(lookup_expr="icontains") - component_name = CharFilter(lookup_expr="icontains") - vulnerability_id = CharFilter(method=custom_vulnerability_id_filter) - description = CharFilter(lookup_expr="icontains") - file_path = CharFilter(lookup_expr="icontains") - hash_code = CharFilter(lookup_expr="icontains") - impact = CharFilter(lookup_expr="icontains") - mitigation = CharFilter(lookup_expr="icontains") - numerical_severity = CharFilter(method=custom_filter, field_name="numerical_severity") - param = CharFilter(lookup_expr="icontains") - payload = CharFilter(lookup_expr="icontains") - references = CharFilter(lookup_expr="icontains") - severity = CharFilter(method=custom_filter, field_name="severity") - severity_justification = CharFilter(lookup_expr="icontains") - steps_to_reproduce = CharFilter(lookup_expr="icontains") - unique_id_from_tool = CharFilter(lookup_expr="icontains") - title = CharFilter(lookup_expr="icontains") - exact_title = CharFilter(field_name="title", lookup_expr="iexact", help_text="Finding title exact match (case-insensitive)") - product_name = CharFilter(lookup_expr="engagement__product__name__iexact", field_name="test", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) - product_name_contains = CharFilter(lookup_expr="engagement__product__name__icontains", field_name="test", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) - product_lifecycle = CharFilter(method=custom_filter, lookup_expr="engagement__product__lifecycle", - field_name="test__engagement__product__lifecycle", label=labels.ASSET_FILTERS_CSV_LIFECYCLES_LABEL) - # DateRangeFilter - created = DateRangeFilter() - date = DateRangeFilter() - discovered_on = DateFilter(field_name="date", lookup_expr="exact") - discovered_before = DateFilter(field_name="date", lookup_expr="lt") - discovered_after = DateFilter(field_name="date", lookup_expr="gt") - jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation") - jira_change = DateRangeFilter(field_name="jira_issue__jira_change") - last_reviewed = DateRangeFilter() - mitigated = DateRangeFilter() - mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", method="filter_mitigated_on") - mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt") - mitigated_after = DateTimeFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After", method="filter_mitigated_after") - # NumberInFilter - cwe = NumberInFilter(field_name="cwe", lookup_expr="in") - defect_review_requested_by = NumberInFilter(field_name="defect_review_requested_by", lookup_expr="in") - endpoints = NumberInFilter(field_name="endpoints", lookup_expr="in") - epss_score = PercentageRangeFilter( - field_name="epss_score", - label="EPSS score range", - help_text=( - "The range of EPSS score percentages to filter on; the min input is a lower bound, " - "the max is an upper bound. Leaving one empty will skip that bound (e.g., leaving " - "the min bound input empty will filter only on the max bound -- filtering on " - '"less than or equal"). Leading 0 required.' - )) - epss_percentile = PercentageRangeFilter( - field_name="epss_percentile", - label="EPSS percentile range", - help_text=( - "The range of EPSS percentiles to filter on; the min input is a lower bound, the max " - "is an upper bound. Leaving one empty will skip that bound (e.g., leaving the min bound " - 'input empty will filter only on the max bound -- filtering on "less than or equal"). Leading 0 required.' - )) - found_by = NumberInFilter(field_name="found_by", lookup_expr="in") - id = NumberInFilter(field_name="id", lookup_expr="in") - last_reviewed_by = NumberInFilter(field_name="last_reviewed_by", lookup_expr="in") - mitigated_by = NumberInFilter(field_name="mitigated_by", lookup_expr="in") - nb_occurences = NumberInFilter(field_name="nb_occurences", lookup_expr="in") - reporter = NumberInFilter(field_name="reporter", lookup_expr="in") - scanner_confidence = NumberInFilter(field_name="scanner_confidence", lookup_expr="in") - review_requested_by = NumberInFilter(field_name="review_requested_by", lookup_expr="in") - reviewers = NumberInFilter(field_name="reviewers", lookup_expr="in") - sast_source_line = NumberInFilter(field_name="sast_source_line", lookup_expr="in") - sonarqube_issue = NumberInFilter(field_name="sonarqube_issue", lookup_expr="in") - test__test_type = NumberInFilter(field_name="test__test_type", lookup_expr="in", label="Test Type") - test__engagement = NumberInFilter(field_name="test__engagement", lookup_expr="in") - test__engagement__product = NumberInFilter(field_name="test__engagement__product", lookup_expr="in") - test__engagement__product__prod_type = NumberInFilter(field_name="test__engagement__product__prod_type", lookup_expr="in") - finding_group = NumberInFilter(field_name="finding_group", lookup_expr="in") - - # ReportRiskAcceptanceFilter - risk_acceptance = extend_schema_field(OpenApiTypes.NUMBER)(ReportRiskAcceptanceFilter()) - - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") - tags = CharFieldInFilter( - field_name="tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags (uses OR for multiple values)") - tags__and = CharFieldFilterANDExpression( - field_name="tags__name", - help_text="Comma separated list of exact tags to match with an AND expression") - test__tags = CharFieldInFilter( - field_name="test__tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags present on test (uses OR for multiple values)") - test__tags__and = CharFieldFilterANDExpression( - field_name="test__tags__name", - help_text="Comma separated list of exact tags to match with an AND expression present on test") - test__engagement__tags = CharFieldInFilter( - field_name="test__engagement__tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags present on engagement (uses OR for multiple values)") - test__engagement__tags__and = CharFieldFilterANDExpression( - field_name="test__engagement__tags__name", - help_text="Comma separated list of exact tags to match with an AND expression present on engagement") - test__engagement__product__tags = CharFieldInFilter( - field_name="test__engagement__product__tags__name", - lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_OR_HELP) - test__engagement__product__tags__and = CharFieldFilterANDExpression( - field_name="test__engagement__product__tags__name", - help_text=labels.ASSET_FILTERS_CSV_TAGS_AND_HELP) - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") - not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on model", exclude="True") - not_test__tags = CharFieldInFilter(field_name="test__tags__name", lookup_expr="in", exclude="True", help_text="Comma separated list of exact tags present on test") - not_test__engagement__tags = CharFieldInFilter(field_name="test__engagement__tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on engagement", - exclude="True") - not_test__engagement__product__tags = CharFieldInFilter( - field_name="test__engagement__product__tags__name", - lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, - exclude="True") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - outside_of_sla = extend_schema_field(OpenApiTypes.NUMBER)(FindingSLAFilter()) - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("active", "active"), - ("component_name", "component_name"), - ("component_version", "component_version"), - ("created", "created"), - ("last_status_update", "last_status_update"), - ("last_reviewed", "last_reviewed"), - ("cwe", "cwe"), - ("date", "date"), - ("duplicate", "duplicate"), - ("dynamic_finding", "dynamic_finding"), - ("false_p", "false_p"), - ("found_by", "found_by"), - ("id", "id"), - ("is_mitigated", "is_mitigated"), - ("numerical_severity", "numerical_severity"), - ("out_of_scope", "out_of_scope"), - ("planned_remediation_date", "planned_remediation_date"), - ("severity", "severity"), - ("sla_expiration_date", "sla_expiration_date"), - ("reviewers", "reviewers"), - ("static_finding", "static_finding"), - ("test__engagement__product__name", "test__engagement__product__name"), - ("title", "title"), - ("under_defect_review", "under_defect_review"), - ("under_review", "under_review"), - ("verified", "verified"), - ), - ) - - class Meta: - model = Finding - exclude = ["url", "thread_id", "notes", "files", - "line", "cve"] - - def filter_mitigated_after(self, queryset, name, value): - if value.hour == 0 and value.minute == 0 and value.second == 0: - value = value.replace(hour=23, minute=59, second=59) - - return queryset.filter(mitigated__gt=value) - - def filter_mitigated_on(self, queryset, name, value): - if value.hour == 0 and value.minute == 0 and value.second == 0: - # we have a simple date without a time, lets get a range from this morning to tonight at 23:59:59:999 - nextday = value + timedelta(days=1) - return queryset.filter(mitigated__gte=value, mitigated__lt=nextday) - - return queryset.filter(mitigated=value) - - def filter_mitigation_available(self, queryset, name, value): - if value: - return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") - return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) - - class PercentageFilter(NumberFilter): def __init__(self, *args, **kwargs): kwargs["method"] = self.filter_percentage @@ -1228,33 +1031,6 @@ def filter_percentage(self, queryset, name, value): return queryset.filter(**lookup_kwargs) -class ApiTemplateFindingFilter(DojoFilter): - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") - tags = CharFieldInFilter( - field_name="tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags (uses OR for multiple values)") - tags__and = CharFieldFilterANDExpression( - field_name="tags__name", - help_text="Comma separated list of exact tags to match with an AND expression") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") - not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on model", exclude="True") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("title", "title"), - ("cwe", "cwe"), - ), - ) - - class Meta: - model = Finding_Template - fields = ["id", "title", "cwe", "severity", "description", - "mitigation"] - - class MetricsEndpointFilterHelper(FilterSet): start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) diff --git a/dojo/finding/api/__init__.py b/dojo/finding/api/__init__.py new file mode 100644 index 00000000000..4d8feaaab6a --- /dev/null +++ b/dojo/finding/api/__init__.py @@ -0,0 +1 @@ +path = "findings" # noqa: RUF067 diff --git a/dojo/finding/api/filters.py b/dojo/finding/api/filters.py new file mode 100644 index 00000000000..f9450aa820f --- /dev/null +++ b/dojo/finding/api/filters.py @@ -0,0 +1,247 @@ +from datetime import timedelta + +from django.db.models import Q +from django_filters import ( + BooleanFilter, + CharFilter, + DateFilter, + DateTimeFilter, + OrderingFilter, +) +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field + +from dojo.filters import ( + CharFieldFilterANDExpression, + CharFieldInFilter, + DateRangeFilter, + DojoFilter, + FindingSLAFilter, + NumberInFilter, + PercentageRangeFilter, + ReportRiskAcceptanceFilter, + custom_filter, + custom_vulnerability_id_filter, + labels, +) +from dojo.models import Finding, Finding_Template + + +class ApiFindingFilter(DojoFilter): + # BooleanFilter + active = BooleanFilter(field_name="active") + duplicate = BooleanFilter(field_name="duplicate") + dynamic_finding = BooleanFilter(field_name="dynamic_finding") + false_p = BooleanFilter(field_name="false_p") + is_mitigated = BooleanFilter(field_name="is_mitigated") + out_of_scope = BooleanFilter(field_name="out_of_scope") + static_finding = BooleanFilter(field_name="static_finding") + under_defect_review = BooleanFilter(field_name="under_defect_review") + under_review = BooleanFilter(field_name="under_review") + verified = BooleanFilter(field_name="verified") + has_jira = BooleanFilter(field_name="jira_issue", lookup_expr="isnull", exclude=True) + fix_available = BooleanFilter(field_name="fix_available") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") + # CharFilter + component_version = CharFilter(lookup_expr="icontains") + component_name = CharFilter(lookup_expr="icontains") + vulnerability_id = CharFilter(method=custom_vulnerability_id_filter) + description = CharFilter(lookup_expr="icontains") + file_path = CharFilter(lookup_expr="icontains") + hash_code = CharFilter(lookup_expr="icontains") + impact = CharFilter(lookup_expr="icontains") + mitigation = CharFilter(lookup_expr="icontains") + numerical_severity = CharFilter(method=custom_filter, field_name="numerical_severity") + param = CharFilter(lookup_expr="icontains") + payload = CharFilter(lookup_expr="icontains") + references = CharFilter(lookup_expr="icontains") + severity = CharFilter(method=custom_filter, field_name="severity") + severity_justification = CharFilter(lookup_expr="icontains") + steps_to_reproduce = CharFilter(lookup_expr="icontains") + unique_id_from_tool = CharFilter(lookup_expr="icontains") + title = CharFilter(lookup_expr="icontains") + exact_title = CharFilter(field_name="title", lookup_expr="iexact", help_text="Finding title exact match (case-insensitive)") + product_name = CharFilter(lookup_expr="engagement__product__name__iexact", field_name="test", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) + product_name_contains = CharFilter(lookup_expr="engagement__product__name__icontains", field_name="test", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) + product_lifecycle = CharFilter(method=custom_filter, lookup_expr="engagement__product__lifecycle", + field_name="test__engagement__product__lifecycle", label=labels.ASSET_FILTERS_CSV_LIFECYCLES_LABEL) + # DateRangeFilter + created = DateRangeFilter() + date = DateRangeFilter() + discovered_on = DateFilter(field_name="date", lookup_expr="exact") + discovered_before = DateFilter(field_name="date", lookup_expr="lt") + discovered_after = DateFilter(field_name="date", lookup_expr="gt") + jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation") + jira_change = DateRangeFilter(field_name="jira_issue__jira_change") + last_reviewed = DateRangeFilter() + mitigated = DateRangeFilter() + mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", method="filter_mitigated_on") + mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt") + mitigated_after = DateTimeFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After", method="filter_mitigated_after") + # NumberInFilter + cwe = NumberInFilter(field_name="cwe", lookup_expr="in") + defect_review_requested_by = NumberInFilter(field_name="defect_review_requested_by", lookup_expr="in") + endpoints = NumberInFilter(field_name="endpoints", lookup_expr="in") + epss_score = PercentageRangeFilter( + field_name="epss_score", + label="EPSS score range", + help_text=( + "The range of EPSS score percentages to filter on; the min input is a lower bound, " + "the max is an upper bound. Leaving one empty will skip that bound (e.g., leaving " + "the min bound input empty will filter only on the max bound -- filtering on " + '"less than or equal"). Leading 0 required.' + )) + epss_percentile = PercentageRangeFilter( + field_name="epss_percentile", + label="EPSS percentile range", + help_text=( + "The range of EPSS percentiles to filter on; the min input is a lower bound, the max " + "is an upper bound. Leaving one empty will skip that bound (e.g., leaving the min bound " + 'input empty will filter only on the max bound -- filtering on "less than or equal"). Leading 0 required.' + )) + found_by = NumberInFilter(field_name="found_by", lookup_expr="in") + id = NumberInFilter(field_name="id", lookup_expr="in") + last_reviewed_by = NumberInFilter(field_name="last_reviewed_by", lookup_expr="in") + mitigated_by = NumberInFilter(field_name="mitigated_by", lookup_expr="in") + nb_occurences = NumberInFilter(field_name="nb_occurences", lookup_expr="in") + reporter = NumberInFilter(field_name="reporter", lookup_expr="in") + scanner_confidence = NumberInFilter(field_name="scanner_confidence", lookup_expr="in") + review_requested_by = NumberInFilter(field_name="review_requested_by", lookup_expr="in") + reviewers = NumberInFilter(field_name="reviewers", lookup_expr="in") + sast_source_line = NumberInFilter(field_name="sast_source_line", lookup_expr="in") + sonarqube_issue = NumberInFilter(field_name="sonarqube_issue", lookup_expr="in") + test__test_type = NumberInFilter(field_name="test__test_type", lookup_expr="in", label="Test Type") + test__engagement = NumberInFilter(field_name="test__engagement", lookup_expr="in") + test__engagement__product = NumberInFilter(field_name="test__engagement__product", lookup_expr="in") + test__engagement__product__prod_type = NumberInFilter(field_name="test__engagement__product__prod_type", lookup_expr="in") + finding_group = NumberInFilter(field_name="finding_group", lookup_expr="in") + + # ReportRiskAcceptanceFilter + risk_acceptance = extend_schema_field(OpenApiTypes.NUMBER)(ReportRiskAcceptanceFilter()) + + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") + tags = CharFieldInFilter( + field_name="tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags (uses OR for multiple values)") + tags__and = CharFieldFilterANDExpression( + field_name="tags__name", + help_text="Comma separated list of exact tags to match with an AND expression") + test__tags = CharFieldInFilter( + field_name="test__tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags present on test (uses OR for multiple values)") + test__tags__and = CharFieldFilterANDExpression( + field_name="test__tags__name", + help_text="Comma separated list of exact tags to match with an AND expression present on test") + test__engagement__tags = CharFieldInFilter( + field_name="test__engagement__tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags present on engagement (uses OR for multiple values)") + test__engagement__tags__and = CharFieldFilterANDExpression( + field_name="test__engagement__tags__name", + help_text="Comma separated list of exact tags to match with an AND expression present on engagement") + test__engagement__product__tags = CharFieldInFilter( + field_name="test__engagement__product__tags__name", + lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_OR_HELP) + test__engagement__product__tags__and = CharFieldFilterANDExpression( + field_name="test__engagement__product__tags__name", + help_text=labels.ASSET_FILTERS_CSV_TAGS_AND_HELP) + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") + not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on model", exclude="True") + not_test__tags = CharFieldInFilter(field_name="test__tags__name", lookup_expr="in", exclude="True", help_text="Comma separated list of exact tags present on test") + not_test__engagement__tags = CharFieldInFilter(field_name="test__engagement__tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on engagement", + exclude="True") + not_test__engagement__product__tags = CharFieldInFilter( + field_name="test__engagement__product__tags__name", + lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, + exclude="True") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + outside_of_sla = extend_schema_field(OpenApiTypes.NUMBER)(FindingSLAFilter()) + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("active", "active"), + ("component_name", "component_name"), + ("component_version", "component_version"), + ("created", "created"), + ("last_status_update", "last_status_update"), + ("last_reviewed", "last_reviewed"), + ("cwe", "cwe"), + ("date", "date"), + ("duplicate", "duplicate"), + ("dynamic_finding", "dynamic_finding"), + ("false_p", "false_p"), + ("found_by", "found_by"), + ("id", "id"), + ("is_mitigated", "is_mitigated"), + ("numerical_severity", "numerical_severity"), + ("out_of_scope", "out_of_scope"), + ("planned_remediation_date", "planned_remediation_date"), + ("severity", "severity"), + ("sla_expiration_date", "sla_expiration_date"), + ("reviewers", "reviewers"), + ("static_finding", "static_finding"), + ("test__engagement__product__name", "test__engagement__product__name"), + ("title", "title"), + ("under_defect_review", "under_defect_review"), + ("under_review", "under_review"), + ("verified", "verified"), + ), + ) + + class Meta: + model = Finding + exclude = ["url", "thread_id", "notes", "files", + "line", "cve"] + + def filter_mitigated_after(self, queryset, name, value): + if value.hour == 0 and value.minute == 0 and value.second == 0: + value = value.replace(hour=23, minute=59, second=59) + + return queryset.filter(mitigated__gt=value) + + def filter_mitigated_on(self, queryset, name, value): + if value.hour == 0 and value.minute == 0 and value.second == 0: + # we have a simple date without a time, lets get a range from this morning to tonight at 23:59:59:999 + nextday = value + timedelta(days=1) + return queryset.filter(mitigated__gte=value, mitigated__lt=nextday) + + return queryset.filter(mitigated=value) + + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + + +class ApiTemplateFindingFilter(DojoFilter): + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") + tags = CharFieldInFilter( + field_name="tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags (uses OR for multiple values)") + tags__and = CharFieldFilterANDExpression( + field_name="tags__name", + help_text="Comma separated list of exact tags to match with an AND expression") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") + not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on model", exclude="True") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("title", "title"), + ("cwe", "cwe"), + ), + ) + + class Meta: + model = Finding_Template + fields = ["id", "title", "cwe", "severity", "description", + "mitigation"] diff --git a/dojo/finding/api/serializer.py b/dojo/finding/api/serializer.py new file mode 100644 index 00000000000..cb17386135c --- /dev/null +++ b/dojo/finding/api/serializer.py @@ -0,0 +1,737 @@ +import logging + +from django.conf import settings +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +import dojo.finding.helper as finding_helper +from dojo.authorization.authorization import user_has_permission +from dojo.celery_dispatch import dojo_dispatch_task +from dojo.finding.helper import ( + save_endpoints_template, + save_vulnerability_ids, + save_vulnerability_ids_template, +) +from dojo.jira import services as jira_services +from dojo.jira.api.serializers import JIRAIssueSerializer +from dojo.location.models import LocationFindingReference +from dojo.models import ( + SEVERITIES, + Development_Environment, + Dojo_User, + DojoMeta, + Endpoint, + Engagement, + Finding, + Finding_Group, + Finding_Template, + Note_Type, + Product, + Product_Type, + Test, + Test_Type, + User, + Vulnerability_Id, +) +from dojo.notifications.helper import async_create_notification +from dojo.user.queries import get_authorized_users + +logger = logging.getLogger(__name__) + + +class FindingGroupSerializer(serializers.ModelSerializer): + jira_issue = JIRAIssueSerializer(read_only=True, allow_null=True) + + class Meta: + model = Finding_Group + fields = ("id", "name", "test", "jira_issue") + + +class FindingMetaSerializer(serializers.ModelSerializer): + class Meta: + model = DojoMeta + fields = ("name", "value") + + +class FindingProdTypeSerializer(serializers.ModelSerializer): + class Meta: + model = Product_Type + fields = ["id", "name"] + + +class FindingProductSerializer(serializers.ModelSerializer): + prod_type = FindingProdTypeSerializer(required=False) + + class Meta: + model = Product + fields = ["id", "name", "prod_type"] + + +class FindingEngagementSerializer(serializers.ModelSerializer): + product = FindingProductSerializer(required=False) + + class Meta: + model = Engagement + fields = [ + "id", + "name", + "description", + "product", + "target_start", + "target_end", + "branch_tag", + "engagement_type", + "build_id", + "commit_hash", + "version", + "created", + "updated", + ] + + +class FindingEnvironmentSerializer(serializers.ModelSerializer): + class Meta: + model = Development_Environment + fields = ["id", "name"] + + +class FindingTestTypeSerializer(serializers.ModelSerializer): + class Meta: + model = Test_Type + fields = ["id", "name"] + + +class FindingTestSerializer(serializers.ModelSerializer): + engagement = FindingEngagementSerializer(required=False) + environment = FindingEnvironmentSerializer(required=False) + test_type = FindingTestTypeSerializer(required=False) + + class Meta: + model = Test + fields = [ + "id", + "title", + "test_type", + "engagement", + "environment", + "branch_tag", + "build_id", + "commit_hash", + "version", + ] + + +class FindingRelatedFieldsSerializer(serializers.Serializer): + test = serializers.SerializerMethodField() + jira = serializers.SerializerMethodField() + + @extend_schema_field(FindingTestSerializer) + def get_test(self, obj): + return FindingTestSerializer(read_only=True).to_representation( + obj.test, + ) + + @extend_schema_field(JIRAIssueSerializer) + def get_jira(self, obj): + issue = jira_services.get_issue(obj) + if issue is None: + return None + return JIRAIssueSerializer(read_only=True).to_representation(issue) + + +class VulnerabilityIdSerializer(serializers.ModelSerializer): + class Meta: + model = Vulnerability_Id + fields = ["vulnerability_id"] + + +class FindingSerializer(serializers.ModelSerializer): + mitigated = serializers.DateTimeField(required=False, allow_null=True) + mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) + request_response = serializers.SerializerMethodField() + accepted_risks = serializers.SerializerMethodField() + push_to_jira = serializers.BooleanField(default=False) + found_by = serializers.PrimaryKeyRelatedField( + queryset=Test_Type.objects.all(), many=True, + ) + age = serializers.IntegerField(read_only=True) + sla_days_remaining = serializers.IntegerField(read_only=True, allow_null=True) + finding_meta = FindingMetaSerializer(read_only=True, many=True) + related_fields = serializers.SerializerMethodField(allow_null=True) + # for backwards compatibility + jira_creation = serializers.SerializerMethodField(read_only=True, allow_null=True) + jira_change = serializers.SerializerMethodField(read_only=True, allow_null=True) + display_status = serializers.SerializerMethodField() + finding_groups = FindingGroupSerializer( + source="finding_group_set", many=True, read_only=True, + ) + vulnerability_ids = VulnerabilityIdSerializer( + source="vulnerability_id_set", many=True, required=False, + ) + reporter = serializers.PrimaryKeyRelatedField( + required=False, queryset=User.objects.all(), + ) + endpoints = serializers.PrimaryKeyRelatedField( + source="locations", + many=True, + required=False, + queryset=LocationFindingReference.objects.all(), + ) + + class Meta: + model = Finding + exclude = ( + "cve", + "inherited_tags", + ) + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + # TODO: Delete this after the move to Locations + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not settings.V3_FEATURE_LOCATIONS: + self.fields["endpoints"] = serializers.PrimaryKeyRelatedField( + many=True, required=False, queryset=Endpoint.objects.all(), + ) + + def get_accepted_risks(self, obj): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + RiskAcceptanceSerializer, + ) + # schema annotation applied lazily at module bottom (avoids circular import) + request = self.context.get("request") + if request is None: + return [] + if not user_has_permission(request.user, obj, "edit"): + return [] + return RiskAcceptanceSerializer( + obj.risk_acceptance_set.all(), many=True, + ).data + + @extend_schema_field(serializers.DateTimeField()) + def get_jira_creation(self, obj): + return jira_services.get_creation(obj) + + @extend_schema_field(serializers.DateTimeField()) + def get_jira_change(self, obj): + return jira_services.get_change(obj) + + @extend_schema_field(FindingRelatedFieldsSerializer) + def get_related_fields(self, obj): + request = self.context.get("request", None) + if request is None: + return None + + query_params = request.query_params + if query_params.get("related_fields", "false") == "true": + return FindingRelatedFieldsSerializer( + required=False, + ).to_representation(obj) + return None + + def get_display_status(self, obj) -> str: + return obj.status() + + def process_risk_acceptance(self, data): + import dojo.risk_acceptance.helper as ra_helper # noqa: PLC0415 -- lazy import, avoids circular dependency + is_risk_accepted = data.get("risk_accepted") + # Do not take any action if the `risk_accepted` was not passed + if not isinstance(is_risk_accepted, bool): + return + # Determine how to proceed based on the value of `risk_accepted` + if is_risk_accepted and not self.instance.risk_accepted and self.instance.test.engagement.product.enable_simple_risk_acceptance and not data.get("active", False): + ra_helper.simple_risk_accept(self.context["request"].user, self.instance) + elif not is_risk_accepted and self.instance.risk_accepted: # turning off risk_accepted + ra_helper.risk_unaccept(self.context["request"].user, self.instance) + + # Overriding this to push add Push to JIRA functionality + def update(self, instance, validated_data): + # push_all_issues already checked in api views.py + push_to_jira = validated_data.pop("push_to_jira") + + # Save vulnerability ids and pop them + parsed_vulnerability_ids = [] + if (vulnerability_ids := validated_data.pop("vulnerability_id_set", None)): + logger.debug("VULNERABILITY_ID_SET: %s", vulnerability_ids) + parsed_vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_ids) + logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) + validated_data["cve"] = parsed_vulnerability_ids[0] + + # Save the reporter on the finding + if reporter_id := validated_data.get("reporter"): + instance.reporter = reporter_id + + # Persist vulnerability IDs first so model save computes hash including them (if there is no hash yet) + # we can't pass unsaved_vulnerabilitiy_ids to super.update() + if parsed_vulnerability_ids: + save_vulnerability_ids(instance, parsed_vulnerability_ids) + + # Get found_by from validated_data + found_by = validated_data.pop("found_by", None) + # Handle updates to found_by data + if found_by: + instance.found_by.set(found_by) + # If there is no argument entered for found_by, the user would like to clear out the values on the Finding's found_by field + # Findings still maintain original found_by value associated with their test + # In the event the user does not supply the found_by field at all, we do not modify it + elif isinstance(found_by, list) and len(found_by) == 0: + instance.found_by.clear() + + locations = None + if settings.V3_FEATURE_LOCATIONS: + locations = validated_data.pop("locations", None) + + instance = super().update( + instance, validated_data, + ) + + if settings.V3_FEATURE_LOCATIONS and locations is not None: + for location_ref in instance.locations.all(): + location_ref.location.disassociate_from_finding(instance) + for location_ref in locations: + location_ref.location.associate_with_finding(instance) + + if push_to_jira or jira_services.is_keep_in_sync(instance): + # Push synchronously so that we can see jira errors in real time + success, message = jira_services.push(instance, force_sync=True) + if not success: + raise serializers.ValidationError(message) + + return instance + + def validate(self, data): + # Enforce mitigated metadata editability (only when non-null values are provided) + attempting_to_set_mitigated = any( + (field in data) and (data.get(field) is not None) + for field in ["mitigated", "mitigated_by"] + ) + user = getattr(self.context.get("request", None), "user", None) + if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user): + errors = {} + if ("mitigated" in data) and (data.get("mitigated") is not None): + errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] + if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): + errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] + if errors: + raise serializers.ValidationError(errors) + + if self.context["request"].method == "PATCH": + is_active = data.get("active", self.instance.active) + is_verified = data.get("verified", self.instance.verified) + is_duplicate = data.get("duplicate", self.instance.duplicate) + is_false_p = data.get("false_p", self.instance.false_p) + is_risk_accepted = data.get( + "risk_accepted", self.instance.risk_accepted, + ) + else: + is_active = data.get("active", True) + is_verified = data.get("verified", False) + is_duplicate = data.get("duplicate", False) + is_false_p = data.get("false_p", False) + is_risk_accepted = data.get("risk_accepted", False) + + if (is_active or is_verified) and is_duplicate: + msg = "Duplicate findings cannot be verified or active" + raise serializers.ValidationError(msg) + if is_false_p and is_verified: + msg = "False positive findings cannot be verified." + raise serializers.ValidationError(msg) + + if is_risk_accepted and not self.instance.risk_accepted: + if ( + not self.instance.test.engagement.product.enable_simple_risk_acceptance + ): + msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." + raise serializers.ValidationError(msg) + + if is_active and is_risk_accepted: + msg = "Active findings cannot be risk accepted." + raise serializers.ValidationError(msg) + + # assuming we made it past the validations,call risk acceptance properly to make sure notes, etc get created + # doing it here instead of in update because update doesn't know if the value changed + self.process_risk_acceptance(data) + + return data + + def validate_severity(self, value: str) -> str: + if value not in SEVERITIES: + msg = f"Severity must be one of the following: {SEVERITIES}" + raise serializers.ValidationError(msg) + return value + + def build_relational_field(self, field_name, relation_info): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + NoteSerializer, + ) + if field_name == "notes": + return NoteSerializer, {"many": True, "read_only": True} + return super().build_relational_field(field_name, relation_info) + + def get_request_response(self, obj): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + BurpRawRequestResponseSerializer, + ) + # Not necessarily Burp scan specific - these are just any request/response pairs + burp_req_resp = obj.burprawrequestresponse_set.all() + var = settings.MAX_REQRESP_FROM_API + if var > -1: + burp_req_resp = burp_req_resp[:var] + burp_list = [] + for burp in burp_req_resp: + request = burp.get_request() + response = burp.get_response() + burp_list.append({"request": request, "response": response}) + serialized_burps = BurpRawRequestResponseSerializer( + {"req_resp": burp_list}, + ) + return serialized_burps.data + + +class FindingCreateSerializer(serializers.ModelSerializer): + mitigated = serializers.DateTimeField(required=False, allow_null=True) + mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) + notes = serializers.PrimaryKeyRelatedField( + read_only=True, allow_null=True, required=False, many=True, + ) + test = serializers.PrimaryKeyRelatedField(queryset=Test.objects.all()) + thread_id = serializers.IntegerField(default=0) + found_by = serializers.PrimaryKeyRelatedField( + queryset=Test_Type.objects.all(), many=True, + ) + url = serializers.CharField(allow_null=True, default=None) + push_to_jira = serializers.BooleanField(default=False) + vulnerability_ids = VulnerabilityIdSerializer( + source="vulnerability_id_set", many=True, required=False, + ) + reporter = serializers.PrimaryKeyRelatedField( + required=False, queryset=User.objects.all(), + ) + + class Meta: + model = Finding + exclude = ( + "cve", + "inherited_tags", + ) + extra_kwargs = { + "active": {"required": True}, + "verified": {"required": True}, + } + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + # Overriding this to push add Push to JIRA functionality + def create(self, validated_data): + logger.debug("Creating finding with validated data: %s", validated_data) + push_to_jira = validated_data.pop("push_to_jira", False) + notes = validated_data.pop("notes", None) + found_by = validated_data.pop("found_by", None) + reviewers = validated_data.pop("reviewers", None) + # Process the vulnerability IDs specially + parsed_vulnerability_ids = [] + if (vulnerability_ids := validated_data.pop("vulnerability_id_set", None)): + logger.debug("VULNERABILITY_ID_SET: %s", vulnerability_ids) + parsed_vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_ids) + logger.debug("PARSED_VULNERABILITY_IDST: %s", parsed_vulnerability_ids) + logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) + validated_data["cve"] = parsed_vulnerability_ids[0] + # validated_data["unsaved_vulnerability_ids"] = parsed_vulnerability_ids + + # super.create() doesn't accept unsaved_vulnerability_ids or dedupe_option=False, so call save directly. + new_finding = Finding(**validated_data) + new_finding.unsaved_vulnerability_ids = parsed_vulnerability_ids or [] + new_finding.save() + + logger.debug(f"New finding CVE: {new_finding.cve}") + + # Deal with all of the many to many things + if notes: + new_finding.notes.set(notes) + if found_by: + new_finding.found_by.set(found_by) + if reviewers: + new_finding.reviewers.set(reviewers) + if parsed_vulnerability_ids: + save_vulnerability_ids(new_finding, parsed_vulnerability_ids) + + if push_to_jira: + jira_services.push(new_finding) + + # Create a notification + dojo_dispatch_task( + async_create_notification, + event="finding_added", + title=_("Addition of %s") % new_finding.title, + finding_id=new_finding.id, + description=_('Finding "%s" was added by %s') % (new_finding.title, new_finding.reporter), + url=reverse("view_finding", args=(new_finding.id,)), + icon="exclamation-triangle", + ) + + return new_finding + + def validate(self, data): + # Ensure mitigated fields are only set when editable is enabled (ignore nulls) + attempting_to_set_mitigated = any( + (field in data) and (data.get(field) is not None) + for field in ["mitigated", "mitigated_by"] + ) + user = getattr(getattr(self.context, "request", None), "user", None) + if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user): + errors = {} + if ("mitigated" in data) and (data.get("mitigated") is not None): + errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] + if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): + errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] + if errors: + raise serializers.ValidationError(errors) + + if "reporter" not in data: + request = self.context["request"] + data["reporter"] = request.user + + if (data.get("active") or data.get("verified")) and data.get( + "duplicate", + ): + msg = "Duplicate findings cannot be verified or active" + raise serializers.ValidationError(msg) + if data.get("false_p") and data.get("verified"): + msg = "False positive findings cannot be verified." + raise serializers.ValidationError(msg) + + if "risk_accepted" in data and data.get("risk_accepted"): + test = data.get("test") + # test = Test.objects.get(id=test_id) + if not test.engagement.product.enable_simple_risk_acceptance: + msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." + raise serializers.ValidationError(msg) + + if ( + data.get("active") + and "risk_accepted" in data + and data.get("risk_accepted") + ): + msg = "Active findings cannot be risk accepted." + raise serializers.ValidationError(msg) + + return data + + def validate_severity(self, value: str) -> str: + if value not in SEVERITIES: + msg = f"Severity must be one of the following: {SEVERITIES}" + raise serializers.ValidationError(msg) + return value + + +class FindingTemplateSerializer(serializers.ModelSerializer): + vulnerability_ids = serializers.SerializerMethodField() + endpoints = serializers.SerializerMethodField() + + class Meta: + model = Finding_Template + exclude = ("cve", "vulnerability_ids_text") + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + @extend_schema_field(serializers.ListField(child=serializers.CharField())) + def get_vulnerability_ids(self, obj): + """Return vulnerability IDs as a list of strings.""" + return obj.vulnerability_ids + + @extend_schema_field(serializers.ListField(child=serializers.CharField())) + def get_endpoints(self, obj): + """Return endpoints as a list of URL strings.""" + return obj.endpoints if hasattr(obj, "endpoints") else [] + + def create(self, validated_data): + + # Handle vulnerability_ids if provided as list + vulnerability_ids = None + if "vulnerability_ids" in self.initial_data: + vulnerability_ids = self.initial_data.get("vulnerability_ids", []) + if isinstance(vulnerability_ids, str): + # If it's a string, split by newlines + vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] + elif not isinstance(vulnerability_ids, list): + vulnerability_ids = [] + + # Handle endpoints if provided as list + endpoint_urls = None + if "endpoints" in self.initial_data: + endpoint_urls = self.initial_data.get("endpoints", []) + if isinstance(endpoint_urls, str): + # If it's a string, split by newlines + endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] + elif not isinstance(endpoint_urls, list): + endpoint_urls = [] + + new_finding_template = super().create( + validated_data, + ) + + # Save vulnerability IDs using helper + if vulnerability_ids: + save_vulnerability_ids_template(new_finding_template, vulnerability_ids) + + # Save endpoints using helper + if endpoint_urls: + save_endpoints_template(new_finding_template, endpoint_urls) + + return new_finding_template + + def update(self, instance, validated_data): + # Handle vulnerability_ids if provided + if "vulnerability_ids" in self.initial_data: + vulnerability_ids = self.initial_data.get("vulnerability_ids", []) + if isinstance(vulnerability_ids, str): + vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] + elif not isinstance(vulnerability_ids, list): + vulnerability_ids = [] + save_vulnerability_ids_template(instance, vulnerability_ids) + + # Handle endpoints if provided + if "endpoints" in self.initial_data: + endpoint_urls = self.initial_data.get("endpoints", []) + if isinstance(endpoint_urls, str): + endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] + elif not isinstance(endpoint_urls, list): + endpoint_urls = [] + save_endpoints_template(instance, endpoint_urls) + + return super().update(instance, validated_data) + + +class FindingToNotesSerializer(serializers.Serializer): + finding_id = serializers.PrimaryKeyRelatedField( + queryset=Finding.objects.all(), many=False, allow_null=True, + ) + + def get_fields(self): + from dojo.api_v2.serializers import NoteSerializer # noqa: PLC0415 -- lazy import, avoids circular dependency + fields = super().get_fields() + fields["notes"] = NoteSerializer(many=True) + return fields + + +class FindingToFilesSerializer(serializers.Serializer): + finding_id = serializers.PrimaryKeyRelatedField( + queryset=Finding.objects.all(), many=False, allow_null=True, + ) + + def get_fields(self): + from dojo.api_v2.serializers import FileSerializer # noqa: PLC0415 -- lazy import, avoids circular dependency + fields = super().get_fields() + fields["files"] = FileSerializer(many=True) + return fields + + def to_representation(self, data): + finding = data.get("finding_id") + files = data.get("files") + new_files = [{ + "id": file.id, + "file": "{site_url}/{file_access_url}".format( + site_url=settings.SITE_URL, + file_access_url=file.get_accessible_url( + finding, finding.id, + ), + ), + "title": file.title, + } for file in files] + return {"finding_id": finding.id, "files": new_files} + + +class FindingCloseSerializer(serializers.ModelSerializer): + is_mitigated = serializers.BooleanField(required=False) + mitigated = serializers.DateTimeField(required=False) + false_p = serializers.BooleanField(required=False) + out_of_scope = serializers.BooleanField(required=False) + duplicate = serializers.BooleanField(required=False) + mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Dojo_User.objects.all()) + note = serializers.CharField(required=False, allow_blank=True) + note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) + + class Meta: + model = Finding + fields = ( + "is_mitigated", + "mitigated", + "false_p", + "out_of_scope", + "duplicate", + "mitigated_by", + "note", + "note_type", + ) + + def validate(self, data): + request = self.context.get("request") + request_user = getattr(request, "user", None) + + mitigated_by_user = data.get("mitigated_by") + if mitigated_by_user is not None: + # Require permission to edit mitigated metadata + if not (request_user and finding_helper.can_edit_mitigated_data(request_user)): + raise serializers.ValidationError({ + "mitigated_by": ["Not allowed to set mitigated_by."], + }) + + # Ensure selected user is authorized (Finding_Edit) + authorized_users = get_authorized_users("edit", user=request_user) + if not authorized_users.filter(id=mitigated_by_user.id).exists(): + raise serializers.ValidationError({ + "mitigated_by": [ + "Selected user is not authorized to be set as mitigated_by.", + ], + }) + + return data + + +class FindingVerifySerializer(serializers.Serializer): + note = serializers.CharField(required=False, allow_blank=True) + note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) + + +class FindingNoteSerializer(serializers.Serializer): + note_id = serializers.IntegerField() + + +def _apply_schema_overrides(): + # Apply @extend_schema_field annotations that reference serializers which remain + # in dojo.api_v2.serializers. These are applied here (rather than as class-body + # decorators) so the module carries no top-level dojo.api_v2.serializers import, + # which would create a circular dependency. drf-spectacular only reads these + # overrides at schema generation time, so applying them lazily on import is fine. + from drf_spectacular.utils import set_override # noqa: PLC0415 -- lazy import, avoids circular dependency + + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + BurpRawRequestResponseSerializer, + RiskAcceptanceSerializer, + ) + set_override(FindingSerializer.get_accepted_risks, "field", RiskAcceptanceSerializer(many=True)) + set_override(FindingSerializer.get_request_response, "field", BurpRawRequestResponseSerializer) + + +_apply_schema_overrides() diff --git a/dojo/finding/api/urls.py b/dojo/finding/api/urls.py new file mode 100644 index 00000000000..3a2e8f3143d --- /dev/null +++ b/dojo/finding/api/urls.py @@ -0,0 +1,7 @@ +from dojo.finding.api.views import FindingTemplatesViewSet, FindingViewSet + + +def add_finding_urls(router): + router.register("finding_templates", FindingTemplatesViewSet, basename="finding_template") + router.register("findings", FindingViewSet, basename="finding") + return router diff --git a/dojo/finding/api/views.py b/dojo/finding/api/views.py new file mode 100644 index 00000000000..877657165f3 --- /dev/null +++ b/dojo/finding/api/views.py @@ -0,0 +1,833 @@ +import base64 +import logging + +import tagulous +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +import dojo.finding.helper as finding_helper +from dojo.api_v2 import ( + mixins as dojo_mixins, +) +from dojo.api_v2 import ( + prefetch, +) +from dojo.api_v2 import ( + serializers as api_v2_serializers, +) +from dojo.api_v2.views import DojoModelViewSet, report_generate +from dojo.authorization import api_permissions as permissions +from dojo.finding.api.filters import ApiFindingFilter, ApiTemplateFindingFilter +from dojo.finding.api.serializer import ( + FindingCloseSerializer, + FindingCreateSerializer, + FindingMetaSerializer, + FindingNoteSerializer, + FindingSerializer, + FindingTemplateSerializer, + FindingToFilesSerializer, + FindingToNotesSerializer, + FindingVerifySerializer, +) +from dojo.finding.queries import get_authorized_findings +from dojo.finding.ui.views import ( + duplicate_cluster, + reset_finding_duplicate_status_internal, + set_finding_as_original_internal, +) +from dojo.jira import services as jira_services +from dojo.models import ( + BurpRawRequestResponse, + DojoMeta, + FileUpload, + Finding, + Finding_Template, + NoteHistory, + Notes, +) +from dojo.risk_acceptance import api as ra_api +from dojo.utils import ( + generate_file_response, + get_system_setting, + process_tag_notifications, +) + +logger = logging.getLogger(__name__) + + +# Authorization: configuration +class FindingTemplatesViewSet( + DojoModelViewSet, +): + serializer_class = FindingTemplateSerializer + queryset = Finding_Template.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiTemplateFindingFilter + permission_classes = (permissions.UserHasConfigurationPermissionStaff,) + + def get_queryset(self): + return Finding_Template.objects.all().order_by("id") + + +# Authorization: object-based +@extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + "related_fields", + OpenApiTypes.BOOL, + OpenApiParameter.QUERY, + required=False, + description="Expand finding external relations (engagement, environment, product, \ + product_type, test, test_type)", + ), + OpenApiParameter( + "prefetch", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + description="List of fields for which to prefetch model instances and add those to the response", + ), + ], + ), + retrieve=extend_schema( + parameters=[ + OpenApiParameter( + "related_fields", + OpenApiTypes.BOOL, + OpenApiParameter.QUERY, + required=False, + description="Expand finding external relations (engagement, environment, product, \ + product_type, test, test_type)", + ), + OpenApiParameter( + "prefetch", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + description="List of fields for which to prefetch model instances and add those to the response", + ), + ], + ), +) +class FindingViewSet( + prefetch.PrefetchListMixin, + prefetch.PrefetchRetrieveMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.CreateModelMixin, + ra_api.AcceptedFindingsMixin, + viewsets.GenericViewSet, + dojo_mixins.DeletePreviewModelMixin, +): + serializer_class = FindingSerializer + queryset = Finding.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiFindingFilter + permission_classes = ( + IsAuthenticated, + permissions.UserHasFindingPermission, + ) + + # Overriding mixins.UpdateModeMixin perform_update() method to grab push_to_jira + # data and add that as a parameter to .save() + def perform_update(self, serializer): + # IF JIRA is enabled and this product has a JIRA configuration + push_to_jira = serializer.validated_data.get("push_to_jira") + jira_project = jira_services.get_project(serializer.instance) + if get_system_setting("enable_jira") and jira_project: + push_to_jira = push_to_jira or jira_project.push_all_issues + + serializer.save(push_to_jira=push_to_jira) + + def get_queryset(self): + if settings.V3_FEATURE_LOCATIONS: + findings = get_authorized_findings( + "view", + ).prefetch_related( + "locations__location__url", + "reviewers", + "found_by", + "notes", + "risk_acceptance_set", + "test", + "tags", + "jira_issue", + "finding_group_set", + "files", + "burprawrequestresponse_set", + "status_finding", + "finding_meta", + "test__test_type", + "test__engagement", + "test__environment", + "test__engagement__product", + "test__engagement__product__prod_type", + ) + else: + # TODO: Delete this after the move to Locations + findings = get_authorized_findings( + "view", + ).prefetch_related( + "endpoints", + "reviewers", + "found_by", + "notes", + "risk_acceptance_set", + "test", + "tags", + "jira_issue", + "finding_group_set", + "files", + "burprawrequestresponse_set", + "status_finding", + "finding_meta", + "test__test_type", + "test__engagement", + "test__environment", + "test__engagement__product", + "test__engagement__product__prod_type", + ) + + return findings.distinct() + + def get_serializer_class(self): + if self.request and self.request.method == "POST": + return FindingCreateSerializer + return FindingSerializer + + @extend_schema( + methods=["POST"], + request=FindingCloseSerializer, + responses={status.HTTP_200_OK: FindingCloseSerializer}, + ) + @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def close(self, request, pk=None): + finding = self.get_object() + + if request.method == "POST": + finding_close = FindingCloseSerializer( + data=request.data, + context={"request": request}, + ) + if finding_close.is_valid(): + # Remove the prefetched tags to avoid issues with delegating to celery + finding.tags._remove_prefetched_objects() + # Use shared helper to perform close operations + finding_helper.close_finding( + finding=finding, + user=request.user, + is_mitigated=finding_close.validated_data["is_mitigated"], + mitigated=(finding_close.validated_data.get("mitigated") if finding_helper.can_edit_mitigated_data(request.user) else timezone.now()), + mitigated_by=finding_close.validated_data.get("mitigated_by") or (request.user if not finding_helper.can_edit_mitigated_data(request.user) else None), + false_p=finding_close.validated_data.get("false_p", False), + out_of_scope=finding_close.validated_data.get("out_of_scope", False), + duplicate=finding_close.validated_data.get("duplicate", False), + note_entry=finding_close.validated_data.get("note"), + note_type=finding_close.validated_data.get("note_type"), + ) + else: + return Response( + finding_close.errors, status=status.HTTP_400_BAD_REQUEST, + ) + serialized_finding = FindingCloseSerializer(finding, context={"request": request}) + return Response(serialized_finding.data) + + @extend_schema( + methods=["POST"], + request=FindingVerifySerializer, + responses={status.HTTP_200_OK: FindingSerializer}, + ) + @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def verify(self, request, pk=None): + finding = self.get_object() + + serializer = FindingVerifySerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Remove prefetched tags to keep queryset state in sync + finding.tags._remove_prefetched_objects() + + finding_helper.verify_finding( + finding=finding, + user=request.user, + note_entry=serializer.validated_data.get("note"), + note_type=serializer.validated_data.get("note_type"), + ) + + serialized_finding = FindingSerializer(finding, context={"request": request}) + return Response(serialized_finding.data) + + @extend_schema( + methods=["GET"], + responses={status.HTTP_200_OK: api_v2_serializers.TagSerializer}, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.TagSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.TagSerializer}, + ) + @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def tags(self, request, pk=None): + finding = self.get_object() + + if request.method == "POST": + new_tags = api_v2_serializers.TagSerializer(data=request.data) + if new_tags.is_valid(): + all_tags = finding.tags + all_tags = api_v2_serializers.TagSerializer({"tags": all_tags}).data[ + "tags" + ] + for tag in new_tags.validated_data["tags"]: + for sub_tag in tagulous.utils.parse_tags(tag): + if sub_tag not in all_tags: + all_tags.append(sub_tag) + + new_tags = tagulous.utils.render_tags(all_tags) + + finding.tags = new_tags + finding.save() + else: + return Response( + new_tags.errors, status=status.HTTP_400_BAD_REQUEST, + ) + tags = finding.tags + serialized_tags = api_v2_serializers.TagSerializer({"tags": tags}) + return Response(serialized_tags.data) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: api_v2_serializers.BurpRawRequestResponseSerializer, + }, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.BurpRawRequestResponseSerializer, + responses={ + status.HTTP_201_CREATED: api_v2_serializers.BurpRawRequestResponseSerializer, + }, + ) + @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def request_response(self, request, pk=None): + finding = self.get_object() + + if request.method == "POST": + burps = api_v2_serializers.BurpRawRequestResponseSerializer( + data=request.data, many=isinstance(request.data, list), + ) + if burps.is_valid(): + for pair in burps.validated_data["req_resp"]: + burp_rr = BurpRawRequestResponse( + finding=finding, + burpRequestBase64=base64.b64encode( + pair["request"].encode("utf-8"), + ), + burpResponseBase64=base64.b64encode( + pair["response"].encode("utf-8"), + ), + ) + burp_rr.clean() + burp_rr.save() + else: + return Response( + burps.errors, status=status.HTTP_400_BAD_REQUEST, + ) + # Not necessarily Burp scan specific - these are just any request/response pairs + burp_req_resp = BurpRawRequestResponse.objects.filter(finding=finding) + var = settings.MAX_REQRESP_FROM_API + if var > -1: + burp_req_resp = burp_req_resp[:var] + + burp_list = [] + for burp in burp_req_resp: + request = burp.get_request() + response = burp.get_response() + burp_list.append({"request": request, "response": response}) + serialized_burps = api_v2_serializers.BurpRawRequestResponseSerializer( + {"req_resp": burp_list}, + ) + return Response(serialized_burps.data) + + @extend_schema( + methods=["GET"], + responses={status.HTTP_200_OK: FindingToNotesSerializer}, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewNoteOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.NoteSerializer}, + ) + @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission)) + def notes(self, request, pk=None): + finding = self.get_object() + if request.method == "POST": + new_note = api_v2_serializers.AddNewNoteOptionSerializer( + data=request.data, + ) + if new_note.is_valid(): + entry = new_note.validated_data["entry"] + private = new_note.validated_data.get("private", False) + note_type = new_note.validated_data.get("note_type", None) + else: + return Response( + new_note.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + if finding.notes: + notes = finding.notes.filter(note_type=note_type).first() + if notes and note_type and note_type.is_single: + return Response("Only one instance of this note_type allowed on a finding.", status=status.HTTP_400_BAD_REQUEST) + + author = request.user + note = Notes( + entry=entry, + author=author, + private=private, + note_type=note_type, + ) + note.save() + # Add an entry to the note history + history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) + note.history.add(history) + # Now add the note to the object + finding.last_reviewed = note.date + finding.last_reviewed_by = author + finding.save(update_fields=["last_reviewed", "last_reviewed_by", "updated"]) + finding.notes.add(note) + # Determine if we need to send any notifications for user mentioned + process_tag_notifications( + request=request, + note=note, + parent_url=request.build_absolute_uri( + reverse("view_finding", args=(finding.id,)), + ), + parent_title=f"Finding: {finding.title}", + ) + + if finding.has_jira_issue: + jira_services.add_comment(finding, note) + elif finding.has_jira_group_issue: + jira_services.add_comment(finding.finding_group, note) + + serialized_note = api_v2_serializers.NoteSerializer( + {"author": author, "entry": entry, "private": private}, + ) + return Response( + serialized_note.data, status=status.HTTP_201_CREATED, + ) + notes = finding.notes.all() + + serialized_notes = FindingToNotesSerializer( + {"finding_id": finding, "notes": notes}, + ) + return Response(serialized_notes.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={status.HTTP_200_OK: FindingToFilesSerializer}, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewFileOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.FileSerializer}, + ) + @action( + detail=True, methods=["get", "post"], parser_classes=(MultiPartParser,), permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def files(self, request, pk=None): + finding = self.get_object() + if request.method == "POST": + new_file = api_v2_serializers.FileSerializer(data=request.data) + if new_file.is_valid(): + title = new_file.validated_data["title"] + file = new_file.validated_data["file"] + else: + return Response( + new_file.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + file = FileUpload(title=title, file=file) + file.save() + finding.files.add(file) + + serialized_file = api_v2_serializers.FileSerializer(file) + return Response( + serialized_file.data, status=status.HTTP_201_CREATED, + ) + + files = finding.files.all() + serialized_files = FindingToFilesSerializer( + {"finding_id": finding, "files": files}, + ) + return Response(serialized_files.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: api_v2_serializers.RawFileSerializer, + }, + ) + @action( + detail=True, + methods=["get"], + url_path=r"files/download/(?P\d+)", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def download_file(self, request, file_id, pk=None): + finding = self.get_object() + # Get the file object + file_object_qs = finding.files.filter(id=file_id) + file_object = ( + file_object_qs.first() if len(file_object_qs) > 0 else None + ) + if file_object is None: + return Response( + {"error": "File ID not associated with Finding"}, + status=status.HTTP_404_NOT_FOUND, + ) + # send file + return generate_file_response(file_object) + + @extend_schema( + request=FindingNoteSerializer, + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + @action(detail=True, methods=["patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission)) + def remove_note(self, request, pk=None): + """Remove Note From Finding Note""" + finding = self.get_object() + notes = finding.notes.all() + if request.data["note_id"]: + note = get_object_or_404(Notes.objects, id=request.data["note_id"]) + if note not in notes: + return Response( + {"error": "Selected Note is not assigned to this Finding"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + {"error": "('note_id') parameter missing"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if ( + note.author.username == request.user.username + or request.user.is_superuser + ): + finding.notes.remove(note) + note.delete() + else: + return Response( + {"error": "Delete Failed, You are not the Note's author"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + {"Success": "Selected Note has been Removed successfully"}, + status=status.HTTP_204_NO_CONTENT, + ) + + @extend_schema( + methods=["PUT", "PATCH"], + request=api_v2_serializers.TagSerializer, + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + @action(detail=True, methods=["put", "patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def remove_tags(self, request, pk=None): + """Remove Tag(s) from finding list of tags""" + finding = self.get_object() + delete_tags = api_v2_serializers.TagSerializer(data=request.data) + if delete_tags.is_valid(): + all_tags = finding.tags + all_tags = api_v2_serializers.TagSerializer({"tags": all_tags}).data[ + "tags" + ] + + # serializer turns it into a string, but we need a list + del_tags = delete_tags.validated_data["tags"] + if len(del_tags) < 1: + return Response( + {"error": "Empty Tag List Not Allowed"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + for tag in del_tags: + if tag not in all_tags: + return Response( + { + "error": f"'{tag}' is not a valid tag in list '{all_tags}'", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + all_tags.remove(tag) + new_tags = tagulous.utils.render_tags(all_tags) + finding.tags = new_tags + finding.save() + return Response( + {"success": "Tag(s) Removed"}, + status=status.HTTP_204_NO_CONTENT, + ) + return Response( + delete_tags.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + @extend_schema( + responses={ + status.HTTP_200_OK: FindingSerializer(many=True), + }, + ) + @action( + detail=True, + methods=["get"], + url_path=r"duplicate", + filter_backends=[], + pagination_class=None, + permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def get_duplicate_cluster(self, request, pk): + finding = self.get_object() + result = duplicate_cluster(request, finding) + serializer = FindingSerializer( + instance=result, many=True, context={"request": request}, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + request=OpenApiTypes.NONE, + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + @action(detail=True, methods=["post"], url_path=r"duplicate/reset", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def reset_finding_duplicate_status(self, request, pk): + self.get_object() + checked_duplicate_id = reset_finding_duplicate_status_internal( + request.user, pk, + ) + if checked_duplicate_id is None: + return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + request=OpenApiTypes.NONE, + parameters=[ + OpenApiParameter( + "new_fid", OpenApiTypes.INT, OpenApiParameter.PATH, + ), + ], + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + @action( + detail=True, methods=["post"], url_path=r"original/(?P\d+)", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def set_finding_as_original(self, request, pk, new_fid): + self.get_object() + success = set_finding_as_original_internal(request.user, pk, new_fid) + if not success: + return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + request=api_v2_serializers.ReportGenerateOptionSerializer, + responses={status.HTTP_200_OK: api_v2_serializers.ReportGenerateSerializer}, + ) + @action( + detail=False, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], + ) + def generate_report(self, request): + findings = self.get_queryset() + options = {} + # prepare post data + report_options = api_v2_serializers.ReportGenerateOptionSerializer( + data=request.data, + ) + if report_options.is_valid(): + options["include_finding_notes"] = report_options.validated_data[ + "include_finding_notes" + ] + options["include_finding_images"] = report_options.validated_data[ + "include_finding_images" + ] + options[ + "include_executive_summary" + ] = report_options.validated_data["include_executive_summary"] + options[ + "include_table_of_contents" + ] = report_options.validated_data["include_table_of_contents"] + else: + return Response( + report_options.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + data = report_generate(request, findings, options) + report = api_v2_serializers.ReportGenerateSerializer(data) + return Response(report.data) + + def _get_metadata(self, request, finding): + metadata = DojoMeta.objects.filter(finding=finding) + serializer = FindingMetaSerializer( + instance=metadata, many=True, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + def _edit_metadata(self, request, finding): + metadata_name = request.query_params.get("name", None) + if metadata_name is None: + return Response( + "Metadata name is required", status=status.HTTP_400_BAD_REQUEST, + ) + + try: + DojoMeta.objects.update_or_create( + name=metadata_name, + finding=finding, + defaults={ + "name": request.data.get("name"), + "value": request.data.get("value"), + }, + ) + + return Response(data=request.data, status=status.HTTP_200_OK) + except IntegrityError: + return Response( + "Update failed because the new name already exists", + status=status.HTTP_400_BAD_REQUEST, + ) + + def _add_metadata(self, request, finding): + metadata_data = FindingMetaSerializer(data=request.data) + + if metadata_data.is_valid(): + name = metadata_data.validated_data["name"] + value = metadata_data.validated_data["value"] + + metadata = DojoMeta(finding=finding, name=name, value=value) + try: + metadata.validate_unique() + metadata.save() + except ValidationError: + return Response( + "Create failed probably because the name of the metadata already exists", + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(data=metadata_data.data, status=status.HTTP_200_OK) + return Response( + metadata_data.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + def _remove_metadata(self, request, finding): + name = request.query_params.get("name", None) + if name is None: + return Response( + "A metadata name must be provided", + status=status.HTTP_400_BAD_REQUEST, + ) + + metadata = get_object_or_404( + DojoMeta.objects, finding=finding, name=name, + ) + metadata.delete() + + return Response("Metadata deleted", status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: FindingMetaSerializer(many=True), + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description="Returned if finding does not exist", + ), + }, + ) + @extend_schema( + methods=["DELETE"], + parameters=[ + OpenApiParameter( + "name", + OpenApiTypes.INT, + OpenApiParameter.QUERY, + required=True, + description="name of the metadata to retrieve. If name is empty, return all the \ + metadata associated with the finding", + ), + ], + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Returned if the metadata was correctly deleted", + ), + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description="Returned if finding does not exist", + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="Returned if there was a problem with the metadata information", + ), + }, + ) + @extend_schema( + methods=["PUT"], + request=FindingMetaSerializer, + responses={ + status.HTTP_200_OK: FindingMetaSerializer, + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description="Returned if finding does not exist", + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="Returned if there was a problem with the metadata information", + ), + }, + ) + @extend_schema( + methods=["POST"], + request=FindingMetaSerializer, + responses={ + status.HTTP_200_OK: FindingMetaSerializer, + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description="Returned if finding does not exist", + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="Returned if there was a problem with the metadata information", + ), + }, + ) + @action( + detail=True, + methods=["post", "put", "delete", "get"], + filter_backends=[], + pagination_class=None, + permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def metadata(self, request, pk=None): + finding = self.get_object() + + if request.method == "GET": + return self._get_metadata(request, finding) + if request.method == "POST": + return self._add_metadata(request, finding) + if request.method in {"PUT", "PATCH"}: + return self._edit_metadata(request, finding) + if request.method == "DELETE": + return self._remove_metadata(request, finding) + + return Response( + {"error", "unsupported method"}, status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/dojo/test/api/serializer.py b/dojo/test/api/serializer.py index fcce90fdd82..c5dda0409a8 100644 --- a/dojo/test/api/serializer.py +++ b/dojo/test/api/serializer.py @@ -20,9 +20,11 @@ class Meta: def get_fields(self): from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency - FindingGroupSerializer, TagListSerializerField, ) + from dojo.finding.api.serializer import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + FindingGroupSerializer, + ) fields = super().get_fields() fields["tags"] = TagListSerializerField(required=False) fields["finding_groups"] = FindingGroupSerializer( diff --git a/dojo/urls.py b/dojo/urls.py index a87b31a3913..fa91624cee9 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -22,8 +22,6 @@ EndpointMetaImporterView, EndpointStatusViewSet, EndPointViewSet, - FindingTemplatesViewSet, - FindingViewSet, ImportLanguagesView, ImportScanView, JiraInstanceViewSet, @@ -58,6 +56,7 @@ from dojo.endpoint.urls import urlpatterns as endpoint_urls from dojo.engagement.api.urls import add_engagement_urls from dojo.engagement.ui.urls import urlpatterns as eng_urls +from dojo.finding.api.urls import add_finding_urls from dojo.finding.ui.urls import urlpatterns as finding_urls from dojo.finding_group.urls import urlpatterns as finding_group_urls from dojo.github.ui.urls import urlpatterns as github_urls @@ -109,8 +108,6 @@ # RBAC endpoints moved to Pro under legacy authorization: # dojo_groups, dojo_group_members → pro/groups, pro/group_members v2_api.register(r"endpoint_meta_import", EndpointMetaImporterView, basename="endpointmetaimport") -v2_api.register(r"finding_templates", FindingTemplatesViewSet, basename="finding_template") -v2_api.register(r"findings", FindingViewSet, basename="finding") # RBAC endpoint moved to Pro under legacy authorization: global_roles → pro/global_roles v2_api.register(r"import-languages", ImportLanguagesView, basename="importlanguages") v2_api.register(r"import-scan", ImportScanView, basename="importscan") @@ -131,6 +128,7 @@ # product_groups, product_members → pro/product_groups, pro/product_members v2_api = add_product_type_urls(v2_api) v2_api = add_engagement_urls(v2_api) +v2_api = add_finding_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") diff --git a/unittests/test_filter_finding_mitigation.py b/unittests/test_filter_finding_mitigation.py index 568c6d3d4ed..d21e7db7693 100644 --- a/unittests/test_filter_finding_mitigation.py +++ b/unittests/test_filter_finding_mitigation.py @@ -3,7 +3,7 @@ from django.test import TestCase from django.utils import timezone -from dojo.filters import ApiFindingFilter +from dojo.finding.api.filters import ApiFindingFilter from dojo.finding.ui.filters import FindingFilterHelper from dojo.models import ( Dojo_User, diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 222f478c933..d4d1bba79d2 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -44,8 +44,6 @@ DevelopmentEnvironmentViewSet, EndpointStatusViewSet, EndPointViewSet, - FindingTemplatesViewSet, - FindingViewSet, ImportLanguagesView, ImportScanView, JiraInstanceViewSet, @@ -69,6 +67,7 @@ ) from dojo.authorization.roles_permissions import Permissions, permission_to_action from dojo.engagement.api.views import EngagementViewSet +from dojo.finding.api.views import FindingTemplatesViewSet, FindingViewSet from dojo.location.api.endpoint_compat import V3EndpointCompatibleViewSet, V3EndpointStatusCompatibleViewSet from dojo.location.api.views import LocationFindingReferenceViewSet, LocationProductReferenceViewSet, LocationViewSet from dojo.location.models import Location, LocationFindingReference, LocationProductReference From d95c5b8021b7b52120be19388744eba687a69e07 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 22:46:18 +0200 Subject: [PATCH 5/5] refactor(finding): fold CWE + BurpRawRequestResponse into dojo/finding/ [finding Phase 1,6,8,9] --- dojo/api_v2/serializers.py | 150 +----------------------------- dojo/api_v2/views.py | 31 ------- dojo/finding/admin.py | 13 ++- dojo/finding/api/serializer.py | 155 ++++++++++++++++++++++++++++++- dojo/finding/api/urls.py | 7 +- dojo/finding/api/views.py | 39 +++++++- dojo/finding/models.py | 20 ++++ dojo/models.py | 25 +---- dojo/urls.py | 2 - unittests/test_rest_framework.py | 7 +- 10 files changed, 232 insertions(+), 217 deletions(-) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 3287619e1a8..83d043aaaac 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -1,5 +1,3 @@ -import base64 -import collections import json import logging import re @@ -21,7 +19,6 @@ from rest_framework import serializers from rest_framework.exceptions import NotFound from rest_framework.exceptions import ValidationError as RestFrameworkValidationError -from rest_framework.fields import DictField import dojo.risk_acceptance.helper as ra_helper from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import @@ -38,7 +35,6 @@ STATS_FIELDS, Announcement, App_Analysis, - BurpRawRequestResponse, Development_Environment, Dojo_User, DojoMeta, @@ -214,150 +210,6 @@ def to_representation(self, value): return value -class RequestResponseDict(collections.UserList): - def __init__(self, *args, **kwargs): - pretty_print = kwargs.pop("pretty_print", True) - collections.UserList.__init__(self, *args, **kwargs) - self.pretty_print = pretty_print - - def __add__(self, rhs): - return RequestResponseDict(list.__add__(self, rhs)) - - def __getitem__(self, item): - result = list.__getitem__(self, item) - try: - return RequestResponseDict(result) - except TypeError: - return result - - def __str__(self): - if self.pretty_print: - return json.dumps( - self, sort_keys=True, indent=4, separators=(",", ": "), - ) - return json.dumps(self) - - -class RequestResponseSerializerField(serializers.ListSerializer): - child = DictField(child=serializers.CharField()) - default_error_messages = { - "not_a_list": _( - 'Expected a list of items but got type "{input_type}".', - ), - "invalid_json": _( - "Invalid json list. A tag list submitted in string" - " form must be valid json.", - ), - "not_a_dict": _( - "All list items must be of dict type with keys 'request' and 'response'", - ), - "not_a_str": _("All values in the dict must be of string type."), - } - order_by = None - - def __init__(self, **kwargs): - pretty_print = kwargs.pop("pretty_print", True) - - style = kwargs.pop("style", {}) - kwargs["style"] = {"base_template": "textarea.html"} - kwargs["style"].update(style) - - if "data" in kwargs: - data = kwargs["data"] - - if isinstance(data, list): - kwargs["many"] = True - - super().__init__(**kwargs) - - self.pretty_print = pretty_print - - def to_internal_value(self, data): - if isinstance(data, six.string_types): - if not data: - data = [] - try: - data = json.loads(data) - except ValueError: - self.fail("invalid_json") - - if not isinstance(data, list): - self.fail("not_a_list", input_type=type(data).__name__) - for s in data: - if not isinstance(s, dict): - self.fail("not_a_dict", input_type=type(s).__name__) - - request = s.get("request", None) - response = s.get("response", None) - - if not isinstance(request, str): - self.fail("not_a_str", input_type=type(request).__name__) - if not isinstance(response, str): - self.fail("not_a_str", input_type=type(request).__name__) - - self.child.run_validation(s) - return data - - def to_representation(self, value): - if not isinstance(value, RequestResponseDict): - if not isinstance(value, list): - # this will trigger when a queryset is found... - burps = value.all().order_by(*self.order_by) if self.order_by else value.all() - value = [ - { - "request": burp.get_request(), - "response": burp.get_response(), - } - for burp in burps - ] - - return value - - -class BurpRawRequestResponseSerializer(serializers.Serializer): - req_resp = RequestResponseSerializerField(required=True) - - -class BurpRawRequestResponseMultiSerializer(serializers.ModelSerializer): - burpRequestBase64 = serializers.CharField() - burpResponseBase64 = serializers.CharField() - - def to_representation(self, data): - return { - "id": data.id, - "finding": data.finding.id, - "burpRequestBase64": data.burpRequestBase64.decode("utf-8"), - "burpResponseBase64": data.burpResponseBase64.decode("utf-8"), - } - - def validate(self, data): - b64request = data.get("burpRequestBase64", None) - b64response = data.get("burpResponseBase64", None) - finding = data.get("finding", None) - # Make sure all fields are present - if not b64request or not b64response or not finding: - msg = "burpRequestBase64, burpResponseBase64, and finding are required." - raise ValidationError(msg) - # Verify we have true base64 decoding - try: - base64.b64decode(b64request, validate=True) - base64.b64decode(b64response, validate=True) - except Exception as e: - msg = "Inputs need to be valid base64 encodings" - raise ValidationError(msg) from e - # Encode the data in utf-8 to remove any bad characters - data["burpRequestBase64"] = b64request.encode("utf-8") - data["burpResponseBase64"] = b64response.encode("utf-8") - # Run the model validation - an ValidationError will be raised if there is an issue - BurpRawRequestResponse(finding=finding, burpRequestBase64=b64request, burpResponseBase64=b64response).clean() - - return data - - class Meta: - model = BurpRawRequestResponse - fields = "__all__" - - class MetaSerializer(serializers.ModelSerializer): product = serializers.PrimaryKeyRelatedField( queryset=Product.objects.all(), @@ -1727,6 +1579,8 @@ class ExecutiveSummarySerializer(serializers.Serializer): # (dojo/api_v2/prefetch/prefetcher.py inspects this module to build its model->serializer # map); changing that membership would silently change prefetch responses. from dojo.finding.api.serializer import ( # noqa: E402 -- backward compat + BurpRawRequestResponseMultiSerializer, # noqa: F401 -- backward compat / prefetcher discovery + BurpRawRequestResponseSerializer, # noqa: F401 -- backward compat FindingCloseSerializer, # noqa: F401 -- backward compat / prefetcher discovery FindingCreateSerializer, # noqa: F401 -- backward compat / prefetcher discovery FindingEngagementSerializer, # noqa: F401 -- backward compat / prefetcher discovery diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index cbe7353292c..09bc52e7252 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -53,9 +53,6 @@ ApiRiskAcceptanceFilter, ApiUserFilter, ) -from dojo.finding.queries import ( - get_authorized_findings, -) from dojo.finding.ui.filters import ( ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, @@ -66,7 +63,6 @@ from dojo.models import ( Announcement, App_Analysis, - BurpRawRequestResponse, Development_Environment, Dojo_User, DojoMeta, @@ -987,33 +983,6 @@ def get_queryset(self): return Note_Type.objects.all().order_by("id") -class BurpRawRequestResponseViewSet( - DojoModelViewSet, -): - serializer_class = serializers.BurpRawRequestResponseMultiSerializer - queryset = BurpRawRequestResponse.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = ["finding"] - permission_classes = ( - IsAuthenticated, - permissions.UserHasBurpRawRequestResponsePermission, - ) - - def get_queryset(self): - return ( - BurpRawRequestResponse.objects.filter( - finding__in=get_authorized_findings( - "view", - ), - ) - .exclude( - burpRequestBase64__exact=b"", - burpResponseBase64__exact=b"", - ) - .order_by("id") - ) - - # Authorization: superuser class NotesViewSet( mixins.UpdateModelMixin, diff --git a/dojo/finding/admin.py b/dojo/finding/admin.py index 61d6098a002..f10732d25f7 100644 --- a/dojo/finding/admin.py +++ b/dojo/finding/admin.py @@ -1,6 +1,13 @@ from django.contrib import admin -from dojo.finding.models import Finding, Finding_Group, Finding_Template, Vulnerability_Id +from dojo.finding.models import ( + CWE, + BurpRawRequestResponse, + Finding, + Finding_Group, + Finding_Template, + Vulnerability_Id, +) @admin.register(Finding) @@ -29,3 +36,7 @@ class VulnerabilityIdAdmin(admin.ModelAdmin): class FindingGroupAdmin(admin.ModelAdmin): """Admin support for the Finding_Group model.""" + + +admin.site.register(CWE) +admin.site.register(BurpRawRequestResponse) diff --git a/dojo/finding/api/serializer.py b/dojo/finding/api/serializer.py index cb17386135c..360f08b7926 100644 --- a/dojo/finding/api/serializer.py +++ b/dojo/finding/api/serializer.py @@ -1,10 +1,16 @@ +import base64 +import collections +import json import logging +import six from django.conf import settings +from django.core.exceptions import ValidationError from django.urls import reverse from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from rest_framework.fields import DictField import dojo.finding.helper as finding_helper from dojo.authorization.authorization import user_has_permission @@ -14,6 +20,7 @@ save_vulnerability_ids, save_vulnerability_ids_template, ) +from dojo.finding.models import BurpRawRequestResponse from dojo.jira import services as jira_services from dojo.jira.api.serializers import JIRAIssueSerializer from dojo.location.models import LocationFindingReference @@ -41,6 +48,150 @@ logger = logging.getLogger(__name__) +class RequestResponseDict(collections.UserList): + def __init__(self, *args, **kwargs): + pretty_print = kwargs.pop("pretty_print", True) + collections.UserList.__init__(self, *args, **kwargs) + self.pretty_print = pretty_print + + def __add__(self, rhs): + return RequestResponseDict(list.__add__(self, rhs)) + + def __getitem__(self, item): + result = list.__getitem__(self, item) + try: + return RequestResponseDict(result) + except TypeError: + return result + + def __str__(self): + if self.pretty_print: + return json.dumps( + self, sort_keys=True, indent=4, separators=(",", ": "), + ) + return json.dumps(self) + + +class RequestResponseSerializerField(serializers.ListSerializer): + child = DictField(child=serializers.CharField()) + default_error_messages = { + "not_a_list": _( + 'Expected a list of items but got type "{input_type}".', + ), + "invalid_json": _( + "Invalid json list. A tag list submitted in string" + " form must be valid json.", + ), + "not_a_dict": _( + "All list items must be of dict type with keys 'request' and 'response'", + ), + "not_a_str": _("All values in the dict must be of string type."), + } + order_by = None + + def __init__(self, **kwargs): + pretty_print = kwargs.pop("pretty_print", True) + + style = kwargs.pop("style", {}) + kwargs["style"] = {"base_template": "textarea.html"} + kwargs["style"].update(style) + + if "data" in kwargs: + data = kwargs["data"] + + if isinstance(data, list): + kwargs["many"] = True + + super().__init__(**kwargs) + + self.pretty_print = pretty_print + + def to_internal_value(self, data): + if isinstance(data, six.string_types): + if not data: + data = [] + try: + data = json.loads(data) + except ValueError: + self.fail("invalid_json") + + if not isinstance(data, list): + self.fail("not_a_list", input_type=type(data).__name__) + for s in data: + if not isinstance(s, dict): + self.fail("not_a_dict", input_type=type(s).__name__) + + request = s.get("request", None) + response = s.get("response", None) + + if not isinstance(request, str): + self.fail("not_a_str", input_type=type(request).__name__) + if not isinstance(response, str): + self.fail("not_a_str", input_type=type(request).__name__) + + self.child.run_validation(s) + return data + + def to_representation(self, value): + if not isinstance(value, RequestResponseDict): + if not isinstance(value, list): + # this will trigger when a queryset is found... + burps = value.all().order_by(*self.order_by) if self.order_by else value.all() + value = [ + { + "request": burp.get_request(), + "response": burp.get_response(), + } + for burp in burps + ] + + return value + + +class BurpRawRequestResponseSerializer(serializers.Serializer): + req_resp = RequestResponseSerializerField(required=True) + + +class BurpRawRequestResponseMultiSerializer(serializers.ModelSerializer): + burpRequestBase64 = serializers.CharField() + burpResponseBase64 = serializers.CharField() + + def to_representation(self, data): + return { + "id": data.id, + "finding": data.finding.id, + "burpRequestBase64": data.burpRequestBase64.decode("utf-8"), + "burpResponseBase64": data.burpResponseBase64.decode("utf-8"), + } + + def validate(self, data): + b64request = data.get("burpRequestBase64", None) + b64response = data.get("burpResponseBase64", None) + finding = data.get("finding", None) + # Make sure all fields are present + if not b64request or not b64response or not finding: + msg = "burpRequestBase64, burpResponseBase64, and finding are required." + raise ValidationError(msg) + # Verify we have true base64 decoding + try: + base64.b64decode(b64request, validate=True) + base64.b64decode(b64response, validate=True) + except Exception as e: + msg = "Inputs need to be valid base64 encodings" + raise ValidationError(msg) from e + # Encode the data in utf-8 to remove any bad characters + data["burpRequestBase64"] = b64request.encode("utf-8") + data["burpResponseBase64"] = b64response.encode("utf-8") + # Run the model validation - an ValidationError will be raised if there is an issue + BurpRawRequestResponse(finding=finding, burpRequestBase64=b64request, burpResponseBase64=b64response).clean() + + return data + + class Meta: + model = BurpRawRequestResponse + fields = "__all__" + + class FindingGroupSerializer(serializers.ModelSerializer): jira_issue = JIRAIssueSerializer(read_only=True, allow_null=True) @@ -378,9 +529,6 @@ def build_relational_field(self, field_name, relation_info): return super().build_relational_field(field_name, relation_info) def get_request_response(self, obj): - from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency - BurpRawRequestResponseSerializer, - ) # Not necessarily Burp scan specific - these are just any request/response pairs burp_req_resp = obj.burprawrequestresponse_set.all() var = settings.MAX_REQRESP_FROM_API @@ -727,7 +875,6 @@ def _apply_schema_overrides(): from drf_spectacular.utils import set_override # noqa: PLC0415 -- lazy import, avoids circular dependency from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency - BurpRawRequestResponseSerializer, RiskAcceptanceSerializer, ) set_override(FindingSerializer.get_accepted_risks, "field", RiskAcceptanceSerializer(many=True)) diff --git a/dojo/finding/api/urls.py b/dojo/finding/api/urls.py index 3a2e8f3143d..c2d240d3a1a 100644 --- a/dojo/finding/api/urls.py +++ b/dojo/finding/api/urls.py @@ -1,7 +1,12 @@ -from dojo.finding.api.views import FindingTemplatesViewSet, FindingViewSet +from dojo.finding.api.views import ( + BurpRawRequestResponseViewSet, + FindingTemplatesViewSet, + FindingViewSet, +) def add_finding_urls(router): router.register("finding_templates", FindingTemplatesViewSet, basename="finding_template") router.register("findings", FindingViewSet, basename="finding") + router.register("request_response_pairs", BurpRawRequestResponseViewSet, basename="request_response_pairs") return router diff --git a/dojo/finding/api/views.py b/dojo/finding/api/views.py index 877657165f3..d40f72fd165 100644 --- a/dojo/finding/api/views.py +++ b/dojo/finding/api/views.py @@ -36,6 +36,8 @@ from dojo.authorization import api_permissions as permissions from dojo.finding.api.filters import ApiFindingFilter, ApiTemplateFindingFilter from dojo.finding.api.serializer import ( + BurpRawRequestResponseMultiSerializer, + BurpRawRequestResponseSerializer, FindingCloseSerializer, FindingCreateSerializer, FindingMetaSerializer, @@ -316,14 +318,14 @@ def tags(self, request, pk=None): @extend_schema( methods=["GET"], responses={ - status.HTTP_200_OK: api_v2_serializers.BurpRawRequestResponseSerializer, + status.HTTP_200_OK: BurpRawRequestResponseSerializer, }, ) @extend_schema( methods=["POST"], - request=api_v2_serializers.BurpRawRequestResponseSerializer, + request=BurpRawRequestResponseSerializer, responses={ - status.HTTP_201_CREATED: api_v2_serializers.BurpRawRequestResponseSerializer, + status.HTTP_201_CREATED: BurpRawRequestResponseSerializer, }, ) @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) @@ -331,7 +333,7 @@ def request_response(self, request, pk=None): finding = self.get_object() if request.method == "POST": - burps = api_v2_serializers.BurpRawRequestResponseSerializer( + burps = BurpRawRequestResponseSerializer( data=request.data, many=isinstance(request.data, list), ) if burps.is_valid(): @@ -362,7 +364,7 @@ def request_response(self, request, pk=None): request = burp.get_request() response = burp.get_response() burp_list.append({"request": request, "response": response}) - serialized_burps = api_v2_serializers.BurpRawRequestResponseSerializer( + serialized_burps = BurpRawRequestResponseSerializer( {"req_resp": burp_list}, ) return Response(serialized_burps.data) @@ -831,3 +833,30 @@ def metadata(self, request, pk=None): return Response( {"error", "unsupported method"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class BurpRawRequestResponseViewSet( + DojoModelViewSet, +): + serializer_class = BurpRawRequestResponseMultiSerializer + queryset = BurpRawRequestResponse.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ["finding"] + permission_classes = ( + IsAuthenticated, + permissions.UserHasBurpRawRequestResponsePermission, + ) + + def get_queryset(self): + return ( + BurpRawRequestResponse.objects.filter( + finding__in=get_authorized_findings( + "view", + ), + ) + .exclude( + burpRequestBase64__exact=b"", + burpResponseBase64__exact=b"", + ) + .order_by("id") + ) diff --git a/dojo/finding/models.py b/dojo/finding/models.py index 19772f95519..337e2b97015 100644 --- a/dojo/finding/models.py +++ b/dojo/finding/models.py @@ -1495,3 +1495,23 @@ def endpoints(self): return [] # Parse newline-separated string, remove empty lines return [line.strip() for line in self.endpoints_text.split("\n") if line.strip()] + + +class CWE(models.Model): + url = models.CharField(max_length=1000) + description = models.CharField(max_length=2000) + number = models.IntegerField() + + +class BurpRawRequestResponse(models.Model): + finding = models.ForeignKey("dojo.Finding", blank=True, null=True, on_delete=models.CASCADE) + burpRequestBase64 = models.BinaryField() + burpResponseBase64 = models.BinaryField() + + def get_request(self): + return str(base64.b64decode(self.burpRequestBase64), errors="ignore") + + def get_response(self): + res = str(base64.b64decode(self.burpResponseBase64), errors="ignore") + # Removes all blank lines + return re.sub(r"\n\s*\n", "\n", res) diff --git a/dojo/models.py b/dojo/models.py index 3cd1041caec..4dfc2336700 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1,4 +1,3 @@ -import base64 import contextlib import copy import logging @@ -1024,12 +1023,6 @@ def __str__(self): ) -class CWE(models.Model): - url = models.CharField(max_length=1000) - description = models.CharField(max_length=2000) - number = models.IntegerField() - - class Endpoint_Params(models.Model): param = models.CharField(max_length=150) value = models.CharField(max_length=150) @@ -1501,6 +1494,8 @@ class Meta: from dojo.finding.models import ( # noqa: E402 -- re-export; class-body FKs below reference these + CWE, + BurpRawRequestResponse, # noqa: F401 -- re-export Finding, Finding_Group, # noqa: F401 -- re-export Finding_Template, @@ -1560,20 +1555,6 @@ def get_breadcrumb(self): return bc -class BurpRawRequestResponse(models.Model): - finding = models.ForeignKey(Finding, blank=True, null=True, on_delete=models.CASCADE) - burpRequestBase64 = models.BinaryField() - burpResponseBase64 = models.BinaryField() - - def get_request(self): - return str(base64.b64decode(self.burpRequestBase64), errors="ignore") - - def get_response(self): - res = str(base64.b64decode(self.burpResponseBase64), errors="ignore") - # Removes all blank lines - return re.sub(r"\n\s*\n", "\n", res) - - class Risk_Acceptance(models.Model): TREATMENT_ACCEPT = "A" TREATMENT_AVOID = "V" @@ -2214,7 +2195,6 @@ def __str__(self): admin.site.register(Tool_Type) admin.site.register(System_Settings) admin.site.register(SLA_Configuration) -admin.site.register(CWE) admin.site.register(Regulation) from dojo.authorization.models import ( # noqa: E402 Dojo_Group, @@ -2246,7 +2226,6 @@ def __str__(self): admin.site.register(Report_Type) admin.site.register(DojoMeta) admin.site.register(Development_Environment) -admin.site.register(BurpRawRequestResponse) admin.site.register(Announcement) admin.site.register(UserAnnouncement) admin.site.register(BannerConf) diff --git a/dojo/urls.py b/dojo/urls.py index fa91624cee9..db1955dd439 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -14,7 +14,6 @@ from dojo.api_v2.views import ( AnnouncementViewSet, AppAnalysisViewSet, - BurpRawRequestResponseViewSet, CeleryViewSet, ConfigurationPermissionViewSet, DevelopmentEnvironmentViewSet, @@ -133,7 +132,6 @@ # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") v2_api.register(r"reimport-scan", ReImportScanView, basename="reimportscan") -v2_api.register(r"request_response_pairs", BurpRawRequestResponseViewSet, basename="request_response_pairs") v2_api.register(r"risk_acceptance", RiskAcceptanceViewSet, basename="risk_acceptance") # RBAC endpoint moved to Pro under legacy authorization: roles → pro/roles v2_api.register(r"sla_configurations", SLAConfigurationViewset, basename="sla_configurations") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index d4d1bba79d2..6a455168670 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -39,7 +39,6 @@ from dojo.api_v2.views import ( AnnouncementViewSet, AppAnalysisViewSet, - BurpRawRequestResponseViewSet, ConfigurationPermissionViewSet, DevelopmentEnvironmentViewSet, EndpointStatusViewSet, @@ -67,7 +66,11 @@ ) from dojo.authorization.roles_permissions import Permissions, permission_to_action from dojo.engagement.api.views import EngagementViewSet -from dojo.finding.api.views import FindingTemplatesViewSet, FindingViewSet +from dojo.finding.api.views import ( + BurpRawRequestResponseViewSet, + FindingTemplatesViewSet, + FindingViewSet, +) from dojo.location.api.endpoint_compat import V3EndpointCompatibleViewSet, V3EndpointStatusCompatibleViewSet from dojo.location.api.views import LocationFindingReferenceViewSet, LocationProductReferenceViewSet, LocationViewSet from dojo.location.models import Location, LocationFindingReference, LocationProductReference