Coverage for /home/benjarobin/Bootlin/projects/Schneider-Electric-Senux/sbom-cve-check/src/sbom_cve_check/vuln/cpe.py: 100%
89 statements
« prev ^ index » next coverage.py v7.11.1, created at 2025-11-28 15:37 +0100
« prev ^ index » next coverage.py v7.11.1, created at 2025-11-28 15:37 +0100
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: GPL-2.0-only
4import dataclasses
5import logging
6from enum import Enum
7from typing import Any, Final, Literal, Optional
9from .version import VersionRange, VersionRangeBuilder
11_CPE23_ESCAPED_CHARS: Final = frozenset(
12 [
13 "\\", "!", '"', "#", "$", "%", "&", "'", "(", ")", "+", ",", "/", ":", ";",
14 "<", "=", ">", "@", "[", "]", "^", "`", "{", "|", "}", "~", "?", "*",
15 ]
16) # fmt: skip
18_CPE23_ENCODE_TRANS_TABLE: Final = str.maketrans(
19 {c: f"\\{c}" for c in _CPE23_ESCAPED_CHARS}
20)
22_CPE23_FIELD_NAMES: Final = [
23 "part",
24 "vendor",
25 "product",
26 "version",
27 "update",
28 "edition",
29 "language",
30 "sw_edition",
31 "target_sw",
32 "target_hw",
33 "other",
34]
37_logger = logging.getLogger(__name__)
40class LogicalValue(Enum):
41 ANY = "*"
42 NA = "-"
45@dataclasses.dataclass(frozen=True)
46class Cpe23:
47 part: Literal["a", "o", "h"] | LogicalValue
48 vendor: str | LogicalValue
49 product: str | LogicalValue
50 version: str | LogicalValue
51 update: str | LogicalValue
52 edition: str | LogicalValue
53 language: str | LogicalValue
54 sw_edition: str | LogicalValue
55 target_sw: str | LogicalValue
56 target_hw: str | LogicalValue
57 other: str | LogicalValue
59 def __str__(self) -> str:
60 values = []
61 for n in _CPE23_FIELD_NAMES:
62 v = getattr(self, n)
63 if isinstance(v, str):
64 values.append(v.translate(_CPE23_ENCODE_TRANS_TABLE))
65 else:
66 values.append(v.value)
67 return "cpe:2.3:" + ":".join(values)
69 def __repr__(self) -> str:
70 return f'"{self!s}"'
72 @staticmethod
73 def parse(cpe_str: str | None) -> Optional["Cpe23"]:
74 if (cpe_str is None) or (not cpe_str.startswith("cpe:2.3:")):
75 return None
77 prev_backslash = False
78 values = []
79 value = ""
80 for c in cpe_str[8:]:
81 if prev_backslash:
82 if c in _CPE23_ESCAPED_CHARS:
83 value += c
84 prev_backslash = False
85 else:
86 _logger.debug("Unexpected escaped char %s in CPE: %s", c, cpe_str)
87 return None
89 elif c == "\\":
90 prev_backslash = True
92 elif c == ":":
93 values.append(value)
94 value = ""
96 else:
97 value += c
99 values.append(value)
100 if len(values) != len(_CPE23_FIELD_NAMES):
101 _logger.debug(
102 "Unexpected number of attributes in CPE 2.3 string: %s", cpe_str
103 )
104 return None
106 cpe_key_vals = {}
108 for field_name, value in zip(_CPE23_FIELD_NAMES, values, strict=False):
109 val: str | LogicalValue = value
110 if value == "-":
111 val = LogicalValue.NA
112 elif value == "*":
113 val = LogicalValue.ANY
115 cpe_key_vals[field_name] = val
117 return Cpe23(**cpe_key_vals) # type: ignore[arg-type]
120def parse_cpe_match(
121 json_obj: dict[str, Any], cna_org: str | None
122) -> tuple[Cpe23 | None, VersionRange | None]:
123 vers_range_builder = VersionRangeBuilder(
124 vulnerable=json_obj.get("vulnerable", True), from_cpe=True, from_cna=cna_org
125 )
126 cpe = Cpe23.parse(json_obj.get("criteria"))
127 if cpe is not None:
128 if cpe.product == LogicalValue.NA:
129 # ignore when product is '-', which means N/A
130 return None, None
132 if isinstance(cpe.version, str):
133 # Version is defined, this is a '=' match. If the CPE update field is
134 # not '*', append it to the version
135 if isinstance(cpe.update, str):
136 vers_range_builder.set_start_version(
137 f"{cpe.version}-{cpe.update}", equal=True
138 )
139 else:
140 vers_range_builder.set_start_version(cpe.version, equal=True)
142 # Parse start version, end version and operators
143 if "versionStartIncluding" in json_obj:
144 vers_range_builder.set_start_version(
145 json_obj["versionStartIncluding"], including=True
146 )
148 if "versionStartExcluding" in json_obj:
149 vers_range_builder.set_start_version(
150 json_obj["versionStartExcluding"], including=False
151 )
153 if "versionEndIncluding" in json_obj:
154 vers_range_builder.set_end_version(
155 json_obj["versionEndIncluding"], including=True
156 )
158 if "versionEndExcluding" in json_obj:
159 vers_range_builder.set_end_version(
160 json_obj["versionEndExcluding"], including=False
161 )
163 if vers_range_builder.is_valid():
164 return cpe, vers_range_builder.sem_ver_range
166 return cpe, None