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

1# -*- coding: utf-8 -*- 

2# SPDX-License-Identifier: GPL-2.0-only 

3 

4import dataclasses 

5import logging 

6from enum import Enum 

7from typing import Any, Final, Literal, Optional 

8 

9from .version import VersionRange, VersionRangeBuilder 

10 

11_CPE23_ESCAPED_CHARS: Final = frozenset( 

12 [ 

13 "\\", "!", '"', "#", "$", "%", "&", "'", "(", ")", "+", ",", "/", ":", ";", 

14 "<", "=", ">", "@", "[", "]", "^", "`", "{", "|", "}", "~", "?", "*", 

15 ] 

16) # fmt: skip 

17 

18_CPE23_ENCODE_TRANS_TABLE: Final = str.maketrans( 

19 {c: f"\\{c}" for c in _CPE23_ESCAPED_CHARS} 

20) 

21 

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] 

35 

36 

37_logger = logging.getLogger(__name__) 

38 

39 

40class LogicalValue(Enum): 

41 ANY = "*" 

42 NA = "-" 

43 

44 

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 

58 

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) 

68 

69 def __repr__(self) -> str: 

70 return f'"{self!s}"' 

71 

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 

76 

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 

88 

89 elif c == "\\": 

90 prev_backslash = True 

91 

92 elif c == ":": 

93 values.append(value) 

94 value = "" 

95 

96 else: 

97 value += c 

98 

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 

105 

106 cpe_key_vals = {} 

107 

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 

114 

115 cpe_key_vals[field_name] = val 

116 

117 return Cpe23(**cpe_key_vals) # type: ignore[arg-type] 

118 

119 

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 

131 

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) 

141 

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 ) 

147 

148 if "versionStartExcluding" in json_obj: 

149 vers_range_builder.set_start_version( 

150 json_obj["versionStartExcluding"], including=False 

151 ) 

152 

153 if "versionEndIncluding" in json_obj: 

154 vers_range_builder.set_end_version( 

155 json_obj["versionEndIncluding"], including=True 

156 ) 

157 

158 if "versionEndExcluding" in json_obj: 

159 vers_range_builder.set_end_version( 

160 json_obj["versionEndExcluding"], including=False 

161 ) 

162 

163 if vers_range_builder.is_valid(): 

164 return cpe, vers_range_builder.sem_ver_range 

165 

166 return cpe, None