Coverage for /home/benjarobin/Bootlin/projects/Schneider-Electric-Senux/sbom-cve-check/src/sbom_cve_check/vuln/version.py: 93%

207 statements  

« 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 

3 

4import abc 

5import dataclasses 

6import functools 

7import itertools 

8import operator 

9import re 

10from collections.abc import Callable 

11from enum import Enum 

12from typing import Any, Final 

13 

14_VERSION_PATTERN = r""" 

15 ^[rv]? 

16 (?:[0-9]:)? # epoch 

17 (?P<release> # release segment 

18 [0-9]+(?:\.(?:[0-9]+|\*))+ 

19 | 

20 [0-9]+(?:-[0-9]+)+ 

21 | 

22 (?:[0-9]+|\*) 

23 ) 

24 (?: # alphabetical segment 

25 [._-]?(?P<letter>[a-z]) 

26 (?:$|(?=[._-])) 

27 )? 

28 (?: # patch segment 

29 [._-]? 

30 (p|patch)_? 

31 (?P<patch_n>[0-9]+) 

32 )? 

33 (?: # pre-release 

34 [._-]? 

35 (?P<pre_l1>(alpha|beta|preview|pre|rc|dev)) 

36 [._-]? 

37 (?P<pre_n1>[0-9]+)? 

38 | 

39 [._-]? 

40 (?P<pre_l2>(a|b|c)) 

41 (?P<pre_n2>[0-9]+) 

42 )? 

43 (?: # post release 

44 (?:-(?P<post_n1>[0-9]+)) 

45 | 

46 (?: 

47 [._-]? 

48 (?:post|rev|r) 

49 [._-]? 

50 (?P<post_n2>[0-9]+)? 

51 ) 

52 )? 

53""" 

54 

55_version_regex = re.compile(_VERSION_PATTERN, re.VERBOSE | re.IGNORECASE) 

56 

57 

58@functools.total_ordering 

59class InfinityType: 

60 def __repr__(self) -> str: 

61 return "Infinity" 

62 

63 def __hash__(self) -> int: 

64 return hash(repr(self)) 

65 

66 def __eq__(self, other: object) -> bool: 

67 return isinstance(other, InfinityType) 

68 

69 def __gt__(self, other: object) -> bool: 

70 return not isinstance(other, InfinityType) 

71 

72 

73INFINITY: Final = InfinityType() 

74 

75 

76@functools.total_ordering 

77class NegInfinityType: 

78 def __repr__(self) -> str: 

79 return "-Infinity" 

80 

81 def __hash__(self) -> int: 

82 return hash(repr(self)) 

83 

84 def __eq__(self, other: object) -> bool: 

85 return isinstance(other, NegInfinityType) 

86 

87 def __lt__(self, other: object) -> bool: 

88 return not isinstance(other, NegInfinityType) 

89 

90 

91NEG_INFINITY: Final = NegInfinityType() 

92 

93 

94def _parse_pre_release( 

95 letter: str | None, number: str | None 

96) -> tuple[str, int] | None: 

97 if letter is None: 

98 return None 

99 

100 # We consider there to be an implicit 0 in a pre-release if there is not a numeral 

101 # associated with it. 

102 num_val = 0 if number is None else int(number) 

103 

104 # We normalize any letters to their lower case form 

105 letter = letter.lower() 

106 

107 # We consider some words to be alternate spellings of other words and in those 

108 # cases we want to normalize the spellings to our preferred spelling. 

109 if letter == "alpha": 

110 letter = "a" 

111 elif letter == "beta": 

112 letter = "b" 

113 elif letter in ["c", "pre", "preview"]: 

114 letter = "rc" 

115 

116 return letter, num_val 

117 

118 

119def _parse_vers_number(v: str | None) -> int | None: 

120 if v is not None: 

121 return int(v) 

122 return None 

123 

124 

125def _create_compare_keys(version: str) -> tuple[Any, ...]: 

126 match = _version_regex.search(version) 

127 if not match: 

128 return () 

129 

130 rel_match = match.group("release") 

131 # If the version starts with a 'r', consider this is a simple CVS revision, 

132 # so create a fake version: 0.0.0.r 

133 if (version[0:1] == "r") and ("." not in rel_match): 

134 rel_match = f"0.0.0.{rel_match}" 

135 release = rel_match.replace("-", ".").split(".") 

136 letter = match.group("letter") 

137 patch = _parse_vers_number(match.group("patch_n")) 

138 pre = _parse_pre_release( 

139 match.group("pre_l1"), match.group("pre_n1") 

140 ) or _parse_pre_release(match.group("pre_l2"), match.group("pre_n2")) 

141 post = _parse_vers_number(match.group("post_n1") or match.group("post_n2")) 

142 

