Skip to content

Commit caf13ba

Browse files
committed
Modify NPM importer to support package-first mode using SCM approach #1936
Signed-off-by: Michael Ehab Mikhail <michael.ehab@hotmail.com>
1 parent b2d9c75 commit caf13ba

File tree

4 files changed

+111
-240
lines changed

4 files changed

+111
-240
lines changed

vulnerabilities/pipelines/npm_importer.py

Lines changed: 21 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,6 @@ def __init__(self, *args, purl=None, **kwargs):
5656

5757
@classmethod
5858
def steps(cls):
59-
if not cls.is_batch_run:
60-
return [
61-
cls.fetch_package_advisories,
62-
cls.collect_and_store_advisories,
63-
cls.import_new_advisories,
64-
]
65-
6659
return [
6760
cls.clone,
6861
cls.collect_and_store_advisories,
@@ -74,58 +67,32 @@ def clone(self):
7467
self.log(f"Cloning `{self.repo_url}`")
7568
self.vcs_response = fetch_via_vcs(self.repo_url)
7669

77-
def fetch_package_advisories(self):
78-
if not self.purl or self.purl.type != "npm":
79-
return
80-
81-
self.log(f"Fetching advisories for package {self.purl.name}")
82-
83-
package_name = self.purl.name
84-
85-
self.temp_dir = tempfile.mkdtemp()
86-
self.package_advisories = []
87-
88-
api_url = "https://api.github.com/repos/nodejs/security-wg/contents/vuln/npm"
89-
response = requests.get(api_url)
70+
def advisories_count(self):
71+
vuln_directory = Path(self.vcs_response.dest_dir) / "vuln" / "npm"
72+
return sum(1 for _ in vuln_directory.glob("*.json"))
9073

91-
if response.status_code != 200:
92-
self.log(f"Failed to fetch advisories directory: {response.status_code}")
93-
return
74+
def collect_advisories(self) -> Iterable[AdvisoryData]:
75+
vuln_directory = Path(self.vcs_response.dest_dir) / "vuln" / "npm"
76+
advisory_files = list(vuln_directory.glob("*.json"))
9477

95-
for item in response.json():
96-
if item["type"] == "file" and item["name"].endswith(".json"):
97-
file_url = item["download_url"]
78+
if not self.is_batch_run:
79+
package_name = self.purl.name
80+
filtered_files = []
81+
for advisory_file in advisory_files:
9882
try:
99-
file_content = requests.get(file_url).json()
100-
101-
if file_content.get("module_name") == package_name:
102-
file_path = os.path.join(self.temp_dir, item["name"])
103-
with open(file_path, "w") as f:
104-
json.dump(file_content, f)
105-
self.package_advisories.append(file_path)
83+
data = load_json(advisory_file)
84+
if data.get("module_name") == package_name:
85+
affected_package = self.get_affected_package(data, package_name)
86+
if not self.purl.version or self._version_is_affected(affected_package):
87+
filtered_files.append(advisory_file)
10688
except Exception as e:
107-
self.log(f"Error processing advisory file {item['name']}: {str(e)}")
89+
self.log(f"Error processing advisory file {advisory_file}: {str(e)}")
90+
advisory_files = filtered_files
10891

109-
self.log(f"Found {len(self.package_advisories)} advisories for package {package_name}")
110-
111-
def advisories_count(self):
112-
if NpmImporterPipeline.is_batch_run:
113-
vuln_directory = Path(self.vcs_response.dest_dir) / "vuln" / "npm"
114-
return sum(1 for _ in vuln_directory.glob("*.json"))
115-
else:
116-
return len(getattr(self, "package_advisories", []))
117-
118-
def collect_advisories(self) -> Iterable[AdvisoryData]:
119-
if NpmImporterPipeline.is_batch_run:
120-
vuln_directory = Path(self.vcs_response.dest_dir) / "vuln" / "npm"
121-
for advisory in vuln_directory.glob("*.json"):
122-
yield from self.to_advisory_data(advisory)
123-
else:
124-
if not hasattr(self, "package_advisories"):
125-
return
126-
127-
for advisory_path in self.package_advisories:
128-
yield from self.to_advisory_data(Path(advisory_path))
92+
for advisory in list(advisory_files):
93+
for result in self.to_advisory_data(advisory):
94+
if result:
95+
yield result
12996

13097
def to_advisory_data(self, file: Path) -> Iterable[AdvisoryData]:
13198
data = load_json(file)
@@ -241,11 +208,5 @@ def clean_downloads(self):
241208
self.log(f"Removing cloned repository")
242209
self.vcs_response.delete()
243210

244-
if hasattr(self, "temp_dir") and os.path.exists(self.temp_dir):
245-
import shutil
246-
247-
self.log(f"Removing temporary directory")
248-
shutil.rmtree(self.temp_dir)
249-
250211
def on_failure(self):
251212
self.clean_downloads()

vulnerabilities/pipelines/v2_importers/npm_importer.py

Lines changed: 21 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,6 @@ def __init__(self, *args, purl=None, **kwargs):
5959

6060
@classmethod
6161
def steps(cls):
62-
if not cls.is_batch_run:
63-
return (
64-
cls.fetch_package_advisories,
65-
cls.collect_and_store_advisories,
66-
cls.clean_downloads,
67-
)
6862
return (
6963
cls.clone,
7064
cls.collect_and_store_advisories,
@@ -75,60 +69,32 @@ def clone(self):
7569
self.log(f"Cloning `{self.repo_url}`")
7670
self.vcs_response = fetch_via_vcs(self.repo_url)
7771

78-
def fetch_package_advisories(self):
79-
if not self.purl or self.purl.type != "npm":
80-
return
81-
82-
self.log(f"Fetching advisories for package {self.purl.name}")
83-
84-
package_name = self.purl.name
85-
86-
self.temp_dir = tempfile.mkdtemp()
87-
self.package_advisories = []
88-
89-
api_url = "https://api.github.com/repos/nodejs/security-wg/contents/vuln/npm"
90-
response = requests.get(api_url)
72+
def advisories_count(self):
73+
vuln_directory = Path(self.vcs_response.dest_dir) / "vuln" / "npm"
74+
return sum(1 for _ in vuln_directory.glob("*.json"))
9175

92-
if response.status_code != 200:
93-
self.log(f"Failed to fetch advisories directory: {response.status_code}")
94-
return
76+
def collect_advisories(self) -> Iterable[AdvisoryData]:
77+
vuln_directory = Path(self.vcs_response.dest_dir) / "vuln" / "npm"
78+
advisory_files = list(vuln_directory.glob("*.json"))
9579

96-
for item in response.json():
97-
if item["type"] == "file" and item["name"].endswith(".json"):
98-
file_url = item["download_url"]
80+
if not self.is_batch_run:
81+
package_name = self.purl.name
82+
filtered_files = []
83+
for advisory_file in advisory_files:
9984
try:
100-
file_content = requests.get(file_url).json()
101-
102-
if file_content.get("module_name") == package_name:
103-
file_path = os.path.join(self.temp_dir, item["name"])
104-
with open(file_path, "w") as f:
105-
json.dump(file_content, f)
106-
self.package_advisories.append(file_path)
85+
data = load_json(advisory_file)
86+
if data.get("module_name") == package_name:
87+
affected_package = self.get_affected_package(data, package_name)
88+
if not self.purl.version or self._version_is_affected(affected_package):
89+
filtered_files.append(advisory_file)
10790
except Exception as e:
108-
self.log(f"Error processing advisory file {item['name']}: {str(e)}")
109-
110-
self.log(f"Found {len(self.package_advisories)} advisories for package {package_name}")
111-
112-
def advisories_count(self):
113-
if NpmImporterPipeline.is_batch_run:
114-
vuln_directory = Path(self.vcs_response.dest_dir) / "vuln" / "npm"
115-
return sum(1 for _ in vuln_directory.glob("*.json"))
116-
else:
117-
return len(getattr(self, "package_advisories", []))
118-
119-
def collect_advisories(self) -> Iterable[AdvisoryData]:
120-
if NpmImporterPipeline.is_batch_run:
121-
vuln_directory = Path(self.vcs_response.dest_dir) / "vuln" / "npm"
122-
for advisory in vuln_directory.glob("*.json"):
123-
yield self.to_advisory_data(advisory)
124-
else:
125-
if not hasattr(self, "package_advisories"):
126-
return
91+
self.log(f"Error processing advisory file {advisory_file}: {str(e)}")
92+
advisory_files = filtered_files
12793

128-
for advisory_path in self.package_advisories:
129-
result = self.to_advisory_data(Path(advisory_path))
130-
if result:
131-
yield result
94+
for advisory in list(advisory_files):
95+
result = self.to_advisory_data(advisory)
96+
if result:
97+
yield result
13298

13399
def to_advisory_data(self, file: Path) -> Iterable[AdvisoryData]:
134100
if file.name == "index.json":

vulnerabilities/tests/pipelines/test_npm_importer_pipeline.py

Lines changed: 40 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import json
1313
import os
1414
from pathlib import Path
15-
from unittest.mock import MagicMock
15+
from types import SimpleNamespace
1616
from unittest.mock import patch
1717

1818
from packageurl import PackageURL
@@ -80,31 +80,23 @@ def test_npm_improver(mock_response):
8080
util_tests.check_results_against_json(result, expected_file)
8181

8282

83-
@patch("requests.get")
84-
def test_package_first_mode_valid_npm_package(mock_get):
85-
mock_dir_response = MagicMock()
86-
mock_dir_response.status_code = 200
87-
mock_dir_response.json.return_value = [
88-
{
89-
"type": "file",
90-
"name": "152.json",
91-
"download_url": "https://raw.githubusercontent.com/nodejs/security-wg/main/vuln/npm/152.json",
92-
}
93-
]
83+
def test_package_first_mode_valid_npm_package(tmp_path):
84+
vuln_dir = tmp_path / "vuln" / "npm"
85+
vuln_dir.mkdir(parents=True)
9486

9587
npm_sample_file = os.path.join(TEST_DATA, "npm_sample.json")
9688
with open(npm_sample_file) as f:
9789
sample_data = json.load(f)
9890

99-
mock_file_response = MagicMock()
100-
mock_file_response.json.return_value = sample_data
91+
advisory_file = vuln_dir / "152.json"
92+
advisory_file.write_text(json.dumps(sample_data))
10193

102-
mock_get.side_effect = [mock_dir_response, mock_file_response]
94+
mock_vcs_response = SimpleNamespace(dest_dir=str(tmp_path), delete=lambda: None)
10395

10496
purl = PackageURL(type="npm", name="npm", version="1.2.0")
10597
pipeline = NpmImporterPipeline(purl=purl)
98+
pipeline.vcs_response = mock_vcs_response
10699

107-
pipeline.fetch_package_advisories()
108100
advisories = list(pipeline.collect_advisories())
109101

110102
assert len(advisories) == 1
@@ -113,91 +105,74 @@ def test_package_first_mode_valid_npm_package(mock_get):
113105
assert advisories[0].affected_packages[0].package.name == "npm"
114106

115107

116-
@patch("requests.get")
117-
def test_package_first_mode_unaffected_version(mock_get):
118-
mock_dir_response = MagicMock()
119-
mock_dir_response.status_code = 200
120-
mock_dir_response.json.return_value = [
121-
{
122-
"type": "file",
123-
"name": "152.json",
124-
"download_url": "https://raw.githubusercontent.com/nodejs/security-wg/main/vuln/npm/152.json",
125-
}
126-
]
108+
def test_package_first_mode_unaffected_version(tmp_path):
109+
vuln_dir = tmp_path / "vuln" / "npm"
110+
vuln_dir.mkdir(parents=True)
127111

128112
npm_sample_file = os.path.join(TEST_DATA, "npm_sample.json")
129113
with open(npm_sample_file) as f:
130114
sample_data = json.load(f)
131115

132-
mock_file_response = MagicMock()
133-
mock_file_response.json.return_value = sample_data
116+
advisory_file = vuln_dir / "152.json"
117+
advisory_file.write_text(json.dumps(sample_data))
134118

135-
mock_get.side_effect = [mock_dir_response, mock_file_response]
119+
mock_vcs_response = SimpleNamespace(dest_dir=str(tmp_path), delete=lambda: None)
136120

137121
purl = PackageURL(type="npm", name="npm", version="1.4.0")
138122
pipeline = NpmImporterPipeline(purl=purl)
123+
pipeline.vcs_response = mock_vcs_response
139124

140-
pipeline.fetch_package_advisories()
141125
advisories = list(pipeline.collect_advisories())
142126

143127
assert len(advisories) == 0
144128

145129

146-
@patch("requests.get")
147-
def test_package_first_mode_invalid_package_type(mock_get):
130+
def test_package_first_mode_invalid_package_type(tmp_path):
131+
vuln_dir = tmp_path / "vuln" / "npm"
132+
vuln_dir.mkdir(parents=True)
133+
134+
mock_vcs_response = SimpleNamespace(dest_dir=str(tmp_path), delete=lambda: None)
135+
148136
purl = PackageURL(type="pypi", name="django", version="3.0.0")
149137
pipeline = NpmImporterPipeline(purl=purl)
138+
pipeline.vcs_response = mock_vcs_response
150139

151-
pipeline.fetch_package_advisories()
152140
advisories = list(pipeline.collect_advisories())
153141

154142
assert len(advisories) == 0
155-
mock_get.assert_not_called()
156-
157-
158-
@patch("requests.get")
159-
def test_package_first_mode_package_not_found(mock_get):
160-
mock_dir_response = MagicMock()
161-
mock_dir_response.status_code = 200
162-
mock_dir_response.json.return_value = [
163-
{
164-
"type": "file",
165-
"name": "152.json",
166-
"download_url": "https://raw.githubusercontent.com/nodejs/security-wg/main/vuln/npm/152.json",
167-
}
168-
]
143+
144+
145+
def test_package_first_mode_package_not_found(tmp_path):
146+
vuln_dir = tmp_path / "vuln" / "npm"
147+
vuln_dir.mkdir(parents=True)
169148

170149
npm_sample_file = os.path.join(TEST_DATA, "npm_sample.json")
171150
with open(npm_sample_file) as f:
172151
sample_data = json.load(f)
173152

174153
sample_data["module_name"] = "some-other-package"
175154

176-
mock_file_response = MagicMock()
177-
mock_file_response.json.return_value = sample_data
155+
advisory_file = vuln_dir / "152.json"
156+
advisory_file.write_text(json.dumps(sample_data))
178157

179-
mock_get.side_effect = [mock_dir_response, mock_file_response]
158+
mock_vcs_response = SimpleNamespace(dest_dir=str(tmp_path), delete=lambda: None)
180159

181160
purl = PackageURL(type="npm", name="nonexistent-package", version="1.0.0")
182161
pipeline = NpmImporterPipeline(purl=purl)
162+
pipeline.vcs_response = mock_vcs_response
183163

184-
pipeline.fetch_package_advisories()
185164
advisories = list(pipeline.collect_advisories())
186165

187166
assert len(advisories) == 0
188167

189168

190-
@patch("requests.get")
191-
def test_package_first_mode_api_error(mock_get):
192-
mock_error_response = MagicMock()
193-
mock_error_response.status_code = 404
194-
195-
mock_get.return_value = mock_error_response
169+
def test_package_first_mode_missing_vuln_directory(tmp_path):
170+
mock_vcs_response = SimpleNamespace(dest_dir=str(tmp_path), delete=lambda: None)
196171

197172
purl = PackageURL(type="npm", name="npm", version="1.0.0")
198173
pipeline = NpmImporterPipeline(purl=purl)
174+
pipeline.vcs_response = mock_vcs_response
199175

200-
pipeline.fetch_package_advisories()
201176
advisories = list(pipeline.collect_advisories())
202177

203178
assert len(advisories) == 0
@@ -228,3 +203,9 @@ def test_version_is_affected():
228203
fixed_version=SemverVersion(string="1.3.3"),
229204
)
230205
assert pipeline._version_is_affected(affected_package_no_range) == True
206+
affected_package_no_range = AffectedPackage(
207+
package=PackageURL(type="npm", name="npm"),
208+
affected_version_range=None,
209+
fixed_version=SemverVersion(string="1.3.3"),
210+
)
211+
assert pipeline._version_is_affected(affected_package_no_range) == True

0 commit comments

Comments
 (0)