Skip to content

Commit dd7c05d

Browse files
committed
Add OSSA Importer V2
Signed-off-by: Sampurna Pyne <sampurnapyne1710@gmail.com>
1 parent b5a4445 commit dd7c05d

File tree

13 files changed

+2034
-0
lines changed

13 files changed

+2034
-0
lines changed

vulnerabilities/importers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from vulnerabilities.pipelines.v2_importers import nvd_importer as nvd_importer_v2
6767
from vulnerabilities.pipelines.v2_importers import openssl_importer as openssl_importer_v2
6868
from vulnerabilities.pipelines.v2_importers import oss_fuzz as oss_fuzz_v2
69+
from vulnerabilities.pipelines.v2_importers import ossa_importer_v2
6970
from vulnerabilities.pipelines.v2_importers import postgresql_importer as postgresql_importer_v2
7071
from vulnerabilities.pipelines.v2_importers import (
7172
project_kb_msr2019_importer as project_kb_msr2019_importer_v2,
@@ -113,6 +114,7 @@
113114
nginx_importer_v2.NginxImporterPipeline,
114115
debian_importer_v2.DebianImporterPipeline,
115116
mattermost_importer_v2.MattermostImporterPipeline,
117+
ossa_importer_v2.OSSAImporterPipeline,
116118
apache_tomcat_v2.ApacheTomcatImporterPipeline,
117119
suse_score_importer_v2.SUSESeverityScoreImporterPipeline,
118120
retiredotnet_importer_v2.RetireDotnetImporterPipeline,
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import re
11+
from datetime import datetime
12+
from pathlib import Path
13+
from typing import Iterable
14+
from typing import Tuple
15+
16+
from dateutil import parser as dateparser
17+
from fetchcode.vcs import fetch_via_vcs
18+
from packageurl import PackageURL
19+
from pytz import UTC
20+
from univers.version_constraint import VersionConstraint
21+
from univers.version_range import PypiVersionRange
22+
from univers.versions import PypiVersion
23+
24+
from vulnerabilities.importer import AdvisoryData
25+
from vulnerabilities.importer import AffectedPackageV2
26+
from vulnerabilities.importer import ReferenceV2
27+
from vulnerabilities.pipelines import VulnerableCodeBaseImporterPipelineV2
28+
from vulnerabilities.utils import load_yaml
29+
30+
31+
class OSSAImporterPipeline(VulnerableCodeBaseImporterPipelineV2):
32+
"""OpenStack Security Advisory (OSSA) Importer Pipeline V2"""
33+
34+
pipeline_id = "ossa_importer_v2"
35+
spdx_license_expression = "CC-BY-3.0"
36+
license_url = "https://github.com/openstack/ossa/blob/master/LICENSE"
37+
repo_url = "git+https://github.com/openstack/ossa"
38+
cutoff_years = 10
39+
40+
@classmethod
41+
def steps(cls):
42+
return (
43+
cls.clone,
44+
cls.fetch,
45+
cls.collect_and_store_advisories,
46+
cls.clean_downloads,
47+
)
48+
49+
def clone(self):
50+
self.log(f"Cloning `{self.repo_url}`")
51+
self.vcs_response = fetch_via_vcs(self.repo_url)
52+
53+
def _get_cutoff_date(self):
54+
current_date = datetime.now(UTC)
55+
cutoff_year = current_date.year - self.cutoff_years
56+
return current_date.replace(year=cutoff_year)
57+
58+
def fetch(self):
59+
ossa_dir = Path(self.vcs_response.dest_dir) / "ossa"
60+
cutoff = self._get_cutoff_date()
61+
self.processable_advisories = []
62+
skipped_old = 0
63+
64+
for file_path in sorted(ossa_dir.glob("OSSA-*.yaml")):
65+
data = load_yaml(str(file_path))
66+
date_str = data.get("date")
67+
68+
if date_str:
69+
date_published = dateparser.parse(str(date_str))
70+
date_published = date_published.replace(tzinfo=UTC)
71+
72+
if date_published < cutoff:
73+
skipped_old += 1
74+
continue
75+
76+
self.processable_advisories.append(file_path)
77+
78+
if skipped_old > 0:
79+
self.log(f"Skipped {skipped_old} advisories older than {self.cutoff_years} years")
80+
self.log(f"Fetched {len(self.processable_advisories)} processable advisories")
81+
82+
def advisories_count(self) -> int:
83+
return len(self.processable_advisories)
84+
85+
def collect_advisories(self) -> Iterable[AdvisoryData]:
86+
for file_path in self.processable_advisories:
87+
advisory = self.process_file(file_path)
88+
yield advisory
89+
90+
def process_file(self, file_path):
91+
data = load_yaml(str(file_path))
92+
ossa_id = data.get("id")
93+
94+
date_published = None
95+
date_str = data.get("date")
96+
date_published = dateparser.parse(str(date_str))
97+
date_published = date_published.replace(tzinfo=UTC)
98+
99+
aliases = []
100+
for vulnerability in data.get("vulnerabilities"):
101+
cve = vulnerability.get("cve-id", "")
102+
aliases.append(cve)
103+
104+
affected_packages = []
105+
for entry in data.get("affected-products"):
106+
product = entry.get("product", "")
107+
version = entry.get("version", "")
108+
109+
if not product:
110+
self.log(f"Missing affected-product: {ossa_id}")
111+
continue
112+
113+
for package_name, version_str in self.expand_products(product, version):
114+
purl = PackageURL(type="pypi", name=package_name.lower())
115+
version_range = self.parse_version_range(version_str)
116+
if purl and version_range:
117+
affected_packages.append(
118+
AffectedPackageV2(package=purl, affected_version_range=version_range)
119+
)
120+
121+
references = []
122+
for link in (data.get("issues")).get("links"):
123+
references.append(ReferenceV2(url=str(link)))
124+
reviews = data.get("reviews")
125+
for branch, links in reviews.items():
126+
# Skip metadata fields like 'type: gerrit'(https://github.com/openstack/ossa/blob/4461806fbad5fbc111b4993b2ab4d6b718ba85c8/ossa/OSSA-2019-004.yaml#L46)
127+
if branch == "type":
128+
continue
129+
for link in links:
130+
references.append(ReferenceV2(url=link))
131+
132+
title = data.get("title", "")
133+
description = data.get("description", "")
134+
summary = f"{title}\n\n{description}"
135+
url = f"https://security.openstack.org/ossa/{ossa_id}.html"
136+
return AdvisoryData(
137+
advisory_id=ossa_id,
138+
aliases=aliases,
139+
summary=summary,
140+
affected_packages=affected_packages,
141+
references_v2=references,
142+
date_published=date_published,
143+
url=url,
144+
)
145+
146+
def expand_products(self, product_str, version_str) -> Iterable[Tuple[str, str]]:
147+
"""
148+
OSSA advisories specifies affected products in different formats:
149+
Format 1:
150+
version="Cinder <1.0; Glance <2.0"
151+
Format 2:
152+
product="Cinder, Glance"
153+
version="<1.0"
154+
"""
155+
# Format 1: "Cinder <1.0; Glance <2.0"
156+
if ";" in version_str:
157+
for segment in version_str.split(";"):
158+
parts = segment.split(None, 1)
159+
if len(parts) == 2:
160+
yield parts[0], parts[1]
161+
return
162+
163+
# Format 2: product="Cinder, Glance" version="<1.0"
164+
if "," in product_str:
165+
for product in product_str.split(","):
166+
if product:
167+
yield product, version_str
168+
return
169+
170+
yield product_str, version_str
171+
172+
def parse_version_range(self, version_str: str):
173+
original_version_str = version_str
174+
175+
if version_str.lower() == "all versions":
176+
self.log(f"Skipping 'all versions' - cannot parse to specific range")
177+
return None
178+
179+
# Normalize "and" to comma
180+
# "<=5.0.3, >=6.0.0 <=6.1.0 and ==7.0.0" -> "<=5.0.3, >=6.0.0 <=6.1.0, ==7.0.0"
181+
version_str = version_str.lower().replace(" and ", ",")
182+
183+
# Remove spaces around operators
184+
# "<=5.0.3, >=6.0.0 <=6.1.0, ==7.0.0" -> "<=5.0.3,>=6.0.0<=6.1.0,==7.0.0"
185+
version_str = re.sub(r"\s+([<>=!]+)", r"\1", version_str)
186+
version_str = re.sub(r"([<>=!]+)\s+", r"\1", version_str)
187+
188+
# Insert comma between consecutive constraints
189+
# "<=5.0.3,>=6.0.0<=6.1.0,==7.0.0" -> "<=5.0.3,>=6.0.0,<=6.1.0,==7.0.0"
190+
version_str = re.sub(r"(\d)([<>=!])", r"\1,\2", version_str)
191+
192+
constraints = []
193+
for part in version_str.split(","):
194+
comparator = None
195+
version = part
196+
197+
for op in ["==", "!=", "<=", ">=", "<", ">", "="]:
198+
if part.startswith(op):
199+
comparator = op
200+
version = part[len(op) :].strip()
201+
break
202+
203+
# Default to "=" if no comparator is found
204+
# "1.16.0" -> "=1.16.0"
205+
if comparator is None:
206+
comparator = "="
207+
# "==27.0.0" -> "=27.0.0"
208+
if comparator == "==":
209+
comparator = "="
210+
try:
211+
constraints.append(
212+
VersionConstraint(comparator=comparator, version=PypiVersion(version))
213+
)
214+
except ValueError as e:
215+
self.log(f"Failed to parse version '{version}' from '{original_version_str}' : {e}")
216+
continue
217+
218+
return PypiVersionRange(constraints=constraints) if constraints else None
219+
220+
def clean_downloads(self):
221+
if self.vcs_response:
222+
self.log("Removing cloned repository")
223+
self.vcs_response.delete()
224+
225+
def on_failure(self):
226+
self.clean_downloads()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
from pathlib import Path
11+
from unittest.mock import MagicMock
12+
from unittest.mock import patch
13+
14+
import pytest
15+
16+
from vulnerabilities.pipelines.v2_importers.ossa_importer_v2 import OSSAImporterPipeline
17+
from vulnerabilities.tests import util_tests
18+
19+
TEST_DATA = Path(__file__).parent.parent.parent / "test_data" / "ossa"
20+
21+
22+
@pytest.fixture
23+
def mock_vcs_response():
24+
mock = MagicMock()
25+
mock.dest_dir = str(TEST_DATA)
26+
mock.delete = MagicMock()
27+
return mock
28+
29+
30+
@pytest.fixture
31+
def mock_fetch_via_vcs(mock_vcs_response):
32+
with patch("vulnerabilities.pipelines.v2_importers.ossa_importer_v2.fetch_via_vcs") as mock:
33+
mock.return_value = mock_vcs_response
34+
yield mock
35+
36+
37+
def test_collect_advisories(mock_fetch_via_vcs):
38+
pipeline = OSSAImporterPipeline()
39+
pipeline.clone()
40+
pipeline.fetch()
41+
advisories = [adv.to_dict() for adv in pipeline.collect_advisories()]
42+
expected_file = TEST_DATA / "expected.json"
43+
util_tests.check_results_against_json(advisories, expected_file)

0 commit comments

Comments
 (0)