Skip to content

Commit 57365a4

Browse files
committed
Modify Ruby importer to support package-first mode #1911
* Update Ruby importer to only load and process advisories relevant to the purl passed in the constructor * Update Ruby importer tests to include testing the package-first mode Signed-off-by: Michael Ehab Mikhail <michael.ehab@hotmail.com>
1 parent a05b65e commit 57365a4

File tree

2 files changed

+199
-0
lines changed

2 files changed

+199
-0
lines changed

vulnerabilities/importers/ruby.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@
1010
import logging
1111
from pathlib import Path
1212
from typing import Iterable
13+
from typing import List
14+
from typing import Optional
1315

16+
import requests
17+
import saneyaml
1418
from dateutil.parser import parse
1519
from packageurl import PackageURL
1620
from pytz import UTC
1721
from univers.version_range import GemVersionRange
22+
from univers.versions import RubygemsVersion
1823

1924
from vulnerabilities.importer import AdvisoryData
2025
from vulnerabilities.importer import AffectedPackage
@@ -52,7 +57,69 @@ class RubyImporter(Importer):
5257
SOFTWARE.
5358
"""
5459

60+
def __init__(self, purl=None, *args, **kwargs):
61+
super().__init__(*args, **kwargs)
62+
self.purl = purl
63+
if self.purl and self.purl.type not in ("gem", "ruby"):
64+
print(
65+
f"Warning: PURL type {self.purl.type} is not 'gem' or 'ruby, may not match any advisories"
66+
)
67+
5568
def advisory_data(self) -> Iterable[AdvisoryData]:
69+
if not self.purl:
70+
return self._batch_advisory_data()
71+
72+
return self._package_first_advisory_data()
73+
74+
def _package_first_advisory_data(self) -> Iterable[AdvisoryData]:
75+
if self.purl.type not in ("gem", "ruby"):
76+
return []
77+
78+
try:
79+
yaml_files = []
80+
81+
if self.purl.type == "gem":
82+
files = self._fetch_github_directory_content(f"gems/{self.purl.name}")
83+
yaml_files.extend(
84+
[
85+
(file, "gems")
86+
for file in files
87+
if file.endswith(".yml") and not file.startswith("OSVDB-")
88+
]
89+
)
90+
elif self.purl.type == "ruby":
91+
files = self._fetch_github_directory_content("rubies")
92+
yaml_files.extend(
93+
[
94+
(file, "rubies")
95+
for file in files
96+
if file.endswith(".yml") and not file.startswith("OSVDB-")
97+
]
98+
)
99+
100+
for file_path, schema_type in yaml_files:
101+
content = self._fetch_github_file_content(file_path)
102+
if not content:
103+
continue
104+
105+
raw_data = saneyaml.load(content)
106+
107+
if schema_type == "rubies" and raw_data.get("engine") != self.purl.name:
108+
continue
109+
110+
advisory_url = (
111+
f"https://github.com/rubysec/ruby-advisory-db/blob/master/{file_path}"
112+
)
113+
advisory = parse_ruby_advisory(raw_data, schema_type, advisory_url)
114+
115+
if advisory and self._advisory_affects_purl(advisory):
116+
yield advisory
117+
118+
except Exception as e:
119+
logger.error(f"Error fetching advisories for {self.purl}: {str(e)}")
120+
return []
121+
122+
def _batch_advisory_data(self) -> Iterable[AdvisoryData]:
56123
try:
57124
self.clone(self.repo_url)
58125
base_path = Path(self.vcs_response.dest_dir)
@@ -72,6 +139,56 @@ def advisory_data(self) -> Iterable[AdvisoryData]:
72139
if self.vcs_response:
73140
self.vcs_response.delete()
74141

142+
def _advisory_affects_purl(self, advisory: AdvisoryData) -> bool:
143+
if not self.purl:
144+
return True
145+
146+
for affected_package in advisory.affected_packages:
147+
if affected_package.package.type != self.purl.type:
148+
continue
149+
150+
if affected_package.package.name != self.purl.name:
151+
continue
152+
153+
if self.purl.version and affected_package.affected_version_range:
154+
purl_version = RubygemsVersion(self.purl.version)
155+
156+
if purl_version not in affected_package.affected_version_range:
157+
continue
158+
159+
return True
160+
161+
return False
162+
163+
def _fetch_github_directory_content(self, path: str) -> List[str]:
164+
url = f"https://api.github.com/repos/rubysec/ruby-advisory-db/contents/{path}"
165+
response = requests.get(url)
166+
167+
if response.status_code != 200:
168+
logger.error(f"Failed to fetch directory contents from GitHub: {response.status_code}")
169+
return []
170+
171+
contents = response.json()
172+
file_paths = []
173+
174+
for item in contents:
175+
if item["type"] == "file":
176+
file_paths.append(item["path"])
177+
elif item["type"] == "dir":
178+
file_paths.extend(self._fetch_github_directory_content(item["path"]))
179+
180+
return file_paths
181+
182+
def _fetch_github_file_content(self, path: str) -> Optional[str]:
183+
url = f"https://api.github.com/repos/rubysec/ruby-advisory-db/contents/{path}"
184+
response = requests.get(url, headers={"Accept": "application/vnd.github.v3.raw"})
185+
186+
if response.status_code != 200:
187+
logger.error(f"Failed to fetch file content from GitHub: {response.status_code}")
188+
return None
189+
190+
return response.text
191+
75192

76193
def parse_ruby_advisory(record, schema_type, advisory_url):
77194
"""

