Repository: RedHatProductSecurity/cvss

Issue:
Do we have a `CVSS3.from_rh_vector` equivalent for CVSS4?
Is the `CVSS4.from_rh_vector` missing, or it's not included by design?

Discussion:

Solution patch:
diff --git a/cvss/cvss4.py b/cvss/cvss4.py
index 4eac9ef..f983186 100644
--- a/cvss/cvss4.py
+++ b/cvss/cvss4.py
@@ -50,7 +50,12 @@ from .constants4 import (
     METRICS_VALUE_NAMES,
     OrderedDict,
 )
-from .exceptions import CVSS4MalformedError, CVSS4MandatoryError
+from .exceptions import (
+    CVSS4MalformedError,
+    CVSS4MandatoryError,
+    CVSS4RHMalformedError,
+    CVSS4RHScoreDoesNotMatch,
+)
 
 
 def final_rounding(x):
@@ -91,6 +96,46 @@ class CVSS4(object):
         self.compute_base_score()
         self.compute_severity()
 
+    @classmethod
+    def from_rh_vector(cls, vector):
+        """
+        Creates a CVSS4 object from CVSS vector in Red Hat notation, e.g. containing base score.
+        Also checks if the score matches the vector.
+
+        Args:
+            vector (str): string specifying CVSS4 vector in Red Hat notation, fields may be out of
+                          order, fields which are not mandatory may be missing
+
+        Returns:
+            CVSS4: the generated CVSS4 object created from the vector string
+
+        Raises:
+            CVSS4RHMalformedError: if vector is not in expected format for Red Hat notation
+            CVSS4RHScoreDoesNotMatch: if vector and score do not match
+        """
+        try:
+            score, base_vector = vector.split("/", 1)
+        except ValueError:
+            raise CVSS4RHMalformedError(
+                'Malformed CVSS4 vector in Red Hat notation "{0}"'.format(vector)
+            )
+        try:
+            score_value = float(score)
+        except ValueError:
+            raise CVSS4RHMalformedError(
+                'Malformed CVSS4 vector in Red Hat notation "{0}"'.format(vector)
+            )
+        cvss_object = cls(base_vector)
+        if cvss_object.scores()[0] == score_value:
+            return cvss_object
+        else:
+            raise CVSS4RHScoreDoesNotMatch(
+                'CVSS4 vector in Red Hat notation "{0}" has score of '
+                '"{1}" which does not match specified score of "{2}"'.format(
+                    base_vector, cvss_object.scores()[0], score
+                )
+            )
+
     def check_mandatory(self):
         """
         Checks if mandatory fields are in CVSS4 vector.
diff --git a/cvss/exceptions.py b/cvss/exceptions.py
index 30e9340..fc00235 100644
--- a/cvss/exceptions.py
+++ b/cvss/exceptions.py
@@ -119,3 +119,20 @@ class CVSS4MandatoryError(CVSS4Error):
     """
 
     pass
+
+
+class CVSS4RHScoreDoesNotMatch(CVSS4Error):
+    """
+    Exception when parsing CVSS4 vectors in Red Hat notation, which have score not matching the
+    computed score.
+    """
+
+    pass
+
+
+class CVSS4RHMalformedError(CVSS4Error):
+    """
+    Exception for malformed input CVSS4 vectors in Red Hat notation.
+    """
+
+    pass


Test patch:
diff --git a/tests/test_cvss4.py b/tests/test_cvss4.py
index 31e221b..1f763e7 100644
--- a/tests/test_cvss4.py
+++ b/tests/test_cvss4.py
@@ -1,3 +1,4 @@
+import ast
 import sys
 import unittest
 from os import path
@@ -5,7 +6,11 @@ from os import path
 sys.path.insert(0, path.dirname(path.dirname(path.abspath(__file__))))
 
 from cvss.cvss4 import CVSS4
-from cvss.exceptions import CVSS4MalformedError
+from cvss.exceptions import (
+    CVSS4MalformedError,
+    CVSS4RHMalformedError,
+    CVSS4RHScoreDoesNotMatch,
+)
 
 WD = path.dirname(path.abspath(sys.argv[0]))  # Manage to run script anywhere in the path
 
@@ -25,6 +30,16 @@ class TestCVSS4(unittest.TestCase):
                     expected_score, results_json_score, test_name + " - " + vector + " - JSON"
                 )
 
+    def run_rh_tests_from_file(self, test_name):
+        with open(path.join(WD, test_name)) as f:
+            for line in f:
+                vector, expected_scores = line.strip().split(" - ")
+                expected_scores = ast.literal_eval(expected_scores)
+                tested_rh_vector = str(expected_scores[0]) + "/" + vector
+                result = CVSS4.from_rh_vector(tested_rh_vector)
+                results_scores = result.scores()
+                self.assertEqual(expected_scores, results_scores, test_name + " - " + vector)
+
     def test_base(self):
         """
         All vector combinations with only mandatory fields, 104,976 bn vectors.
@@ -209,6 +224,30 @@ class TestCVSS4(unittest.TestCase):
             error = str(e)
         self.assertEqual(error, 'Duplicate metric "SI"')
 
+    def test_rh_vector(self):
+        """
+        Test for parsing Red Hat style of CVSS vectors, e.g. containing score.
+        """
+        self.run_rh_tests_from_file("vectors_simple4")
+
+        # Bad values
+        v = "8.3/CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:N/SC:L/SI:L/SA:N"
+        self.assertRaises(CVSS4RHScoreDoesNotMatch, CVSS4.from_rh_vector, v)
+
+        v = "7.0/CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:N/VA:N/SC:N/SI:L/SA:N"
+        self.assertRaises(CVSS4RHScoreDoesNotMatch, CVSS4.from_rh_vector, v)
+
+        # Vector cannot be split to score/vector
+        v = ""
+        self.assertRaises(CVSS4RHMalformedError, CVSS4.from_rh_vector, v)
+
+        v = "8.3|AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:L/VA:N/SC:H/SI:N/SA:L"
+        self.assertRaises(CVSS4RHMalformedError, CVSS4.from_rh_vector, v)
+
+        # Score is not float
+        v = "ABC|AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:L/VA:N/SC:H/SI:N/SA:L"
+        self.assertRaises(CVSS4RHMalformedError, CVSS4.from_rh_vector, v)
+
 
 if __name__ == "__main__":
     unittest.main()