Skip to content

Commit 6b0a4c2

Browse files
authored
Merge pull request #960 from TG1999/migrate/npm
Migrate npm importer
2 parents ef4fe40 + 258254b commit 6b0a4c2

File tree

9 files changed

+246
-299
lines changed

9 files changed

+246
-299
lines changed

CHANGELOG.rst

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ Release notes
22
=============
33

44

5+
Version v31.1.0
6+
----------------
7+
8+
- We re-enabled support for the NPM vulnerabilities advisories importer.
9+
510

611
Version v31.0.0
712
----------------

vulnerabilities/importer.py

+9
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ class Importer:
294294
spdx_license_expression = ""
295295
license_url = ""
296296
notice = ""
297+
vcs_response = None
297298

298299
def __init__(self):
299300
if not self.spdx_license_expression:
@@ -319,6 +320,14 @@ def advisory_data(self) -> Iterable[AdvisoryData]:
319320
"""
320321
raise NotImplementedError
321322

323+
def clone(self, repo_url):
324+
try:
325+
self.vcs_response = fetch_via_vcs(repo_url)
326+
except Exception as e:
327+
msg = f"Failed to fetch {repo_url} via vcs: {e}"
328+
logger.error(msg)
329+
raise ForkError(msg) from e
330+
322331

323332
class ForkError(Exception):
324333
pass

vulnerabilities/importers/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from vulnerabilities.importers import github
1515
from vulnerabilities.importers import gitlab
1616
from vulnerabilities.importers import nginx
17+
from vulnerabilities.importers import npm
1718
from vulnerabilities.importers import nvd
1819
from vulnerabilities.importers import openssl
1920
from vulnerabilities.importers import postgresql
@@ -37,6 +38,7 @@
3738
archlinux.ArchlinuxImporter,
3839
ubuntu.UbuntuImporter,
3940
debian_oval.DebianOvalImporter,
41+
npm.NpmImporter,
4042
]
4143

4244
IMPORTERS_REGISTRY = {x.qualified_name: x for x in IMPORTERS_REGISTRY}

vulnerabilities/importers/npm.py

+114-170
Original file line numberDiff line numberDiff line change
@@ -9,190 +9,134 @@
99

1010
# Author: Navonil Das (@NavonilDas)
1111

12-
import asyncio
12+
from pathlib import Path
13+
from typing import Iterable
1314
from typing import List
14-
from typing import Set
15-
from typing import Tuple
16-
from urllib.parse import quote
1715

1816
import pytz
1917
from dateutil.parser import parse
2018
from packageurl import PackageURL
21-
from univers.version_range import VersionRange
22-
from univers.versions import SemverVersion
19+
from univers.version_range import NpmVersionRange
2320

2421
from vulnerabilities.importer import AdvisoryData
25-
from vulnerabilities.importer import GitImporter
22+
from vulnerabilities.importer import AffectedPackage
23+
from vulnerabilities.importer import Importer
2624
from vulnerabilities.importer import Reference
27-
from vulnerabilities.package_managers import NpmVersionAPI
25+
from vulnerabilities.importer import VulnerabilitySeverity
26+
from vulnerabilities.severity_systems import CVSSV2
27+
from vulnerabilities.severity_systems import CVSSV3
28+
from vulnerabilities.utils import build_description
2829
from vulnerabilities.utils import load_json
29-
from vulnerabilities.utils import nearest_patched_package
3030

31-
NPM_URL = "https://registry.npmjs.org{}"
3231

33-
34-
class NpmImporter(GitImporter):
35-
def __enter__(self):
36-
super(NpmImporter, self).__enter__()
37-
if not getattr(self, "_added_files", None):
38-
self._added_files, self._updated_files = self.file_changes(
39-
recursive=True, file_ext="json", subdir="./vuln/npm"
32+
class NpmImporter(Importer):
33+
spdx_license_expression = "MIT"
34+
license_url = "https://github.com/nodejs/security-wg/blob/main/LICENSE.md"
35+
repo_url = "git+https://github.com/nodejs/security-wg"
36+
37+
def advisory_data(self) -> Iterable[AdvisoryData]:
38+
try:
39+
self.clone(self.repo_url)
40+
path = Path(self.vcs_response.dest_dir)
41+
42+
vuln = path / "vuln"
43+
npm_vulns = vuln / "npm"
44+
for file in npm_vulns.glob("*.json"):
45+
yield from self.to_advisory_data(file)
46+
finally:
47+
if self.vcs_response:
48+
self.vcs_response.delete()
49+
50+
def to_advisory_data(self, file: Path) -> List[AdvisoryData]:
51+
data = load_json(file)
52+
id = data.get("id")
53+
description = data.get("overview") or ""
54+
summary = data.get("title") or ""
55+
date_published = parse(data.get("created_at")).replace(tzinfo=pytz.UTC)
56+
references = []
57+
cvss_vector = data.get("cvss_vector")
58+
cvss_score = data.get("cvss_score")
59+
severities = []
60+
if cvss_vector and cvss_vector.startswith("CVSS:3.0/"):
61+
severities.append(
62+
VulnerabilitySeverity(
63+
system=CVSSV3,
64+
value=cvss_score,
65+
)
66+
)
67+
if cvss_vector and cvss_vector.startswith("CVSS:2.0/"):
68+
severities.append(
69+
VulnerabilitySeverity(
70+
system=CVSSV2,
71+
value=cvss_score,
72+
)
4073
)
4174

42-
self._versions = NpmVersionAPI()
43-
self.set_api(self.collect_packages())
44-
45-
def updated_advisories(self) -> Set[AdvisoryData]:
46-
files = self._updated_files.union(self._added_files)
47-
advisories = []
48-
for f in files:
49-
processed_data = self.process_file(f)
50-
if processed_data:
51-
advisories.extend(processed_data)
52-
return self.batch_advisories(advisories)
53-
54-
def set_api(self, packages):
55-
asyncio.run(self._versions.load_api(packages))
56-
57-
def collect_packages(self):
58-
packages = set()
59-
files = self._updated_files.union(self._added_files)
60-
for f in files:
61-
data = load_json(f)
62-
packages.add(data["module_name"].strip())
63-
64-
return packages
65-
66-
@property
67-
def versions(self): # quick hack to make it patchable
68-
return self._versions
69-
70-
def process_file(self, file) -> List[AdvisoryData]:
71-
72-
record = load_json(file)
73-
advisories = []
74-
package_name = record["module_name"].strip()
75-
76-
publish_date = parse(record["updated_at"])
77-
publish_date = publish_date.replace(tzinfo=pytz.UTC)
78-
79-
all_versions = self.versions.get(package_name, until=publish_date).valid_versions
80-
aff_range = record.get("vulnerable_versions")
81-
if not aff_range:
82-
aff_range = ""
83-
fixed_range = record.get("patched_versions")
84-
if not fixed_range:
85-
fixed_range = ""
86-
87-
if aff_range == "*" or fixed_range == "*":
88-
return []
89-
90-
impacted_versions, resolved_versions = categorize_versions(
91-
all_versions, aff_range, fixed_range
75+
advisory_reference = Reference(
76+
url=f"https://github.com/nodejs/security-wg/blob/main/vuln/npm/{id}.json",
77+
reference_id=id,
78+
severities=severities,
9279
)
9380

94-
impacted_purls = _versions_to_purls(package_name, impacted_versions)
95-
resolved_purls = _versions_to_purls(package_name, resolved_versions)
96-
vuln_reference = [
97-
Reference(
98-
url=NPM_URL.format(f'/-/npm/v1/advisories/{record["id"]}'),
99-
reference_id=record["id"],
100-
)
101-
]
102-
103-
for cve_id in record.get("cves") or [""]:
104-
advisories.append(
105-
AdvisoryData(
106-
summary=record.get("overview", ""),
107-
vulnerability_id=cve_id,
108-
affected_packages=nearest_patched_package(impacted_purls, resolved_purls),
109-
references=vuln_reference,
81+
for ref in data.get("references") or []:
82+
references.append(
83+
Reference(
84+
url=ref,
85+
severities=severities,
11086
)
11187
)
112-
return advisories
113-
114-
115-
def _versions_to_purls(package_name, versions):
116-
purls = {f"pkg:npm/{quote(package_name)}@{v}" for v in versions}
117-
return [PackageURL.from_string(s) for s in purls]
118-
119-
120-
def normalize_ranges(version_range_string):
121-
"""
122-
- Splits version range strings with "||" operator into separate ranges.
123-
- Removes spaces between range operator and range operands
124-
- Normalizes 'x' ranges
125-
Example:
126-
>>> z = normalize_ranges(">=6.1.3 < 7.0.0 || >=7.0.3")
127-
>>> assert z == [">=6.1.3,<7.0.0", ">=7.0.3"]
128-
"""
129-
130-
version_ranges = version_range_string.split("||")
131-
version_ranges = list(map(str.strip, version_ranges))
132-
for id, version_range in enumerate(version_ranges):
133-
134-
# TODO: This is cryptic, simplify this if possible
135-
version_ranges[id] = ",".join(version_range.split())
136-
version_ranges[id] = version_ranges[id].replace(">=,", ">=")
137-
version_ranges[id] = version_ranges[id].replace("<=,", "<=")
138-
version_ranges[id] = version_ranges[id].replace("<=,", "<=")
139-
version_ranges[id] = version_ranges[id].replace("<,", "<")
140-
version_ranges[id] = version_ranges[id].replace(">,", ">")
141-
142-
# "x" is interpretted as wild card character here. These are not part of semver
143-
# spec. We replace the "x" with aribitarily large number to simulate the effect.
144-
if ".x." in version_ranges[id]:
145-
version_ranges[id] = version_ranges[id].replace(".x", ".10000.0")
146-
if ".x" in version_ranges[id]:
147-
version_ranges[id] = version_ranges[id].replace(".x", ".10000")
148-
149-
return version_ranges
150-
151-
152-
def categorize_versions(
153-
all_versions: Set[str],
154-
affected_version_range: str,
155-
fixed_version_range: str,
156-
) -> Tuple[Set[str], Set[str]]:
157-
"""
158-
Seperate list of affected versions and unaffected versions from all versions
159-
using the ranges specified.
160-
161-
:return: impacted, resolved versions
162-
"""
163-
if not all_versions:
164-
# NPM registry has no data regarding this package, we skip these
165-
return set(), set()
166-
167-
aff_spec = []
168-
fix_spec = []
169-
170-
if affected_version_range:
171-
aff_specs = normalize_ranges(affected_version_range)
172-
aff_spec = [
173-
VersionRange.from_scheme_version_spec_string("semver", spec)
174-
for spec in aff_specs
175-
if len(spec) >= 3
176-
]
177-
178-
if fixed_version_range:
179-
fix_specs = normalize_ranges(fixed_version_range)
180-
fix_spec = [
181-
VersionRange.from_scheme_version_spec_string("semver", spec)
182-
for spec in fix_specs
183-
if len(spec) >= 3
184-
]
185-
aff_ver, fix_ver = set(), set()
186-
# Unaffected version is that version which is in the fixed_version_range
187-
# or which is absent in the affected_version_range
188-
for ver in all_versions:
189-
ver_obj = SemverVersion(ver)
190-
191-
if not any([ver_obj in spec for spec in aff_spec]) or any(
192-
[ver_obj in spec for spec in fix_spec]
193-
):
194-
fix_ver.add(ver)
195-
else:
196-
aff_ver.add(ver)
197-
198-
return aff_ver, fix_ver
88+
89+
if advisory_reference not in references:
90+
references.append(advisory_reference)
91+
92+
package_name = data.get("module_name")
93+
affected_packages = []
94+
if package_name:
95+
affected_packages.append(self.get_affected_package(data, package_name))
96+
advsisory_aliases = data.get("cves") or []
97+
98+
for alias in advsisory_aliases:
99+
yield AdvisoryData(
100+
summary=build_description(summary=summary, description=description),
101+
references=references,
102+
date_published=date_published,
103+
affected_packages=affected_packages,
104+
aliases=[alias],
105+
)
106+
107+
def get_affected_package(self, data, package_name):
108+
affected_version_range = None
109+
unaffected_version_range = None
110+
fixed_version = None
111+
112+
vulnerable_range = data.get("vulnerable_versions") or ""
113+
patched_range = data.get("patched_versions") or ""
114+
115+
# https://github.com/nodejs/security-wg/blob/cfaa51cc5c83f01eea61b69658f7bc76a77c5979/vuln/npm/213.json#L14
116+
if vulnerable_range == "<=99.999.99999":
117+
vulnerable_range = "*"
118+
if vulnerable_range:
119+
affected_version_range = NpmVersionRange.from_native(vulnerable_range)
120+
121+
# https://github.com/nodejs/security-wg/blob/cfaa51cc5c83f01eea61b69658f7bc76a77c5979/vuln/npm/213.json#L15
122+
if patched_range == "<0.0.0":
123+
patched_range = None
124+
if patched_range:
125+
unaffected_version_range = NpmVersionRange.from_native(patched_range)
126+
127+
# We only store single fixed versions and not a range of fixed versions
128+
# If there is a single constraint in the unaffected_version_range
129+
# having comparator as ">=" then we store that as the fixed version
130+
if unaffected_version_range and len(unaffected_version_range.constraints) == 1:
131+
constraint = unaffected_version_range.constraints[0]
132+
if constraint.comparator == ">=":
133+
fixed_version = constraint.version
134+
135+
return AffectedPackage(
136+
package=PackageURL(
137+
type="npm",
138+
name=package_name,
139+
),
140+
affected_version_range=affected_version_range,
141+
fixed_version=fixed_version,
142+
)

vulnerabilities/models.py

+4
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,10 @@ def url(self):
781781
if alias.startswith("GHSA"):
782782
return f"https://github.com/advisories/{alias}"
783783

784+
if alias.startswith("NPM-"):
785+
id = alias.lstrip("NPM-")
786+
return f"https://github.com/nodejs/security-wg/blob/main/vuln/npm/{id}.json"
787+
784788

785789
class Advisory(models.Model):
786790
"""

vulnerabilities/tests/conftest.py

-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ def no_rmtree(monkeypatch):
3535
"test_models.py",
3636
"test_mozilla.py",
3737
"test_msr2019.py",
38-
"test_npm.py",
3938
"test_package_managers.py",
4039
"test_retiredotnet.py",
4140
"test_ruby.py",

0 commit comments

Comments
 (0)