From 3250e7dcdcf44467acfe5a355ed9d788d5991323 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 6 Jul 2025 11:16:31 +0200 Subject: [PATCH 01/26] cvss4: model + parsers --- ...234_finding_cvssv4_finding_cvssv4_score.py | 25 +++++ dojo/models.py | 33 +++++- .../engines/base.py | 1 + dojo/tools/aqua/parser.py | 4 +- dojo/tools/auditjs/parser.py | 103 +++++------------- dojo/tools/jfrog_xray_unified/parser.py | 4 +- dojo/tools/npm_audit_7_plus/parser.py | 4 +- dojo/tools/ptart/assessment_parser.py | 15 ++- dojo/tools/ptart/ptart_parser_tools.py | 1 + dojo/tools/ptart/retest_parser.py | 22 ++-- dojo/tools/qualys/csv_parser.py | 4 +- dojo/tools/qualys/parser.py | 4 +- dojo/tools/sonatype/parser.py | 4 +- dojo/tools/trivy/parser.py | 4 +- dojo/utils.py | 81 ++++++++++++-- dojo/validators.py | 31 +++++- unittests/tools/test_auditjs_parser.py | 13 ++- unittests/tools/test_ptart_parser.py | 36 ------ 18 files changed, 234 insertions(+), 155 deletions(-) create mode 100644 dojo/db_migrations/0234_finding_cvssv4_finding_cvssv4_score.py diff --git a/dojo/db_migrations/0234_finding_cvssv4_finding_cvssv4_score.py b/dojo/db_migrations/0234_finding_cvssv4_finding_cvssv4_score.py new file mode 100644 index 00000000000..2e33f7c10db --- /dev/null +++ b/dojo/db_migrations/0234_finding_cvssv4_finding_cvssv4_score.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.8 on 2025-07-06 07:25 + +import django.core.validators +import dojo.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0233_remove_test_actual_time_remove_test_estimated_time'), + ] + + operations = [ + migrations.AddField( + model_name='finding', + name='cvssv4', + field=models.TextField(help_text='Common Vulnerability Scoring System version 4 (CVSSv4) score associated with this finding.', max_length=117, null=True, validators=[dojo.validators.cvss4_validator], verbose_name='CVSS v4 vector'), + ), + migrations.AddField( + model_name='finding', + name='cvssv4_score', + field=models.FloatField(blank=True, 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.', null=True, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(10.0)], verbose_name='CVSSv4 score'), + ), + ] diff --git a/dojo/models.py b/dojo/models.py index 68ab2092c3d..08e132de3ea 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -43,7 +43,7 @@ from tagulous.models import TagField from tagulous.models.managers import FakeTagRelatedManager -from dojo.validators import cvss3_validator +from dojo.validators import cvss3_validator, cvss4_validator logger = logging.getLogger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") @@ -2356,6 +2356,17 @@ class Finding(models.Model): 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=117, + null=True, + verbose_name=_("CVSS v4 vector"), + help_text=_("Common Vulnerability Scoring System version 4 (CVSSv4) 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, @@ -2712,13 +2723,14 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru 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("vector") - self.cvssv3_score = cvss_data.get("score") + self.cvssv3 = cvss_data.get("cvssv3") + if not self.cvssv3_score: + 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) @@ -2726,6 +2738,19 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru if self.pk is None: self.cvssv3 = None + # behaviour for CVVS4 is slightly different. Extracting thsi into a method would lead to probabl hard to read code + if self.cvssv4: + try: + cvss_data = parse_cvss_data(self.cvssv4) + if cvss_data: + self.cvssv4 = cvss_data.get("cvssv4_vector") + if not self.cvssv4_score: + 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 self.pk is None: diff --git a/dojo/tools/appcheck_web_application_scanner/engines/base.py b/dojo/tools/appcheck_web_application_scanner/engines/base.py index 1efefeb9c17..9700af69bf4 100644 --- a/dojo/tools/appcheck_web_application_scanner/engines/base.py +++ b/dojo/tools/appcheck_web_application_scanner/engines/base.py @@ -307,6 +307,7 @@ def set_endpoints(self, finding: Finding, item: Any) -> None: ##### def parse_cvss_vector(self, value: str) -> str | None: # CVSS4 vectors don't parse with the handy-danty parse method :( + # TODO: This needs a rewrite to use dojo.utils.parse_cvss_data try: if (severity := cvss.CVSS4(value).severity) in Finding.SEVERITIES: return severity diff --git a/dojo/tools/aqua/parser.py b/dojo/tools/aqua/parser.py index bf6b104ab68..01e697f9224 100644 --- a/dojo/tools/aqua/parser.py +++ b/dojo/tools/aqua/parser.py @@ -226,8 +226,8 @@ def get_item(self, resource, vuln, test): cvss_data = parse_cvss_data(cvssv3) if cvss_data: - finding.cvssv3 = cvss_data.get("vector") - finding.cvssv3_score = cvss_data.get("score") + finding.cvssv3 = cvss_data.get("cvssv3") + finding.cvssv3_score = cvss_data.get("cvssv3_score") if vulnerability_id != "No CVE": finding.unsaved_vulnerability_ids = [vulnerability_id] diff --git a/dojo/tools/auditjs/parser.py b/dojo/tools/auditjs/parser.py index f9aec0c9a26..aad30c313ce 100644 --- a/dojo/tools/auditjs/parser.py +++ b/dojo/tools/auditjs/parser.py @@ -1,49 +1,13 @@ import json +import logging import re from json.decoder import JSONDecodeError # import cvss.parser -from cvss import CVSS2, CVSS3, CVSS4, CVSSError - from dojo.models import Finding +from dojo.utils import parse_cvss_data - -# TEMPORARY: Local implementation until the upstream PR is merged & released: https://github.com/RedHatProductSecurity/cvss/pull/75 -def parse_cvss_from_text(text): - """ - Parses CVSS2, CVSS3, and CVSS4 vectors from arbitrary text and returns a list of CVSS objects. - - Parses text for substrings that look similar to CVSS vector - and feeds these matches to CVSS constructor. - - Args: - text (str): arbitrary text - - Returns: - A list of CVSS objects. - - """ - # Looks for substrings that resemble CVSS2, CVSS3, or CVSS4 vectors. - # CVSS3 and CVSS4 vectors start with a 'CVSS:x.x/' prefix and are matched by the optional non-capturing group. - # CVSS2 vectors do not include a prefix and are matched by raw vector pattern only. - # Minimum total match length is 26 characters to reduce false positives. - matches = re.compile(r"(?:CVSS:[3-4]\.\d/)?[A-Za-z:/]{26,}").findall(text) - - cvsss = set() - for match in matches: - try: - if match.startswith("CVSS:4."): - cvss = CVSS4(match) - elif match.startswith("CVSS:3."): - cvss = CVSS3(match) - else: - cvss = CVSS2(match) - - cvsss.add(cvss) - except (CVSSError, KeyError): - pass - - return list(cvsss) +logger = logging.getLogger(__name__) class AuditJSParser: @@ -107,7 +71,13 @@ def get_findings(self, filename, test): ) = ( cvss_score ) = ( - cvss_vector + cvssv3 + ) = ( + cvssv4 + ) = ( + cvssv3_score + ) = ( + cvssv4_score ) = vulnerability_id = cwe = references = severity = None # Check mandatory if ( @@ -127,38 +97,20 @@ def get_findings(self, filename, test): raise ValueError(msg) if "cvssScore" in vulnerability: cvss_score = vulnerability["cvssScore"] - if "cvssVector" in vulnerability: - cvss_vectors = parse_cvss_from_text( - vulnerability["cvssVector"], - ) - - if len(cvss_vectors) > 0: - vector_obj = cvss_vectors[0] - - if isinstance(vector_obj, CVSS4): - description += "\nCVSS V4 Vector:" + vector_obj.clean_vector() - severity = vector_obj.severities()[0] - - elif isinstance(vector_obj, CVSS3): - cvss_vector = vector_obj.clean_vector() - severity = vector_obj.severities()[0] - - elif isinstance(vector_obj, CVSS2): - description += "\nCVSS V2 Vector:" + vector_obj.clean_vector() - severity = vector_obj.severities()[0] - - else: - msg = "Unsupported CVSS version detected in parser." - raise ValueError(msg) - else: - # Explicitly raise an error if no CVSS vectors are found, - # to avoid 'NoneType' errors during severity processing later. - msg = "No CVSS vectors found. Please check that parse_cvss_from_text() correctly parses the provided cvssVector." - raise ValueError(msg) + cvss_data = parse_cvss_data(vulnerability.get("cvssVector")) + if cvss_data: + severity = cvss_data["severity"] + cvssv3 = cvss_data["cvssv3"] + cvssv4 = cvss_data["cvssv4"] + # The score in the report can be different from what the cvss library calulates + if cvss_data["major_version"] == 2: + description += "\nCVSS V2 Vector:" + cvss_data["cvssv2"] + if cvss_data["major_version"] == 3: + cvssv3_score = cvss_score + if cvss_data["major_version"] == 4: + cvssv4_score = cvss_score else: - # If there is no vector, calculate severity based on - # score and CVSS V3 (AuditJS does not always include - # it) + # If there is no vector, calculate severity based on CVSS score severity = self.get_severity(cvss_score) if "cve" in vulnerability: vulnerability_id = vulnerability["cve"] @@ -169,10 +121,12 @@ def get_findings(self, filename, test): title=title, test=test, cwe=cwe, - cvssv3=cvss_vector, - cvssv3_score=cvss_score, description=description, severity=severity, + cvssv3=cvssv3, + cvssv3_score=cvssv3_score, + cvssv4=cvssv4, + cvssv4_score=cvssv4_score, references=references, file_path=file_path, component_name=component_name, @@ -181,6 +135,9 @@ def get_findings(self, filename, test): dynamic_finding=False, unique_id_from_tool=unique_id_from_tool, ) + logger.debug("Finding fields:") + for field, value in finding.__dict__.items(): + logger.debug(" %s: %r", field, value) if vulnerability_id: finding.unsaved_vulnerability_ids = [vulnerability_id] diff --git a/dojo/tools/jfrog_xray_unified/parser.py b/dojo/tools/jfrog_xray_unified/parser.py index 9690cbe4ab5..9d2f0421ba0 100644 --- a/dojo/tools/jfrog_xray_unified/parser.py +++ b/dojo/tools/jfrog_xray_unified/parser.py @@ -142,8 +142,8 @@ def get_item(vulnerability, test): cvss_data = parse_cvss_data(cvssv3) if cvss_data: - finding.cvssv3 = cvss_data.get("vector") - finding.cvssv3_score = cvss_data.get("score") + finding.cvssv3 = cvss_data.get("cvssv3") + finding.cvssv3_score = cvss_data.get("cvssv3_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 93f04111689..653266a12bc 100644 --- a/dojo/tools/npm_audit_7_plus/parser.py +++ b/dojo/tools/npm_audit_7_plus/parser.py @@ -169,8 +169,8 @@ def get_item(item_node, tree, test): if (cvssv3 is not None) and (len(cvssv3) > 0): cvss_data = parse_cvss_data(cvssv3) if cvss_data: - dojo_finding.cvssv3 = cvss_data.get("vector") - dojo_finding.cvssv3_score = cvss_data.get("score") + dojo_finding.cvssv3 = cvss_data.get("cvssv3") + dojo_finding.cvssv3_score = cvss_data.get("cvssv3_score") return dojo_finding diff --git a/dojo/tools/ptart/assessment_parser.py b/dojo/tools/ptart/assessment_parser.py index ffeb09535d3..e4b301e540e 100644 --- a/dojo/tools/ptart/assessment_parser.py +++ b/dojo/tools/ptart/assessment_parser.py @@ -1,5 +1,6 @@ import dojo.tools.ptart.ptart_parser_tools as ptart_tools from dojo.models import Finding +from dojo.utils import parse_cvss_data class PTARTAssessmentParser: @@ -43,10 +44,18 @@ def get_finding(self, assessment, hit): finding.vuln_id_from_tool = hit.get("id") finding.cve = hit.get("id") - # Clean up and parse the CVSS vector - cvss_vector = ptart_tools.parse_cvss_vector(hit, self.cvss_type) + cvss_vector = hit.get("cvss_vector", None) + cvss_score = hit.get("cvss_score", None) if cvss_vector: - finding.cvssv3 = cvss_vector + cvss_data = parse_cvss_data(cvss_vector) + if cvss_data: + finding.cvssv3 = cvss_data["cvssv3"] + finding.cvssv4 = cvss_data["cvssv4"] + # The score in the report can be different from what the cvss library calulates + if cvss_data["major_version"] == 3: + finding.cvssv3_score = cvss_score + if cvss_data["major_version"] == 4: + finding.cvssv4_score = cvss_score if "labels" in hit: finding.unsaved_tags = hit["labels"] diff --git a/dojo/tools/ptart/ptart_parser_tools.py b/dojo/tools/ptart/ptart_parser_tools.py index 06732310cab..b10f34caab4 100644 --- a/dojo/tools/ptart/ptart_parser_tools.py +++ b/dojo/tools/ptart/ptart_parser_tools.py @@ -62,6 +62,7 @@ def parse_cvss_vector(hit, cvss_type): return c.clean_vector() except cvss.CVSS3Error: return None + return None diff --git a/dojo/tools/ptart/retest_parser.py b/dojo/tools/ptart/retest_parser.py index 6a029b4ddc2..f38e3f519e5 100644 --- a/dojo/tools/ptart/retest_parser.py +++ b/dojo/tools/ptart/retest_parser.py @@ -1,5 +1,6 @@ import dojo.tools.ptart.ptart_parser_tools as ptart_tools from dojo.models import Finding +from dojo.utils import parse_cvss_data def generate_retest_hit_title(hit, original_hit): @@ -16,13 +17,8 @@ def generate_retest_hit_title(hit, original_hit): class PTARTRetestParser: - def __init__(self): - self.cvss_type = None - def get_test_data(self, tree): - self.cvss_type = None if "retests" in tree: - self.cvss_type = tree.get("cvss_type", None) retests = tree["retests"] else: return [] @@ -82,12 +78,18 @@ def get_finding(self, retest, hit): finding.vuln_id_from_tool = original_hit.get("id") finding.cve = original_hit.get("id") - cvss_vector = ptart_tools.parse_cvss_vector( - original_hit, - self.cvss_type, - ) + cvss_vector = original_hit.get("cvss_vector", None) + cvss_score = original_hit.get("cvss_score", None) if cvss_vector: - finding.cvssv3 = cvss_vector + cvss_data = parse_cvss_data(cvss_vector) + if cvss_data: + finding.cvssv3 = cvss_data["cvssv3"] + finding.cvssv4 = cvss_data["cvssv4"] + # The score in the report can be different from what the cvss library calulates + if cvss_data["major_version"] == 3: + finding.cvssv3_score = cvss_score + if cvss_data["major_version"] == 4: + finding.cvssv4_score = cvss_score if "labels" in original_hit: finding.unsaved_tags = original_hit["labels"] diff --git a/dojo/tools/qualys/csv_parser.py b/dojo/tools/qualys/csv_parser.py index f0672df9c87..49d31b1783d 100644 --- a/dojo/tools/qualys/csv_parser.py +++ b/dojo/tools/qualys/csv_parser.py @@ -232,8 +232,8 @@ def build_findings_from_dict(report_findings: [dict]) -> [Finding]: # 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") + finding.cvssv3 = cvss_data.get("cvssv3") + finding.cvssv3_score = cvss_data.get("cvssv3_score") # Qualys reports regression findings as active, but with a Date Last # Fixed. diff --git a/dojo/tools/qualys/parser.py b/dojo/tools/qualys/parser.py index dded935b7ad..83e9004e53b 100644 --- a/dojo/tools/qualys/parser.py +++ b/dojo/tools/qualys/parser.py @@ -355,8 +355,8 @@ def parse_finding(host, tree): if temp.get("CVSS_vector") is not None: 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") + finding.cvssv3 = cvss_data.get("cvssv3") + finding.cvssv3_score = cvss_data.get("cvssv3_score") if temp.get("CVSS_value") is not None: finding.cvssv3_score = temp.get("CVSS_value") diff --git a/dojo/tools/sonatype/parser.py b/dojo/tools/sonatype/parser.py index aeb5a0e3e77..78fd00c4261 100644 --- a/dojo/tools/sonatype/parser.py +++ b/dojo/tools/sonatype/parser.py @@ -66,8 +66,8 @@ def get_finding(security_issue, component, test): if "cvssVector" in security_issue: cvss_data = parse_cvss_data(security_issue["cvssVector"]) if cvss_data: - finding.cvssv3 = cvss_data.get("vector") - finding.cvssv3_score = cvss_data.get("score") + finding.cvssv3 = cvss_data.get("cvssv3") + finding.cvssv3_score = cvss_data.get("cvssv3_score") if "pathnames" in component: finding.file_path = " ".join(component["pathnames"])[:1000] diff --git a/dojo/tools/trivy/parser.py b/dojo/tools/trivy/parser.py index 27ddafb02aa..6c4d523741a 100644 --- a/dojo/tools/trivy/parser.py +++ b/dojo/tools/trivy/parser.py @@ -255,8 +255,8 @@ def get_result_items(self, test, results, service_name=None, artifact_name=""): 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") + cvssv3 = cvss_data.get("cvssv3") + cvssv3_score = cvss_data.get("cvssv3_score") elif cvssclass.get("V3Score") is not None: cvssv3_score = cvssclass.get("V3Score") elif cvssclass.get("V2Score") is not None: diff --git a/dojo/utils.py b/dojo/utils.py index b6fba9a5eb9..2b2d4e5c8a8 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -15,14 +15,13 @@ import bleach import crum -import cvss.parser import hyperlink import vobject from asteval import Interpreter from auditlog.models import LogEntry from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from cvss.cvss3 import CVSS3 +from cvss import CVSS2, CVSS3, CVSS4, CVSSError from dateutil.parser import parse from dateutil.relativedelta import MO, SU, relativedelta from django.conf import settings @@ -2669,18 +2668,84 @@ def generate_file_response_from_file_path( return response +# TEMPORARY: Local implementation until the upstream PR is merged & released: https://github.com/RedHatProductSecurity/cvss/pull/75 +def parse_cvss_from_text(text): + """ + Parses CVSS2, CVSS3, and CVSS4 vectors from arbitrary text and returns a list of CVSS objects. + + Parses text for substrings that look similar to CVSS vector + and feeds these matches to CVSS constructor. + + Args: + text (str): arbitrary text + + Returns: + A list of CVSS objects. + + """ + # Looks for substrings that resemble CVSS2, CVSS3, or CVSS4 vectors. + # CVSS3 and CVSS4 vectors start with a 'CVSS:x.x/' prefix and are matched by the optional non-capturing group. + # CVSS2 vectors do not include a prefix and are matched by raw vector pattern only. + # Minimum total match length is 26 characters to reduce false positives. + matches = re.compile(r"(?:CVSS:[3-4]\.\d/)?[A-Za-z:/]{26,}").findall(text) + + cvsss = set() + for match in matches: + try: + if match.startswith("CVSS:4."): + cvss = CVSS4(match) + elif match.startswith("CVSS:3."): + cvss = CVSS3(match) + else: + cvss = CVSS2(match) + + cvsss.add(cvss) + except (CVSSError, KeyError): + pass + + return list(cvsss) + + def parse_cvss_data(cvss_vector_string: str) -> dict: if not cvss_vector_string: return {} - vectors = cvss.parser.parse_cvss_from_text(cvss_vector_string) - if len(vectors) > 0 and type(vectors[0]) is CVSS3: + vectors = parse_cvss_from_text(cvss_vector_string) + if len(vectors) > 0: + vector = vectors[0] + # For CVSS2, environmental score is at index 2 + # For CVSS3, environmental score is at index 2 + # For CVSS4, only base score is available (at index 0) + # These CVSS2/3/4 objects do not have a version field (only a minor_version field) + major_version = cvssv2 = cvssv2_score = cvssv3 = cvssv3_score = cvssv4 = cvssv4_score = severity = None + if type(vector) is CVSS4: + major_version = 4 + cvssv4 = vector.clean_vector() + cvssv4_score = vector.scores()[0] + severity = vector.severities()[0] + elif type(vector) is CVSS3: + major_version = 3 + cvssv3 = vector.clean_vector() + cvssv3_score = vector.scores()[2] + severity = vector.severities()[0] + elif type(vector) is CVSS2: + # CVSS2 is not supported, but we return it anyway to allow parser to use the severity or score for other purposes + cvssv2 = vector.clean_vector() + cvssv2_score = vector.scores()[2] + severity = vector.severities()[0] + major_version = 2 + return { - "vector": vectors[0].clean_vector(), - "score": vectors[0].scores()[2], # environmental score is the most detailed one - "severity": vectors[0].severities()[0], + "major_version": major_version, + "cvssv2": cvssv2, + "cvssv2_score": cvssv2_score, + "cvssv3": cvssv3, + "cvssv3_score": cvssv3_score, + "cvssv4": cvssv4, + "cvssv4_score": cvssv4_score, + "severity": severity, } - logger.debug("No valid CVSS3 vector found in %s", cvss_vector_string) + logger.debug("No valid CVSS3 or CVSS4 vector found in %s", cvss_vector_string) return {} diff --git a/dojo/validators.py b/dojo/validators.py index e6e3a784b2b..c8fdc5af8d2 100644 --- a/dojo/validators.py +++ b/dojo/validators.py @@ -27,7 +27,7 @@ def tag_validator(value: str | list[str], exception_class: Callable = Validation def cvss3_validator(value: str | list[str], exception_class: Callable = ValidationError) -> None: - logger.error("cvss3_validator called with value: %s", value) + logger.debug("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] @@ -37,8 +37,33 @@ def cvss3_validator(value: str | list[str], exception_class: Callable = Validati 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." + msg = "CVSS(4) vector vannot be stored in the cvss3 field. Use the cvss4 fields." + 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) + + +def cvss4_validator(value: str | list[str], exception_class: Callable = ValidationError) -> None: + logger.debug("cvss4_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, CVSS4): + # all is good + return + + if isinstance(vector_obj, CVSS3): + msg = "CVSS(3) vector vannot be stored in the cvss3 field. Use the cvss3 fields." raise exception_class(msg) if isinstance(vector_obj, CVSS2): msg = "Unsupported CVSS(2) version detected." diff --git a/unittests/tools/test_auditjs_parser.py b/unittests/tools/test_auditjs_parser.py index 90b7fed3f29..2f31e9003ff 100644 --- a/unittests/tools/test_auditjs_parser.py +++ b/unittests/tools/test_auditjs_parser.py @@ -45,14 +45,17 @@ def test_auditjs_parser_with_many_vuln_has_many_findings(self): # Tests for vulnerabilities with CVSS V4 vector self.assertEqual("dompurify", findings[0].component_name) self.assertEqual("2.5.7", findings[0].component_version) - self.assertEqual(6.4, findings[0].cvssv3_score) + self.assertEqual(None, findings[0].cvssv3_score) + self.assertEqual(6.4, findings[0].cvssv4_score) self.assertEqual("Medium", findings[0].severity) - self.assertEqual(2.1, findings[1].cvssv3_score) + self.assertEqual(None, findings[1].cvssv3_score) + self.assertEqual(2.1, findings[1].cvssv4_score) self.assertEqual("Low", findings[1].severity) self.assertEqual("CVE-2024-47875", findings[0].unique_id_from_tool) self.assertIn("DOMPurify is a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML, MathML and SVG. DOMpurify was...", findings[0].description) - self.assertIn("\nCVSS V4 Vector:", findings[0].description) + self.assertEqual("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:L/SI:H/SA:L", findings[0].cvssv4) + self.assertEqual("CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N", findings[1].cvssv4) self.assertEqual("[CVE-2024-47875] CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')", findings[0].title) self.assertEqual(1, len(findings[0].unsaved_vulnerability_ids)) @@ -64,6 +67,7 @@ def test_auditjs_parser_with_many_vuln_has_many_findings(self): self.assertEqual("connect", findings[2].component_name) self.assertEqual("2.6.0", findings[2].component_version) self.assertEqual(5.4, findings[2].cvssv3_score) + self.assertEqual(None, findings[2].cvssv4_score) self.assertEqual("Medium", findings[2].severity) self.assertEqual("CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N", findings[2].cvssv3) self.assertEqual("7df31426-09a2-4b5f-a0ab-acc699023c57", findings[2].unique_id_from_tool) @@ -80,7 +84,8 @@ def test_auditjs_parser_with_many_vuln_has_many_findings(self): # Tests for vulnerabilities with CVSS V2 vector self.assertEqual("qs", findings[7].component_name) self.assertEqual("0.5.1", findings[7].component_version) - self.assertEqual(5, findings[7].cvssv3_score) + self.assertEqual(None, findings[7].cvssv3_score) + self.assertEqual(None, findings[7].cvssv4_score) self.assertEqual("Medium", findings[7].severity) self.assertEqual("3a3bf289-21dc-4c84-a46e-39280f80bb01", findings[7].unique_id_from_tool) self.assertIn("The qs module before 1.0.0 in Node.js does not call the compact function for array data, which allows...", findings[7].description) diff --git a/unittests/tools/test_ptart_parser.py b/unittests/tools/test_ptart_parser.py index 1a82dbd6bad..bc22e7dda90 100644 --- a/unittests/tools/test_ptart_parser.py +++ b/unittests/tools/test_ptart_parser.py @@ -59,42 +59,6 @@ def test_ptart_parser_tools_parse_title_from_hit(self): with self.subTest("Non-blank Title and Blank id"): self.assertEqual("Test Title", parse_title_from_hit({"title": "Test Title", "id": ""})) - def test_ptart_parser_tools_cvss_vector_acquisition(self): - from dojo.tools.ptart.ptart_parser_tools import parse_cvss_vector - with self.subTest("Test CVSSv3 Vector"): - hit = { - "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", - } - self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", parse_cvss_vector(hit, 3)) - with self.subTest("Test CVSSv4 Vector"): - hit = { - "cvss_vector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N", - } - self.assertEqual(None, parse_cvss_vector(hit, 4)) - with self.subTest("Test CVSSv3 Vector with CVSSv4 Request"): - hit = { - "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", - } - self.assertEqual(None, parse_cvss_vector(hit, 4)) - with self.subTest("Test CVSSv4 Vector with CVSSv3 Request"): - hit = { - "cvss_vector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N", - } - self.assertEqual(None, parse_cvss_vector(hit, 3)) - with self.subTest("Test No CVSS Vector"): - hit = {} - self.assertEqual(None, parse_cvss_vector(hit, 3)) - with self.subTest("Test CVSSv2 Vector"): - hit = { - "cvss_vector": "CVSS:2.0/AV:N/AC:L/Au:N/C:C/I:C/A:C", - } - self.assertEqual(None, parse_cvss_vector(hit, 2)) - with self.subTest("Test Blank CVSS Vector"): - hit = { - "cvss_vector": "", - } - self.assertEqual(None, parse_cvss_vector(hit, 3)) - def test_ptart_parser_tools_retest_fix_status_parse(self): from dojo.tools.ptart.ptart_parser_tools import parse_retest_status with self.subTest("Fixed"): From e638d06b7edcb3395909b94b1fd76e8ff5e8c7a4 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 6 Jul 2025 12:26:14 +0200 Subject: [PATCH 02/26] cvss4: UI + rest tests + fixes --- dojo/forms.py | 5 ++- dojo/models.py | 2 +- dojo/templates/dojo/view_finding.html | 16 ++++----- dojo/validators.py | 11 +++--- unittests/test_metrics_queries.py | 1 + unittests/test_rest_framework.py | 52 ++++++++++++++++++++------- 6 files changed, 59 insertions(+), 28 deletions(-) diff --git a/dojo/forms.py b/dojo/forms.py index 19f1fe6962d..33663058aac 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -1354,6 +1354,9 @@ class FindingForm(forms.ModelForm): vulnerability_ids = vulnerability_ids_field cvssv3 = forms.CharField(max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) cvssv3_score = forms.FloatField(required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(max_length=255, required=False) + cvssv4_score = forms.FloatField(required=False, max_value=10.0, min_value=0.0) + description = forms.CharField(widget=forms.Textarea) severity = forms.ChoiceField( choices=SEVERITY_CHOICES, @@ -1385,7 +1388,7 @@ class FindingForm(forms.ModelForm): # 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", "cvssv3", - "cvssv3_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", + "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") diff --git a/dojo/models.py b/dojo/models.py index 08e132de3ea..06024c438de 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -2743,7 +2743,7 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru try: cvss_data = parse_cvss_data(self.cvssv4) if cvss_data: - self.cvssv4 = cvss_data.get("cvssv4_vector") + self.cvssv4 = cvss_data.get("cvssv4") if not self.cvssv4_score: self.cvssv4_score = cvss_data.get("cvssv4_score") diff --git a/dojo/templates/dojo/view_finding.html b/dojo/templates/dojo/view_finding.html index ba973b1ba34..6b1fe760821 100755 --- a/dojo/templates/dojo/view_finding.html +++ b/dojo/templates/dojo/view_finding.html @@ -292,16 +292,16 @@

{% if finding.severity %} - {% if finding.cvssv3 %} + {{ finding.severity_display }} + {% if finding.cvssv4_score %} + data-content="{{ finding.cvssv4 }}"> + ({{ finding.cvssv4_score }}{% if finding.cvssv3_score %},{% else %}){% endif %} {% endif %} - {{ finding.severity_display }} {% if finding.cvssv3_score %} - ({{ finding.cvssv3_score }}) - {% endif %} - {% if finding.cvssv3 %} - + + {% if not finding.cvssv4_score %}({% endif %}{{ finding.cvssv3_score }}) {% endif %} {% else %} Unknown @@ -760,7 +760,7 @@

Similar Findings ({{ similar_findings.paginator.count }} {% endif %} - + {% endif %} {% comment %} Add a form to (ab)use to submit any actions related to similar/duplicates as POST requests {% endcomment %}