143 # When we compare a release version, we want to compare it with all the trailing 

144 # zeros removed. So we'll reverse the list, drop all the now leading zeros until we 

145 # come to something non-zero, then take the rest re-reverse it back into the 

146 # correct order. 

147 release_p = [INFINITY if v == "*" else int(v) for v in release] 

148 _release = tuple( 

149 reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release_p)))) 

150 ) 

151 

152 # Versions without a letter (after release part) should sort before those with one. 

153 _letter = NEG_INFINITY if letter is None else letter 

154 

155 # Versions without a patch segment should sort before those with one. 

156 _patch = NEG_INFINITY if patch is None else patch 

157 

158 # Versions without a pre-release should sort after those with one. 

159 _pre = INFINITY if pre is None else pre 

160 

161 # Versions without a post segment should sort before those with one. 

162 _post = NEG_INFINITY if post is None else post 

163 

164 return _release, _letter, _patch, _pre, _post 

165 

166 

167@functools.total_ordering 

168class Version: 

169 def __init__(self, v: str) -> None: 

170 self._version = v.strip() 

171 self._cmp_keys = _create_compare_keys(self._version) 

172 

173 def __repr__(self) -> str: 

174 return self._version 

175 

176 @property 

177 def original(self) -> str: 

178 return self._version 

179 

180 def is_valid(self) -> bool: 

181 return bool(self._cmp_keys) 

182 

183 def __hash__(self) -> int: 

184 return hash(self._cmp_keys) 

185 

186 def __eq__(self, other: object) -> bool: 

187 if not isinstance(other, Version): 

188 return NotImplemented 

189 return self._cmp_keys == other._cmp_keys 

190 

191 def __gt__(self, other: object) -> bool: 

192 if not isinstance(other, Version): 

193 return NotImplemented 

194 return self._cmp_keys > other._cmp_keys 

195 

196 

197_OPERATOR_TO_STR: Final[dict[Callable[[Version, Version], bool] | None, str]] = { 

198 operator.lt: "<", 

199 operator.le: "<=", 

200 operator.eq: "=", 

201 operator.ge: ">=", 

202 operator.gt: ">", 

203 None: "", 

204} 

205 

206 

207class VersRangeStatus(Enum): 

208 NO_INFO = None 

209 SMALLER = -1 

210 IN_RANGE = 0 

211 GREATER = 1 

212 

213 

214@dataclasses.dataclass(frozen=True, kw_only=True) 

215class VersionRange(abc.ABC): 

216 vulnerable: bool | None 

217 from_cpe: bool | None = None 

218 from_cna: str | None = None # CNA organization identifier 

219 

220 def is_not_vuln_trusted(self) -> bool: 

221 """ 

222 :return: True if we trust version range that are declared not vulnerable. 

223 By default, do not trust it. 

224 """ 

225 # Special case for kernel.org CNA 

226 return self.from_cna == "416baaa9-dc9f-4396-8d5f-8c081fb06d67" 

227 

228 

229@dataclasses.dataclass(frozen=True, kw_only=True) 

230class _CmpVersion: 

231 vers: Version 

232 op: Callable[[Version, Version], bool] 

233 

234 def compare(self, pkg_version: Version) -> bool: 

235 return self.op(pkg_version, self.vers) 

236 

237 

238@dataclasses.dataclass(frozen=True, kw_only=True) 

239class SemVerRange(VersionRange): 

240 v_start: str | None 

241 op_start: Callable[[Version, Version], bool] | None 

242 v_end: str | None 

243 op_end: Callable[[Version, Version], bool] | None 

244 

245 def __str__(self) -> str: 

246 s = f"[{_OPERATOR_TO_STR[self.op_start]} {self.v_start}" 

247 if self.v_end: 

248 s = f"{s}, {_OPERATOR_TO_STR[self.op_end]} {self.v_end}" 

249 return f"{s}]: vuln={self.vulnerable}" 

250 

251 @staticmethod 

252 def build_single_vers( 

253 vulnerable: bool, v_start: str, from_cpe: bool 

254 ) -> "SemVerRange": 

255 return SemVerRange( 

256 vulnerable=vulnerable, 

257 from_cpe=from_cpe, 

258 v_start=v_start, 

259 op_start=operator.eq, 

260 v_end=None, 

261 op_end=None, 

262 ) 

263 

264 def check_strictly_equal(self, pkg_version: str) -> bool: 

265 """ 

266 For annotation, check that the specified version is strictly equal to the start 

267 version. If this range does not specify a unique version, always return false 

268 """ 

269 return (pkg_version == self.v_start) and (self.op_start == operator.eq) 

270 

271 def check_in_range(self, pkg_version: Version) -> VersRangeStatus: 

