1414from dateutil .parser import parse
1515from packageurl import PackageURL
1616from pytz import UTC
17+ from univers .version_range import AlpineLinuxVersionRange
18+ from univers .version_range import DebianVersionRange
1719from univers .version_range import GenericVersionRange
20+ from univers .version_range import RpmVersionRange
1821
1922from vulnerabilities .importer import AdvisoryData
2023from vulnerabilities .importer import AffectedPackageV2
2528
2629logger = logging .getLogger (__name__ )
2730
31+ # See https://docs.tuxcare.com/els-for-os/#cve-status-definition
32+ NON_AFFECTED_STATUSES = ["Not Vulnerable" ]
33+ AFFECTED_STATUSES = ["Ignored" , "Needs Triage" , "In Testing" , "In Progress" , "In Rollout" ]
34+ FIXED_STATUSES = ["Released" , "Already Fixed" ]
35+
36+ VERSION_RANGE_BY_PURL_TYPE = {
37+ "rpm" : RpmVersionRange ,
38+ "deb" : DebianVersionRange ,
39+ "apk" : AlpineLinuxVersionRange ,
40+ "generic" : GenericVersionRange ,
41+ }
42+
2843
2944class TuxCareImporterPipeline (VulnerableCodeBaseImporterPipelineV2 ):
3045 pipeline_id = "tuxcare_importer_v2"
@@ -43,9 +58,54 @@ def fetch(self) -> None:
4358 self .log (f"Fetching `{ url } `" )
4459 response = fetch_response (url )
4560 self .response = response .json () if response else []
61+ self ._grouped = self ._group_records_by_cve ()
62+
63+ def _group_records_by_cve (self ) -> dict :
64+ grouped = {}
65+ skipped_invalid = 0
66+ skipped_non_affected = 0
67+
68+ for record in self .response :
69+ cve_id = record .get ("cve" , "" ).strip ()
70+ if not cve_id or not cve_id .startswith ("CVE-" ):
71+ logger .warning (f"Skipping invalid CVE ID: { cve_id } " )
72+ skipped_invalid += 1
73+ continue
74+
75+ os_name = record .get ("os_name" , "" ).strip ()
76+ project_name = record .get ("project_name" , "" ).strip ()
77+ version = record .get ("version" , "" ).strip ()
78+ status = record .get ("status" , "" ).strip ()
79+
80+ if not all ([os_name , project_name , version , status ]):
81+ logger .warning (f"Skipping { cve_id } : missing required fields" )
82+ skipped_invalid += 1
83+ continue
84+
85+ # Skip records with non-affected statuses
86+ if status in NON_AFFECTED_STATUSES :
87+ skipped_non_affected += 1
88+ continue
89+
90+ if status not in AFFECTED_STATUSES and status not in FIXED_STATUSES :
91+ logger .warning (f"Skipping { cve_id } : unrecognized status '{ status } '" )
92+ skipped_invalid += 1
93+ continue
94+
95+ if cve_id not in grouped :
96+ grouped [cve_id ] = []
97+ grouped [cve_id ].append (record )
98+
99+ total_skipped = skipped_invalid + skipped_non_affected
100+ self .log (
101+ f"Grouped { len (self .response ):,d} records into { len (grouped ):,d} unique CVEs "
102+ f"(skipped { total_skipped :,d} : { skipped_invalid :,d} invalid, "
103+ f"{ skipped_non_affected :,d} non-affected)"
104+ )
105+ return grouped
46106
47107 def advisories_count (self ) -> int :
48- return len (self .response )
108+ return len (self ._grouped )
49109
50110 def _create_purl (self , project_name : str , os_name : str ) -> PackageURL :
51111 normalized_os = os_name .lower ().replace (" " , "-" )
@@ -64,9 +124,6 @@ def _create_purl(self, project_name: str, os_name: str) -> PackageURL:
64124 "tuxcare" : ("generic" , "tuxcare" ),
65125 }
66126
67- pkg_type = "generic"
68- namespace = "tuxcare"
69-
70127 for keyword , (ptype , pns ) in os_mapping .items ():
71128 if keyword in os_lower :
72129 pkg_type = ptype
@@ -75,105 +132,90 @@ def _create_purl(self, project_name: str, os_name: str) -> PackageURL:
75132 else :
76133 return None
77134
78- qualifiers = {}
79- if normalized_os :
80- qualifiers ["distro" ] = normalized_os
135+ qualifiers = {"distro" : normalized_os }
81136
82137 return PackageURL (
83138 type = pkg_type , namespace = namespace , name = project_name , qualifiers = qualifiers
84139 )
85140
86141 def collect_advisories (self ) -> Iterable [AdvisoryData ]:
87- for record in self .response :
88- cve_id = record .get ("cve" , "" ).strip ()
89- if not cve_id or not cve_id .startswith ("CVE-" ):
90- logger .warning (f"Skipping record with invalid CVE ID: { cve_id } " )
91- continue
92-
93- os_name = record .get ("os_name" , "" ).strip ()
94- project_name = record .get ("project_name" , "" ).strip ()
95- version = record .get ("version" , "" ).strip ()
96- score = record .get ("score" , "" ).strip ()
97- severity = record .get ("severity" , "" ).strip ()
98- status = record .get ("status" , "" ).strip ()
99- last_updated = record .get ("last_updated" , "" ).strip ()
142+ grouped_by_cve = self ._grouped
100143
101- if not all ([os_name , project_name , version , status ]):
102- logger .warning (f"Skipping { cve_id } - missing required fields" )
103- continue
144+ for cve_id , records in grouped_by_cve .items ():
145+ affected_packages = []
146+ severities = []
147+ date_published = None
148+ all_records = []
149+ severity_added = False
150+
151+ for record in records :
152+ os_name = record .get ("os_name" , "" ).strip ()
153+ project_name = record .get ("project_name" , "" ).strip ()
154+ version = record .get ("version" , "" ).strip ()
155+ score = record .get ("score" , "" ).strip ()
156+ severity = record .get ("severity" , "" ).strip ()
157+ status = record .get ("status" , "" ).strip ()
158+ last_updated = record .get ("last_updated" , "" ).strip ()
159+
160+ purl = self ._create_purl (project_name , os_name )
161+ if not purl :
162+ logger .warning (
163+ f"Skipping package { project_name } on { os_name } for { cve_id } - unexpected OS type"
164+ )
165+ continue
104166
105- # See https://docs.tuxcare.com/els-for-os/#cve-status-definition
106- non_affected_statuses = ["Not Vulnerable" ]
107- affected_statuses = [
108- "Ignored" ,
109- "Needs Triage" ,
110- "In Testing" ,
111- "In Progress" ,
112- "In Rollout" ,
113- ]
114- fixed_statuses = ["Released" , "Already Fixed" ]
115-
116- # Skip CVEs that are not vulnerable
117- if status in non_affected_statuses :
118- continue
167+ version_range_class = VERSION_RANGE_BY_PURL_TYPE .get (purl .type , GenericVersionRange )
168+ try :
169+ version_range = version_range_class .from_versions ([version ])
170+ except ValueError as e :
171+ logger .warning (f"Failed to parse version { version } for { cve_id } : { e } " )
172+ continue
173+
174+ affected_version_range = None
175+ fixed_version_range = None
176+
177+ if status in AFFECTED_STATUSES :
178+ affected_version_range = version_range
179+ elif status in FIXED_STATUSES :
180+ fixed_version_range = version_range
181+
182+ affected_packages .append (
183+ AffectedPackageV2 (
184+ package = purl ,
185+ affected_version_range = affected_version_range ,
186+ fixed_version_range = fixed_version_range ,
187+ )
188+ )
119189
120- if status not in affected_statuses and status not in fixed_statuses :
121- logger .warning (f"Skipping { cve_id } - unknown status: { status } " )
122- continue
190+ if severity and score and not severity_added :
191+ severities .append (
192+ VulnerabilitySeverity (
193+ system = GENERIC ,
194+ value = score ,
195+ scoring_elements = severity ,
196+ )
197+ )
198+ severity_added = True
123199
124- normalized_os = os_name .lower ().replace (" " , "-" )
125- advisory_id = f"{ cve_id } -{ normalized_os } -{ project_name .lower ()} -{ version } "
200+ if last_updated :
201+ try :
202+ current_date = parse (last_updated ).replace (tzinfo = UTC )
203+ if date_published is None or current_date > date_published :
204+ date_published = current_date
205+ except ValueError as e :
206+ logger .warning (f"Failed to parse date { last_updated } for { cve_id } : { e } " )
126207
127- purl = self ._create_purl (project_name , os_name )
128- if not purl :
129- logger .warning (f"Skipping { cve_id } - unexpected OS type: '{ os_name } '" )
130- continue
208+ all_records .append (record )
131209
132- try :
133- version_range = GenericVersionRange .from_versions ([version ])
134- except ValueError as e :
135- logger .warning (f"Failed to parse version { version } for { cve_id } : { e } " )
210+ if not affected_packages :
211+ logger .warning (f"Skipping { cve_id } - no valid affected packages" )
136212 continue
137213
138- affected_version_range = None
139- fixed_version_range = None
140-
141- if status in affected_statuses :
142- affected_version_range = version_range
143- elif status in fixed_statuses :
144- fixed_version_range = version_range
145-
146- affected_packages = [
147- AffectedPackageV2 (
148- package = purl ,
149- affected_version_range = affected_version_range ,
150- fixed_version_range = fixed_version_range ,
151- )
152- ]
153-
154- severities = []
155- if severity and score :
156- severities .append (
157- VulnerabilitySeverity (
158- system = GENERIC ,
159- value = score ,
160- scoring_elements = severity ,
161- )
162- )
163-
164- date_published = None
165- if last_updated :
166- try :
167- date_published = parse (last_updated ).replace (tzinfo = UTC )
168- except ValueError as e :
169- logger .warning (f"Failed to parse date { last_updated } for { cve_id } : { e } " )
170-
171214 yield AdvisoryData (
172- advisory_id = advisory_id ,
173- aliases = [cve_id ],
215+ advisory_id = cve_id ,
174216 affected_packages = affected_packages ,
175217 severities = severities ,
176218 date_published = date_published ,
177219 url = f"https://cve.tuxcare.com/els/cve/{ cve_id } " ,
178- original_advisory_text = json .dumps (record , indent = 2 , ensure_ascii = False ),
220+ original_advisory_text = json .dumps (all_records , indent = 2 , ensure_ascii = False ),
179221 )
0 commit comments