diff --git a/docs/content/en/open_source/contributing/how-to-write-a-parser.md b/docs/content/en/open_source/contributing/how-to-write-a-parser.md index 3cb4f031d84..5a2b743e093 100644 --- a/docs/content/en/open_source/contributing/how-to-write-a-parser.md +++ b/docs/content/en/open_source/contributing/how-to-write-a-parser.md @@ -169,15 +169,25 @@ Good example: ### Do not parse CVSS by hand (vector, score or severity) Data can have `CVSS` vectors or scores. Don't write your own CVSS score algorithm. -For parser, we rely on module `cvss`. +For parser, we rely on module `cvss`. But we also have a helper method to validate the vector and extract the base score and severity from it. -It's easy to use and will make the parser aligned with the rest of the code. +```python +from dojo.utils import parse_cvss_data +cvss_data = parse_cvss_data("CVSS:3.0/S:C/C:H/I:H/A:N/AV:P/AC:H/PR:H/UI:R/E:H/RL:O/RC:R/CR:H/IR:X/AR:X/MAC:H/MPR:X/MUI:X/MC:L/MA:X") +if cvss_data: + finding.cvssv3 = cvss_data.get("vector") + finding.cvssv3_score = cvss_data.get("score") + finding.severity = cvss_data.get("severity") # if your tool does generate severity +``` + +If you need more manual processing, you can parse the `CVSS3` vector directly. Example of use: ```python -from cvss.cvss3 import CVSS3 import cvss.parser +from cvss import CVSS2, CVSS3 + vectors = cvss.parser.parse_cvss_from_text("CVSS:3.0/S:C/C:H/I:H/A:N/AV:P/AC:H/PR:H/UI:R/E:H/RL:O/RC:R/CR:H/IR:X/AR:X/MAC:H/MPR:X/MUI:X/MC:L/MA:X") if len(vectors) > 0 and type(vectors[0]) is CVSS3: print(vectors[0].severities()) # this is the 3 severities @@ -186,17 +196,8 @@ if len(vectors) > 0 and type(vectors[0]) is CVSS3: severity = vectors[0].severities()[0] vectors[0].compute_base_score() cvssv3_score = vectors[0].scores()[0] - print(severity) - print(cvssv3_score) -``` - -Good example: - -```python -vectors = cvss.parser.parse_cvss_from_text(item['cvss_vect']) -if len(vectors) > 0 and type(vectors[0]) is CVSS3: - finding.cvss = vectors[0].clean_vector() - finding.severity = vectors[0].severities()[0] # if your tool does generate severity + finding.severity = severity + finding.cvssv3_score = cvssv3_score ``` Bad example (DIY): @@ -313,7 +314,7 @@ or like this: $ ./run-unittest.sh --test-case unittests.tools.test_aqua_parser.TestAquaParser {{< /highlight >}} -If you want to run all unit tests, simply run `$ docker-compose exec uwsgi bash -c 'python manage.py test unittests -v2'` +If you want to run all parser unit tests, simply run `$ docker-compose exec uwsgi bash -c 'python manage.py test -p "test_*_parser.py" -v2'` ### Endpoint validation diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 085a2c544ea..cc94b9151bc 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -123,7 +123,8 @@ requires_tool_type, ) from dojo.user.utils import get_configuration_permissions_codenames -from dojo.utils import is_scan_file_too_large, tag_validator +from dojo.utils import is_scan_file_too_large +from dojo.validators import tag_validator logger = logging.getLogger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") diff --git a/dojo/db_migrations/0231_alter_finding_cvssv3_alter_finding_template_cvssv3.py b/dojo/db_migrations/0231_alter_finding_cvssv3_alter_finding_template_cvssv3.py new file mode 100644 index 00000000000..e8278db9f1b --- /dev/null +++ b/dojo/db_migrations/0231_alter_finding_cvssv3_alter_finding_template_cvssv3.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.8 on 2025-05-14 06:35 + +import dojo.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0230_alter_jira_instance_accepted_mapping_resolution_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='finding', + name='cvssv3', + field=models.TextField(help_text='Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this finding.', max_length=117, null=True, validators=[dojo.validators.cvss3_validator], verbose_name='CVSS v3 vector'), + ), + migrations.AlterField( + model_name='finding_template', + name='cvssv3', + field=models.TextField(help_text='Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this finding.', max_length=117, null=True, validators=[dojo.validators.cvss3_validator], verbose_name='CVSS v3 vector'), + ), + ] diff --git a/dojo/forms.py b/dojo/forms.py index e76a1987ee1..19f1fe6962d 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -110,8 +110,8 @@ get_system_setting, is_finding_groups_enabled, is_scan_file_too_large, - tag_validator, ) +from dojo.validators import tag_validator from dojo.widgets import TableCheckboxWidget logger = logging.getLogger(__name__) diff --git a/dojo/models.py b/dojo/models.py index caea5a53894..da5690c0be6 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -14,7 +14,6 @@ import hyperlink import tagulous.admin from auditlog.registry import auditlog -from cvss import CVSS3 from dateutil.relativedelta import relativedelta from django import forms from django.conf import settings @@ -44,6 +43,8 @@ from tagulous.models import TagField from tagulous.models.managers import FakeTagRelatedManager +from dojo.validators import cvss3_validator + logger = logging.getLogger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") @@ -2331,12 +2332,11 @@ class Finding(models.Model): 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)]) - cvssv3_regex = RegexValidator(regex=r"^AV:[NALP]|AC:[LH]|PR:[UNLH]|UI:[NR]|S:[UC]|[CIA]:[NLH]", message="CVSS must be entered in format: 'AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H'") - cvssv3 = models.TextField(validators=[cvssv3_regex], + cvssv3 = models.TextField(validators=[cvss3_validator], max_length=117, null=True, - verbose_name=_("CVSS v3"), - help_text=_("Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this flaw.")) + verbose_name=_("CVSS v3 vector"), + help_text=_("Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this finding.")) cvssv3_score = models.FloatField(null=True, blank=True, verbose_name=_("CVSSv3 score"), @@ -2698,11 +2698,17 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru # Synchronize cvssv3 score using cvssv3 vector if self.cvssv3: try: - cvss_object = CVSS3(self.cvssv3) - # use the environmental score, which is the most refined score - self.cvssv3_score = cvss_object.scores()[2] + + cvss_data = parse_cvss_data(self.cvssv3) + if cvss_data: + self.cvssv3 = cvss_data.get("vector") + self.cvssv3_score = cvss_data.get("score") + except Exception as ex: - logger.error("Can't compute cvssv3 score for finding id %i. Invalid cvssv3 vector found: '%s'. Exception: %s", self.id, self.cvssv3, 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 self.set_hash_code(dedupe_option) @@ -3515,8 +3521,8 @@ class Finding_Template(models.Model): 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_regex = RegexValidator(regex=r"^AV:[NALP]|AC:[LH]|PR:[UNLH]|UI:[NR]|S:[UC]|[CIA]:[NLH]", message="CVSS must be entered in format: 'AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H'") - cvssv3 = models.TextField(validators=[cvssv3_regex], max_length=117, null=True) + 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")) + severity = models.CharField(max_length=200, null=True, blank=True) description = models.TextField(null=True, blank=True) mitigation = models.TextField(null=True, blank=True) @@ -4632,7 +4638,11 @@ def __str__(self): auditlog.register(Notification_Webhooks, exclude_fields=["header_name", "header_value"]) -from dojo.utils import calculate_grade, to_str_typed # noqa: E402 # there is issue due to a circular import +from dojo.utils import ( # noqa: E402 # there is issue due to a circular import + calculate_grade, + parse_cvss_data, + to_str_typed, +) tagulous.admin.register(Product.tags) tagulous.admin.register(Test.tags) diff --git a/dojo/tools/aqua/parser.py b/dojo/tools/aqua/parser.py index e78d03086da..bf6b104ab68 100644 --- a/dojo/tools/aqua/parser.py +++ b/dojo/tools/aqua/parser.py @@ -1,6 +1,7 @@ import json from dojo.models import Finding +from dojo.utils import parse_cvss_data class AquaParser: @@ -201,6 +202,7 @@ def get_item(self, resource, vuln, test): f"NVD score v2 ({score}) used for classification.\n" ) severity_justification += "\nNVD v2 vectors: {}".format(vuln.get("nvd_vectors")) + severity_justification += f"\n{used_for_classification}" severity = self.severity_of(score) finding = Finding( @@ -214,7 +216,6 @@ def get_item(self, resource, vuln, test): severity=severity, severity_justification=severity_justification, cwe=0, - cvssv3=cvssv3, description=description.strip(), mitigation=fix_version, references=url, @@ -222,6 +223,12 @@ def get_item(self, resource, vuln, test): component_version=resource.get("version"), impact=severity, ) + + cvss_data = parse_cvss_data(cvssv3) + if cvss_data: + finding.cvssv3 = cvss_data.get("vector") + finding.cvssv3_score = cvss_data.get("score") + if vulnerability_id != "No CVE": finding.unsaved_vulnerability_ids = [vulnerability_id] if vuln.get("epss_score"): diff --git a/dojo/tools/jfrog_xray_unified/parser.py b/dojo/tools/jfrog_xray_unified/parser.py index 3f394cce345..9690cbe4ab5 100644 --- a/dojo/tools/jfrog_xray_unified/parser.py +++ b/dojo/tools/jfrog_xray_unified/parser.py @@ -2,6 +2,7 @@ from datetime import datetime from dojo.models import Finding +from dojo.utils import parse_cvss_data class JFrogXrayUnifiedParser: @@ -134,12 +135,16 @@ def get_item(vulnerability, test): dynamic_finding=False, references=references, impact=severity, - cvssv3=cvssv3, date=scan_time, unique_id_from_tool=vulnerability["issue_id"], tags=tags, ) + cvss_data = parse_cvss_data(cvssv3) + if cvss_data: + finding.cvssv3 = cvss_data.get("vector") + finding.cvssv3_score = cvss_data.get("score") + if vulnerability_id: finding.unsaved_vulnerability_ids = [vulnerability_id] diff --git a/dojo/tools/npm_audit_7_plus/parser.py b/dojo/tools/npm_audit_7_plus/parser.py index 38024110bc4..93f04111689 100644 --- a/dojo/tools/npm_audit_7_plus/parser.py +++ b/dojo/tools/npm_audit_7_plus/parser.py @@ -3,6 +3,7 @@ import logging from dojo.models import Finding +from dojo.utils import parse_cvss_data logger = logging.getLogger(__name__) @@ -166,7 +167,10 @@ def get_item(item_node, tree, test): dojo_finding.cwe = cwe if (cvssv3 is not None) and (len(cvssv3) > 0): - dojo_finding.cvssv3 = cvssv3 + cvss_data = parse_cvss_data(cvssv3) + if cvss_data: + dojo_finding.cvssv3 = cvss_data.get("vector") + dojo_finding.cvssv3_score = cvss_data.get("score") return dojo_finding diff --git a/dojo/tools/qualys/csv_parser.py b/dojo/tools/qualys/csv_parser.py index 9fd19bc46d6..f0672df9c87 100644 --- a/dojo/tools/qualys/csv_parser.py +++ b/dojo/tools/qualys/csv_parser.py @@ -8,6 +8,7 @@ from django.conf import settings from dojo.models import Endpoint, Finding +from dojo.utils import parse_cvss_data _logger = logging.getLogger(__name__) @@ -227,8 +228,13 @@ def build_findings_from_dict(report_findings: [dict]) -> [Finding]: impact=report_finding["Impact"], date=date, vuln_id_from_tool=report_finding["QID"], - cvssv3=cvssv3, ) + # Make sure vector is valid + cvss_data = parse_cvss_data(cvssv3) + if cvss_data: + finding.cvssv3 = cvss_data.get("vector") + finding.cvssv3_score = cvss_data.get("score") + # Qualys reports regression findings as active, but with a Date Last # Fixed. if report_finding["Date Last Fixed"]: diff --git a/dojo/tools/qualys/parser.py b/dojo/tools/qualys/parser.py index 03b6c301710..dded935b7ad 100644 --- a/dojo/tools/qualys/parser.py +++ b/dojo/tools/qualys/parser.py @@ -8,6 +8,7 @@ from dojo.models import Endpoint, Finding from dojo.tools.qualys import csv_parser +from dojo.utils import parse_cvss_data logger = logging.getLogger(__name__) @@ -352,7 +353,11 @@ def parse_finding(host, tree): finding.is_mitigated = temp["mitigated"] finding.active = temp["active"] if temp.get("CVSS_vector") is not None: - finding.cvssv3 = temp.get("CVSS_vector") + cvss_data = parse_cvss_data(temp.get("CVSS_vector")) + if cvss_data: + finding.cvssv3 = cvss_data.get("vector") + finding.cvssv3_score = cvss_data.get("score") + if temp.get("CVSS_value") is not None: finding.cvssv3_score = temp.get("CVSS_value") finding.verified = True diff --git a/dojo/tools/sonatype/parser.py b/dojo/tools/sonatype/parser.py index b82f1937c77..aeb5a0e3e77 100644 --- a/dojo/tools/sonatype/parser.py +++ b/dojo/tools/sonatype/parser.py @@ -2,6 +2,7 @@ from dojo.models import Finding from dojo.tools.sonatype.identifier import ComponentIdentifier +from dojo.utils import parse_cvss_data class SonatypeParser: @@ -63,7 +64,10 @@ def get_finding(security_issue, component, test): finding.cwe = security_issue["cwe"] if "cvssVector" in security_issue: - finding.cvssv3 = security_issue["cvssVector"] + cvss_data = parse_cvss_data(security_issue["cvssVector"]) + if cvss_data: + finding.cvssv3 = cvss_data.get("vector") + finding.cvssv3_score = cvss_data.get("score") if "pathnames" in component: finding.file_path = " ".join(component["pathnames"])[:1000] diff --git a/dojo/tools/sysdig_cli/parser.py b/dojo/tools/sysdig_cli/parser.py index 60d76bf57f9..dd8cf91f790 100644 --- a/dojo/tools/sysdig_cli/parser.py +++ b/dojo/tools/sysdig_cli/parser.py @@ -108,7 +108,7 @@ def parse_json(self, data, test): finding.cvssv3_score = vulnCvssScore vectors = cvss.parser.parse_cvss_from_text(vulnCvssVector) if len(vectors) > 0 and isinstance(vectors[0], CVSS3): - finding.cvss = vectors[0].clean_vector() + finding.cvssv3 = vectors[0].clean_vector() except ValueError: continue @@ -164,7 +164,7 @@ def parse_csv(self, arr_data, test): finding.cvssv3_score = float(row.cvss_score) vectors = cvss.parser.parse_cvss_from_text(row.cvss_vector) if len(vectors) > 0 and isinstance(vectors[0], CVSS3): - finding.cvss = vectors[0].clean_vector() + finding.cvssv3 = vectors[0].clean_vector() except ValueError: continue finding.risk_accepted = row.risk_accepted diff --git a/dojo/tools/sysdig_reports/parser.py b/dojo/tools/sysdig_reports/parser.py index 0160e285e8a..c488c307ea2 100644 --- a/dojo/tools/sysdig_reports/parser.py +++ b/dojo/tools/sysdig_reports/parser.py @@ -221,7 +221,7 @@ def parse_csv(self, arr_data, test): finding.cvssv3_score = float(row.cvss_score) vectors = cvss.parser.parse_cvss_from_text(row.cvss_vector) if len(vectors) > 0 and isinstance(vectors[0], CVSS3): - finding.cvss = vectors[0].clean_vector() + finding.cvssv3 = vectors[0].clean_vector() except ValueError: continue finding.risk_accepted = row.risk_accepted diff --git a/dojo/tools/trivy/parser.py b/dojo/tools/trivy/parser.py index fbe87bd64cd..f06a4de1395 100644 --- a/dojo/tools/trivy/parser.py +++ b/dojo/tools/trivy/parser.py @@ -4,6 +4,7 @@ import logging from dojo.models import Finding +from dojo.utils import parse_cvss_data logger = logging.getLogger(__name__) @@ -166,12 +167,19 @@ def get_result_items(self, test, results, service_name=None, artifact_name=""): severity_source = vuln.get("SeveritySource", None) cvss = vuln.get("CVSS", None) cvssv3 = None + cvssv3_score = None if severity_source is not None and cvss is not None: cvssclass = cvss.get(severity_source, None) if cvssclass is not None: if cvssclass.get("V3Score") is not None: severity = self.convert_cvss_score(cvssclass.get("V3Score")) - cvssv3 = dict(cvssclass).get("V3Vector") + cvssv3_string = dict(cvssclass).get("V3Vector") + cvss_data = parse_cvss_data(cvssv3_string) + if cvss_data: + cvssv3 = cvss_data.get("vector") + cvssv3_score = cvss_data.get("score") + elif cvssclass.get("V3Score") is not None: + cvssv3_score = cvssclass.get("V3Score") elif cvssclass.get("V2Score") is not None: severity = self.convert_cvss_score(cvssclass.get("V2Score")) else: @@ -216,6 +224,7 @@ def get_result_items(self, test, results, service_name=None, artifact_name=""): component_name=package_name, component_version=package_version, cvssv3=cvssv3, + cvssv3_score=cvssv3_score, static_finding=True, dynamic_finding=False, tags=[vul_type, target_class], diff --git a/dojo/utils.py b/dojo/utils.py index 81f282c2c09..a7d57b6c915 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -15,17 +15,18 @@ import bleach import crum +import cvss.parser import hyperlink import vobject from asteval import Interpreter from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cvss.cvss3 import CVSS3 from dateutil.parser import parse from dateutil.relativedelta import MO, SU, relativedelta from django.conf import settings from django.contrib import messages from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed -from django.core.exceptions import ValidationError from django.core.paginator import Paginator from django.db.models import Case, Count, IntegerField, Q, Sum, Value, When from django.db.models.query import QuerySet @@ -2657,18 +2658,16 @@ def generate_file_response_from_file_path( return response -def tag_validator(value: str | list[str], exception_class: Callable = ValidationError) -> None: - TAG_PATTERN = re.compile(r'[ ,\'"]') - error_messages = [] +def parse_cvss_data(cvss_vector_string: str) -> dict: + if not cvss_vector_string: + return {} - if isinstance(value, list): - error_messages.extend(f"Invalid tag: '{tag}'. Tags should not contain spaces, commas, or quotes." for tag in value if TAG_PATTERN.search(tag)) - elif isinstance(value, str): - if TAG_PATTERN.search(value): - error_messages.append(f"Invalid tag: '{value}'. Tags should not contain spaces, commas, or quotes.") - else: - error_messages.append(f"Value must be a string or list of strings: {value} - {type(value)}.") - - if error_messages: - logger.debug(f"Tag validation failed: {error_messages}") - raise exception_class(error_messages) + vectors = cvss.parser.parse_cvss_from_text(cvss_vector_string) + if len(vectors) > 0 and type(vectors[0]) is CVSS3: + return { + "vector": vectors[0].clean_vector(), + "score": vectors[0].scores()[2], # environmental score is the most detailed one + "severity": vectors[0].severities()[0], + } + logger.debug("No valid CVSS3 vector found in %s", cvss_vector_string) + return {} diff --git a/dojo/validators.py b/dojo/validators.py new file mode 100644 index 00000000000..e6e3a784b2b --- /dev/null +++ b/dojo/validators.py @@ -0,0 +1,53 @@ +import logging +import re +from collections.abc import Callable + +import cvss.parser +from cvss import CVSS2, CVSS3, CVSS4 +from django.core.exceptions import ValidationError + +logger = logging.getLogger(__name__) + + +def tag_validator(value: str | list[str], exception_class: Callable = ValidationError) -> None: + TAG_PATTERN = re.compile(r'[ ,\'"]') + error_messages = [] + + if isinstance(value, list): + error_messages.extend(f"Invalid tag: '{tag}'. Tags should not contain spaces, commas, or quotes." for tag in value if TAG_PATTERN.search(tag)) + elif isinstance(value, str): + if TAG_PATTERN.search(value): + error_messages.append(f"Invalid tag: '{value}'. Tags should not contain spaces, commas, or quotes.") + else: + error_messages.append(f"Value must be a string or list of strings: {value} - {type(value)}.") + + if error_messages: + logger.debug(f"Tag validation failed: {error_messages}") + raise exception_class(error_messages) + + +def cvss3_validator(value: str | list[str], exception_class: Callable = ValidationError) -> None: + logger.error("cvss3_validator called with value: %s", value) + cvss_vectors = cvss.parser.parse_cvss_from_text(value) + if len(cvss_vectors) > 0: + vector_obj = cvss_vectors[0] + + if isinstance(vector_obj, CVSS3): + # all is good + return + + if isinstance(vector_obj, CVSS4): + # CVSS4 is not supported yet by the parse_cvss_from_text function, but let's prepare for it anyway: https://github.com/RedHatProductSecurity/cvss/issues/53 + msg = "Unsupported CVSS(4) version detected." + raise exception_class(msg) + if isinstance(vector_obj, CVSS2): + msg = "Unsupported CVSS(2) version detected." + raise exception_class(msg) + + msg = "Unsupported CVSS version detected." + raise exception_class(msg) + + # Explicitly raise an error if no CVSS vectors are found, + # to avoid 'NoneType' errors during severity processing later. + msg = "No valid CVSS vectors found by cvss.parse_cvss_from_text()" + raise exception_class(msg) diff --git a/run-unittest.sh b/run-unittest.sh index 6aaa8c78cb4..e1956a981df 100755 --- a/run-unittest.sh +++ b/run-unittest.sh @@ -51,7 +51,7 @@ then fi echo "Running docker compose unit tests with test case $TEST_CASE ..." -# Compose V2 integrates compose functions into the Docker platform, continuing to support -# most of the previous docker-compose features and flags. You can run Compose V2 by +# Compose V2 integrates compose functions into the Docker platform, continuing to support +# most of the previous docker-compose features and flags. You can run Compose V2 by # replacing the hyphen (-) with a space, using docker compose, instead of docker-compose. docker compose exec uwsgi bash -c "python manage.py test $TEST_CASE -v2 --keepdb" diff --git a/tests/finding_test.py b/tests/finding_test.py index 0f7ec62a5dc..d55c269333e 100644 --- a/tests/finding_test.py +++ b/tests/finding_test.py @@ -112,7 +112,6 @@ def test_edit_finding(self): driver.find_element(By.ID, "dropdownMenu1").click() # Click on `Edit Finding` driver.find_element(By.LINK_TEXT, "Edit Finding").click() - # Change: 'Severity' and 'cvssv3' # finding Severity Select(driver.find_element(By.ID, "id_severity")).select_by_visible_text("Critical") # finding Vulnerability Ids @@ -165,6 +164,8 @@ def _edit_finding_cvssv3_and_assert( self.assertEqual(str(expected_cvssv3_score), driver.find_element(By.ID, "id_cvssv3_score").get_attribute("value")) else: self.assertTrue(self.is_error_message_present(text=error_message)) + self.assertEqual(expected_cvssv3_value, driver.find_element(By.ID, "id_cvssv3").get_attribute("value")) + self.assertEqual(str(expected_cvssv3_score), driver.find_element(By.ID, "id_cvssv3_score").get_attribute("value")) # See https://github.com/DefectDojo/django-DefectDojo/issues/8264 # Capturing current behavior which might not be the desired one yet @@ -184,8 +185,9 @@ def test_edit_finding_cvssv3_valid_vector_no_prefix(self): cvssv3_value="AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", cvssv3_score="2", expected_cvssv3_value="AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", - expected_cvssv3_score="2.0", - expect_success=True, + expected_cvssv3_score="2", + expect_success=False, + error_message="No valid CVSS vectors found by cvss.parse_cvss_from_text()", ) @on_exception_html_source_logger @@ -194,29 +196,53 @@ def test_edit_finding_cvssv3_valid_vector_with_trailing_slash(self): cvssv3_value="CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H/", cvssv3_score="3", expected_cvssv3_value="CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H/", - expected_cvssv3_score="3.0", - expect_success=True, + expected_cvssv3_score="3", + expect_success=False, + error_message="No valid CVSS vectors found by cvss.parse_cvss_from_text()", ) @on_exception_html_source_logger - def test_edit_finding_cvssv3_with_v2_vector(self): + def test_edit_finding_cvssv3_with_v2_vector_invalid_due_to_prefix(self): self._edit_finding_cvssv3_and_assert( cvssv3_value="CVSS:2.0/AV:N/AC:L/Au:N/C:P/I:P/A:P", cvssv3_score="4", expected_cvssv3_value="CVSS:2.0/AV:N/AC:L/Au:N/C:P/I:P/A:P", - expected_cvssv3_score="4.0", - expect_success=True, + expected_cvssv3_score="4", + expect_success=False, + error_message="No valid CVSS vectors found by cvss.parse_cvss_from_text()", + ) + + @on_exception_html_source_logger + def test_edit_finding_cvssv3_with_v2_vector(self): + self._edit_finding_cvssv3_and_assert( + cvssv3_value="AV:N/AC:L/Au:N/C:P/I:P/A:P", + cvssv3_score="4", + expected_cvssv3_value="AV:N/AC:L/Au:N/C:P/I:P/A:P", + expected_cvssv3_score="4", + expect_success=False, + error_message="Unsupported CVSS(2) version detected.", + ) + + @on_exception_html_source_logger + def test_edit_finding_cvssv3_with_v4_vector(self): + self._edit_finding_cvssv3_and_assert( + cvssv3_value="CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/S:U/C:H/I:H/A:H", + cvssv3_score="5", + expected_cvssv3_value="CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/S:U/C:H/I:H/A:H", + expected_cvssv3_score="5", + expect_success=False, + error_message="No valid CVSS vectors found by cvss.parse_cvss_from_text()", ) @on_exception_html_source_logger def test_edit_finding_cvssv3_with_rubbish(self): self._edit_finding_cvssv3_and_assert( cvssv3_value="happy little vector", - cvssv3_score="4", - expected_cvssv3_value=None, - expected_cvssv3_score=None, + cvssv3_score="5", + expected_cvssv3_value="happy little vector", + expected_cvssv3_score="5", expect_success=False, - error_message="CVSS must be entered in format: 'AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H'", + error_message="No valid CVSS vectors found by cvss.parse_cvss_from_text()", ) def test_add_image(self): @@ -654,6 +680,8 @@ def add_finding_tests_to_suite(suite, *, jira=False, github=False, block_executi suite.addTest(FindingTest("test_edit_finding_cvssv3_valid_vector_no_prefix")) suite.addTest(FindingTest("test_edit_finding_cvssv3_valid_vector_with_trailing_slash")) suite.addTest(FindingTest("test_edit_finding_cvssv3_with_v2_vector")) + suite.addTest(FindingTest("test_edit_finding_cvssv3_with_v2_vector_invalid_due_to_prefix")) + suite.addTest(FindingTest("test_edit_finding_cvssv3_with_v4_vector")) suite.addTest(FindingTest("test_edit_finding_cvssv3_with_rubbish")) suite.addTest(FindingTest("test_add_note_to_finding")) suite.addTest(FindingTest("test_add_image")) diff --git a/unittests/test_finding_model.py b/unittests/test_finding_model.py index aa6971accb0..bc65ffd1096 100644 --- a/unittests/test_finding_model.py +++ b/unittests/test_finding_model.py @@ -318,6 +318,7 @@ def test_get_references_with_links_markdown(self): # See https://github.com/DefectDojo/django-DefectDojo/issues/8264 # Capturing current behavior which might not be the desired one yet + # This test saves vectors without any validation. This is capturing current behavior. def test_cvssv3(self): """Tests if the CVSSv3 score is calculated correctly""" user, _ = User.objects.get_or_create(username="admin") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 0f04e7b7799..3cf133f0fe6 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -1281,68 +1281,84 @@ def test_severity_validation(self): self.assertEqual(result.status_code, status.HTTP_400_BAD_REQUEST, "Severity just got set to something invalid") self.assertEqual(result.json()["severity"], ["Severity must be one of the following: ['Info', 'Low', 'Medium', 'High', 'Critical']"]) - # See https://github.com/DefectDojo/django-DefectDojo/issues/8264 - # Capturing current behavior which might not be the desired one yet def test_cvss3_validation(self): with self.subTest(i=0): self.assertEqual(None, Finding.objects.get(id=2).cvssv3) result = self.client.patch(self.url + "2/", data={"cvssv3": "CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", "cvssv3_score": 3}) self.assertEqual(result.status_code, status.HTTP_200_OK) finding = Finding.objects.get(id=2) + # valid so vector must be set and score calculated self.assertEqual("CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) self.assertEqual(8.8, finding.cvssv3_score) with self.subTest(i=1): # extra slash makes it invalid result = self.client.patch(self.url + "3/", data={"cvssv3": "CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H/", "cvssv3_score": 3}) - self.assertEqual(result.status_code, status.HTTP_200_OK) + self.assertEqual(result.status_code, status.HTTP_400_BAD_REQUEST) finding = Finding.objects.get(id=3) - self.assertEqual("CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H/", finding.cvssv3) - self.assertEqual(3, finding.cvssv3_score) + self.assertEqual(result.json()["cvssv3"], ["No valid CVSS vectors found by cvss.parse_cvss_from_text()"]) + # invalid vector, so no calculated score and no score stored + self.assertEqual(None, finding.cvssv3) + self.assertEqual(None, finding.cvssv3_score) with self.subTest(i=2): # no CVSS version prefix makes it invalid result = self.client.patch(self.url + "3/", data={"cvssv3": "AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", "cvssv3_score": 4}) - self.assertEqual(result.status_code, status.HTTP_200_OK) + self.assertEqual(result.status_code, status.HTTP_400_BAD_REQUEST) finding = Finding.objects.get(id=3) - self.assertEqual("AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) - # invalid vector, so no calculated score - self.assertEqual(4, finding.cvssv3_score) + self.assertEqual(result.json()["cvssv3"], ["No valid CVSS vectors found by cvss.parse_cvss_from_text()"]) + # invalid vector, so no calculated score and no score stored + self.assertEqual(None, finding.cvssv3) + self.assertEqual(None, finding.cvssv3_score) with self.subTest(i=3): # CVSS4 version makes it invalid result = self.client.patch(self.url + "3/", data={"cvssv3": "CVSS:4.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", "cvssv3_score": 5}) - self.assertEqual(result.status_code, status.HTTP_200_OK) + self.assertEqual(result.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(result.json()["cvssv3"], ["No valid CVSS vectors found by cvss.parse_cvss_from_text()"]) finding = Finding.objects.get(id=3) - self.assertEqual("CVSS:4.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) - # invalid vector, so no calculated score - self.assertEqual(5, finding.cvssv3_score) + # invalid vector, so no calculated score and no score stored + self.assertEqual(None, finding.cvssv3) + self.assertEqual(None, finding.cvssv3_score) with self.subTest(i=4): # CVSS2 style vector makes not supported result = self.client.patch(self.url + "3/", data={"cvssv3": "AV:N/AC:L/Au:N/C:P/I:P/A:P", "cvssv3_score": 6}) - self.assertEqual(result.status_code, status.HTTP_200_OK) + self.assertEqual(result.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(result.json()["cvssv3"], ["Unsupported CVSS(2) version detected."]) finding = Finding.objects.get(id=3) - self.assertEqual("AV:N/AC:L/Au:N/C:P/I:P/A:P", finding.cvssv3) - # invalid vector, so no calculated score - self.assertEqual(6, finding.cvssv3_score) + # invalid vector, so no calculated score and no score stored + self.assertEqual(None, finding.cvssv3) + self.assertEqual(None, finding.cvssv3_score) with self.subTest(i=5): # CVSS2 prefix makes it invalid result = self.client.patch(self.url + "3/", data={"cvssv3": "CVSS:2.0/AV:N/AC:L/Au:N/C:P/I:P/A:P", "cvssv3_score": 7}) - self.assertEqual(result.status_code, status.HTTP_200_OK) + self.assertEqual(result.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(result.json()["cvssv3"], ["No valid CVSS vectors found by cvss.parse_cvss_from_text()"]) finding = Finding.objects.get(id=3) - self.assertEqual("CVSS:2.0/AV:N/AC:L/Au:N/C:P/I:P/A:P", finding.cvssv3) - # invalid vector, so no calculated score - self.assertEqual(7, finding.cvssv3_score) + # invalid vector, so no calculated score and no score stored + self.assertEqual(None, finding.cvssv3) + self.assertEqual(None, finding.cvssv3_score) with self.subTest(i=6): # try to put rubbish in there result = self.client.patch(self.url + "4/", data={"cvssv3": "happy little vector", "cvssv3_score": 3}) self.assertEqual(result.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(result.json()["cvssv3"], ["No valid CVSS vectors found by cvss.parse_cvss_from_text()"]) finding = Finding.objects.get(id=4) + # invalid vector, so no calculated score and no score stored + self.assertEqual(None, finding.cvssv3) + self.assertEqual(None, finding.cvssv3_score) + + with self.subTest(i=7): + # CVSS4 prefix makes it invalid + result = self.client.patch(self.url + "3/", data={"cvssv3": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/S:U/C:H/I:H/A:H", "cvssv3_score": 7}) + self.assertEqual(result.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(result.json()["cvssv3"], ["No valid CVSS vectors found by cvss.parse_cvss_from_text()"]) + finding = Finding.objects.get(id=3) + # invalid vector, so no calculated score and no score stored self.assertEqual(None, finding.cvssv3) - # invalid request, so no score at all self.assertEqual(None, finding.cvssv3_score) diff --git a/unittests/tools/test_generic_parser.py b/unittests/tools/test_generic_parser.py index 0b3d441e76a..b6b83da6719 100644 --- a/unittests/tools/test_generic_parser.py +++ b/unittests/tools/test_generic_parser.py @@ -456,6 +456,7 @@ def test_parse_json(self): self.assertIn("network", finding.unsaved_tags) self.assertEqual("3287f2d0-554f-491b-8516-3c349ead8ee5", finding.unique_id_from_tool) self.assertEqual("TEST1", finding.vuln_id_from_tool) + # finding.clean_fields(exclude=["reporter", "numerical_severity", "planned_remediation_date"]) with self.subTest(i=1): finding = findings[1] self.assertEqual("test title2", finding.title)