vulnerabilities/tests/test_ruby.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
import pytest
1414
from packageurl import PackageURL
1515
from univers.version_range import GemVersionRange
16+
from univers.versions import RubygemsVersion
1617

1718
from vulnerabilities.importer import AdvisoryData
1819
from vulnerabilities.importer import AffectedPackage
20+
from vulnerabilities.importers.ruby import RubyImporter
1921
from vulnerabilities.importers.ruby import get_affected_packages
2022
from vulnerabilities.importers.ruby import parse_ruby_advisory
2123
from vulnerabilities.improvers.default import DefaultImprover
@@ -94,3 +96,83 @@ def test_ruby_improver(mock_response):
9496
)
9597
def test_get_affected_packages(record, purl, result):
9698
assert get_affected_packages(record, purl) == result
99+
100+
101+
@pytest.fixture
102+
def mock_github_api(monkeypatch):
103+
test_files = {
104+
"gems/sinatra/CVE-2018-7212.yml": open(os.path.join(TEST_DATA, "CVE-2018-7212.yml")).read(),
105+
"gems/sinatra/CVE-2018-11627.yml": open(
106+
os.path.join(TEST_DATA, "CVE-2018-11627.yml")
107+
).read(),
108+
"rubies/CVE-2010-1330.yml": open(os.path.join(TEST_DATA, "CVE-2010-1330.yml")).read(),
109+
"rubies/CVE-2007-5770.yml": open(os.path.join(TEST_DATA, "CVE-2007-5770.yml")).read(),
110+
}
111+
112+
dir_listing = {
113+
"gems/sinatra": [
114+
"gems/sinatra/CVE-2018-7212.yml",
115+
"gems/sinatra/CVE-2018-11627.yml",
116+
],
117+
"rubies": [
118+
"rubies/CVE-2010-1330.yml",
119+
"rubies/CVE-2007-5770.yml",
120+
],
121+
}
122+
123+
def mock_fetch_github_directory_content(self, path):
124+
return dir_listing.get(path, [])
125+
126+
def mock_fetch_github_file_content(self, path):
127+
return test_files.get(path, "")
128+
129+
monkeypatch.setattr(
130+
RubyImporter, "_fetch_github_directory_content", mock_fetch_github_directory_content
131+
)
132+
monkeypatch.setattr(RubyImporter, "_fetch_github_file_content", mock_fetch_github_file_content)
133+
134+
135+
def test_package_first_mode_gem_affecting(mock_github_api):
136+
purl = PackageURL(type="gem", name="sinatra")
137+
importer = RubyImporter(purl=purl)
138+
advisories = list(importer.advisory_data())
139+
assert len(advisories) == 2
140+
assert all(a.affected_packages[0].package.name == "sinatra" for a in advisories)
141+
142+
143+
def test_package_first_mode_gem_version(mock_github_api):
144+
purl = PackageURL(type="gem", name="sinatra", version="1.2.7")
145+
importer = RubyImporter(purl=purl)
146+
advisories = list(importer.advisory_data())
147+
assert len(advisories) == 2
148+
for adv in advisories:
149+
affected = any(
150+
purl.version
151+
and ap.package.name == purl.name
152+
and ap.affected_version_range
153+
and ap.affected_version_range.contains(RubygemsVersion(purl.version))
154+
for ap in adv.affected_packages
155+
)
156+
assert affected
157+
158+
159+
def test_package_first_mode_gem_not_affecting(mock_github_api):
160+
purl = PackageURL(type="gem", name="nonexistent", version="9.9.9")
161+
importer = RubyImporter(purl=purl)
162+
advisories = list(importer.advisory_data())
163+
assert advisories == []
164+
165+
166+
def test_package_first_mode_ruby_engine(mock_github_api):
167+
purl = PackageURL(type="ruby", name="jruby")
168+
importer = RubyImporter(purl=purl)
169+
advisories = list(importer.advisory_data())
170+
assert len(advisories) == 1
171+
assert advisories[0].affected_packages[0].package.name == "jruby"
172+
173+
174+
def test_package_first_mode_ruby_engine_not_affecting(mock_github_api):
175+
purl = PackageURL(type="ruby", name="nonexistent")
176+
importer = RubyImporter(purl=purl)
177+
advisories = list(importer.advisory_data())
178+
assert advisories == []

0 commit comments

Comments
 (0)