diff --git a/metadata/fields/custom/cpe_prefix.py b/metadata/fields/custom/cpe_prefix.py index 35d8c6669..33c270587 100644 --- a/metadata/fields/custom/cpe_prefix.py +++ b/metadata/fields/custom/cpe_prefix.py @@ -19,7 +19,46 @@ import metadata.fields.field_types as field_types import metadata.fields.util as util import metadata.validation_result as vr -_PATTERN_CPE_PREFIX = re.compile(r"^cpe:(2.3:|/).+:.+:.+(:.+)*$", re.IGNORECASE) +# Pattern for CPE 2.3 URI binding (also compatible with CPE 2.2). +_PATTERN_CPE_URI = re.compile( + r"^c[pP][eE]:/[AHOaho]?(:[A-Za-z0-9._\-~%]*){0,6}$") + +# Pattern that will match CPE 2.3 formatted string binding. +_PATTERN_CPE_FORMATTED_STRING = re.compile(r"^cpe:2\.3:[aho\-\*](:[^:]+){10}$") + + +def is_uri_cpe(value: str) -> bool: + """Returns whether the value conforms to the CPE 2.3 URI binding (which is + compatible with CPE 2.2), with the additional constraint that at least one + component other than "part" has been specified. + + For reference, see section 6.1 of the CPE Naming Specification Version 2.3 at + https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7695.pdf + """ + if not util.matches(_PATTERN_CPE_URI, value): + return False + + components = value.split(":") + if len(components) < 3: + # At most, only part was provided. + return False + + # Check at least one component other than "part" has been specified. + for component in components[2:]: + if component: + return True + + return False + + +def is_formatted_string_cpe(value: str) -> bool: + """Returns whether the value conforms to the CPE 2.3 formatted string + binding. + + For reference, see section 6.2 of the CPE Naming Specification Version 2.3 at + https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7695.pdf + """ + return util.matches(_PATTERN_CPE_FORMATTED_STRING, value) class CPEPrefixField(field_types.MetadataField): @@ -31,7 +70,8 @@ class CPEPrefixField(field_types.MetadataField): """Checks the given value is either 'unknown', or conforms to either the CPE 2.3 or 2.2 format. """ - if util.is_unknown(value) or util.matches(_PATTERN_CPE_PREFIX, value): + if (util.is_unknown(value) or is_formatted_string_cpe(value) + or is_uri_cpe(value)): return None return vr.ValidationError( @@ -40,4 +80,5 @@ class CPEPrefixField(field_types.MetadataField): "This field should be a CPE (version 2.3 or 2.2), or 'unknown'.", "Search for a CPE tag for the package at " "https://nvd.nist.gov/products/cpe/search.", + f"Current value: '{value}'.", ]) diff --git a/metadata/tests/fields_test.py b/metadata/tests/fields_test.py index a1756990e..212f69a73 100644 --- a/metadata/tests/fields_test.py +++ b/metadata/tests/fields_test.py @@ -28,13 +28,13 @@ class FieldValidationTest(unittest.TestCase): warning_values: List[str] = []): """Helper to run a field's validation for different values.""" for value in valid_values: - self.assertIsNone(field.validate(value)) + self.assertIsNone(field.validate(value), value) for value in error_values: - self.assertIsInstance(field.validate(value), vr.ValidationError) + self.assertIsInstance(field.validate(value), vr.ValidationError, value) for value in warning_values: - self.assertIsInstance(field.validate(value), vr.ValidationWarning) + self.assertIsInstance(field.validate(value), vr.ValidationWarning, value) def test_freeform_text_validation(self): # Check validation of a freeform text field that should be on one line. @@ -71,12 +71,21 @@ class FieldValidationTest(unittest.TestCase): field=known_fields.CPE_PREFIX, valid_values=[ "unknown", - "Cpe:2.3:a:sqlite:sqlite:3.0.0", - "cpe:2.3:a:sqlite:sqlite", - "CPE:/a:sqlite:sqlite:3.0.0", - "cpe:/a:sqlite:sqlite", + "cpe:2.3:a:sqlite:sqlite:3.0.0:*:*:*:*:*:*:*", + "cpe:2.3:a:sqlite:sqlite:*:*:*:*:*:*:*:*", + "cpe:/a:vendor:product:version:update:edition:lang", + "cpe:/a::product:", + "cpe:/:vendor::::edition", + "cpe:/:vendor", + ], + error_values=[ + "", + "\n", + "cpe:2.3:a:sqlite:sqlite:3.0.0", + "cpe:2.3:a:sqlite:sqlite::::::::", + "cpe:/", + "cpe:/a:vendor:product:version:update:edition:lang:", ], - error_values=["", "\n"], ) def test_date_validation(self):