272 """ 

273 Check if a component version is within specified range. If this is not the case, 

274 indicate if the component version is smaller or greater than the specified 

275 version range. 

276 """ 

277 if not pkg_version.is_valid(): 

278 return VersRangeStatus.NO_INFO 

279 

280 cmp_start: _CmpVersion | None = None 

281 if (self.v_start is not None) and (self.op_start is not None): 

282 v = Version(self.v_start) 

283 if v.is_valid(): 

284 cmp_start = _CmpVersion(vers=v, op=self.op_start) 

285 

286 cmp_end: _CmpVersion | None = None 

287 if (self.v_end is not None) and (self.op_end is not None): 

288 v = Version(self.v_end) 

289 if v.is_valid(): 

290 cmp_end = _CmpVersion(vers=v, op=self.op_end) 

291 

292 if (cmp_start is not None) and (cmp_end is not None): 

293 r_start = cmp_start.compare(pkg_version) 

294 r_end = cmp_end.compare(pkg_version) 

295 if r_start and r_end: 

296 return VersRangeStatus.IN_RANGE 

297 if not r_start: 

298 return VersRangeStatus.SMALLER 

299 return VersRangeStatus.GREATER 

300 

301 if cmp_start is not None: 

302 r_start = cmp_start.compare(pkg_version) 

303 if self.op_start == operator.eq: 

304 if r_start: 

305 return VersRangeStatus.IN_RANGE 

306 if pkg_version < cmp_start.vers: 

307 return VersRangeStatus.SMALLER 

308 return VersRangeStatus.GREATER 

309 if r_start: 

310 return VersRangeStatus.IN_RANGE 

311 return VersRangeStatus.SMALLER 

312 

313 if cmp_end is not None: 

314 r_end = cmp_end.compare(pkg_version) 

315 if r_end: 

316 return VersRangeStatus.IN_RANGE 

317 return VersRangeStatus.GREATER 

318 

319 return VersRangeStatus.NO_INFO 

320 

321 

322@dataclasses.dataclass(frozen=True, kw_only=True) 

323class GitVerRange(VersionRange): 

324 # This git commit is that start version (included) 

325 v_start: str 

326 # This git commit is that end version (that may be included in the range) 

327 v_end: str | None = None 

328 end_included: bool = False 

329 

330 

331class VersionRangeBuilder: 

332 def __init__( 

333 self, 

334 *, 

335 vulnerable: bool | None, 

336 from_cpe: bool | None = None, 

337 from_cna: str | None = None, 

338 ) -> None: 

339 self._vulnerable: bool | None = vulnerable 

340 self._from_cpe: bool | None = from_cpe 

341 self._from_cna: str | None = from_cna 

342 self._v_start: str | None = None 

343 self._start_including: bool | None = None 

344 self._start_equal: bool | None = None 

345 self._v_end: str | None = None 

346 self._end_including: bool | None = None 

347 

348 def set_start_version( 

349 self, version: str, *, including: bool = False, equal: bool = False 

350 ) -> None: 

351 self._v_start = version 

352 self._start_including = including 

353 self._start_equal = equal 

354 

355 def set_end_version(self, version: str, *, including: bool = False) -> None: 

356 self._v_end = None if version == "*" else version 

357 self._end_including = including 

358 

359 def is_valid(self) -> bool: 

360 return (self._v_start is not None) or (self._v_end is not None) 

361 

362 @property 

363 def sem_ver_range(self) -> SemVerRange: 

364 op_start: Callable[[Version, Version], bool] | None = None 

365 if self._v_start is not None: 

366 if self._start_equal: 

367 op_start = operator.eq 

368 elif self._start_including: 

369 op_start = operator.ge 

370 else: 

371 op_start = operator.gt 

372 

373 op_end = None 

374 if self._v_end is not None: 

375 op_end = operator.le if self._end_including else operator.lt 

376 

377 return SemVerRange( 

378 vulnerable=self._vulnerable, 

379 from_cpe=self._from_cpe, 

380 from_cna=self._from_cna, 

381 v_start=self._v_start, 

382 op_start=op_start, 

383 v_end=self._v_end, 

384 op_end=op_end, 

385 ) 

386 

387 @property 

388 def git_ver_range(self) -> GitVerRange: 

389 if self._start_equal or not self._start_including: 

390 raise ValueError( 

391 'Start version for a git version range is always "including"' 

392 ) 

393 

394 if self._v_start is None: 

395 raise ValueError("Missing start version") 

396 

397 return GitVerRange( 

398 vulnerable=self._vulnerable, 

399 from_cpe=self._from_cpe, 

400 from_cna=self._from_cna, 

401 v_start=self._v_start, 

402 v_end=self._v_end, 

403 end_included=bool(self._end_including), 

404 )