Skip to content

Commit 7d47d46

Browse files
committed
Implement impact packages and specific version with univers
Signed-off-by: Sampurna Pyne <sampurnapyne1710@gmail.com>
1 parent c6a87b7 commit 7d47d46

File tree

4 files changed

+346
-162
lines changed

4 files changed

+346
-162
lines changed

vulnerabilities/pipelines/v2_importers/tuxcare_importer.py

Lines changed: 128 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
from dateutil.parser import parse
1515
from packageurl import PackageURL
1616
from pytz import UTC
17+
from univers.version_range import AlpineLinuxVersionRange
18+
from univers.version_range import DebianVersionRange
1719
from univers.version_range import GenericVersionRange
20+
from univers.version_range import RpmVersionRange
1821

1922
from vulnerabilities.importer import AdvisoryData
2023
from vulnerabilities.importer import AffectedPackageV2
@@ -25,6 +28,18 @@
2528

2629
logger = 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

2944
class 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
)

vulnerabilities/tests/pipelines/v2_importers/test_tuxcare_importer_v2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ def test_collect_advisories(self, mock_fetch):
3535
expected_file = TEST_DATA / "expected.json"
3636
util_tests.check_results_against_json(advisories, expected_file)
3737

38-
assert pipeline.advisories_count() == 13
38+
assert len(advisories) == 14

vulnerabilities/tests/test_data/tuxcare/data.json

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,46 @@
1919
"status": "In Testing",
2020
"last_updated": "2025-12-23 10:08:35.944749"
2121
},
22+
{
23+
"cve": "CVE-2023-52922",
24+
"os_name": "CentOS 8.5 ELS",
25+
"project_name": "kernel",
26+
"version": "4.18.0",
27+
"score": "7.8",
28+
"severity": "HIGH",
29+
"status": "Released",
30+
"last_updated": "2025-05-21 01:43:28.677045"
31+
},
32+
{
33+
"cve": "CVE-2023-52922",
34+
"os_name": "AlmaLinux 9.2 ESU",
35+
"project_name": "squid",
36+
"version": "5.5",
37+
"score": "7.8",
38+
"severity": "HIGH",
39+
"status": "Not Vulnerable",
40+
"last_updated": "2025-08-28 00:52:25.579518"
41+
},
42+
{
43+
"cve": "CVE-2023-52922",
44+
"os_name": "RHEL 7 ELS",
45+
"project_name": "squid",
46+
"version": "3.5.20",
47+
"score": "7.8",
48+
"severity": "HIGH",
49+
"status": "Not Vulnerable",
50+
"last_updated": "2025-11-27 10:32:13.088814"
51+
},
52+
{
53+
"cve": "CVE-2023-52922",
54+
"os_name": "CentOS Stream 8 ELS",
55+
"project_name": "squid",
56+
"version": "4.15",
57+
"score": "7.8",
58+
"severity": "HIGH",
59+
"status": "Ignored",
60+
"last_updated": "2025-08-28 22:56:12.357818"
61+
},
2262
{
2363
"cve": "CVE-2023-48161",
2464
"os_name": "RHEL 7 ELS",
@@ -128,5 +168,35 @@
128168
"severity": "MEDIUM",
129169
"status": "In Rollout",
130170
"last_updated": "2025-12-20 04:34:51.112485"
171+
},
172+
{
173+
"cve": "CVE-2022-50268",
174+
"os_name": "Ubuntu 16.04 ELS",
175+
"project_name": "linux",
176+
"version": "4.4.0",
177+
"score": "5.5",
178+
"severity": "MEDIUM",
179+
"status": "Needs Triage",
180+
"last_updated": "2025-12-23 07:31:24.920518"
181+
},
182+
{
183+
"cve": "CVE-2020-1472",
184+
"os_name": "Debian 10 ELS",
185+
"project_name": "samba",
186+
"version": "4.9.5",
187+
"score": "0.0",
188+
"severity": "",
189+
"status": "In Testing",
190+
"last_updated": "2025-12-22 16:58:13.189255"
191+
},
192+
{
193+
"cve": "CVE-2025-6297",
194+
"os_name": "Alpine Linux 3.18 ELS",
195+
"project_name": "dpkg",
196+
"version": "1.21.21",
197+
"score": "0.0",
198+
"severity": "",
199+
"status": "Released",
200+
"last_updated": "2025-12-19 05:00:40.874276"
131201
}
132202
]

0 commit comments

Comments
 (0)