Skip to content

Commit 0d73e90

Browse files
committed
Migrate npm importer
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 6bad931 commit 0d73e90

2 files changed

Lines changed: 77 additions & 171 deletions

File tree

vulnerabilities/importers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from vulnerabilities.importers import github
1313
from vulnerabilities.importers import gitlab
1414
from vulnerabilities.importers import nginx
15+
from vulnerabilities.importers import npm
1516
from vulnerabilities.importers import nvd
1617
from vulnerabilities.importers import openssl
1718
from vulnerabilities.importers import pypa
@@ -29,6 +30,7 @@
2930
debian.DebianImporter,
3031
gitlab.GitLabAPIImporter,
3132
pypa.PyPaImporter,
33+
npm.NpmImporter,
3234
]
3335

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

vulnerabilities/importers/npm.py

Lines changed: 75 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -9,190 +9,94 @@
99

1010
# Author: Navonil Das (@NavonilDas)
1111

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

1815
import pytz
1916
from dateutil.parser import parse
2017
from packageurl import PackageURL
21-
from univers.version_range import VersionRange
22-
from univers.versions import SemverVersion
18+
from univers.version_range import NpmVersionRange
2319

2420
from vulnerabilities.importer import AdvisoryData
21+
from vulnerabilities.importer import AffectedPackage
2522
from vulnerabilities.importer import GitImporter
2623
from vulnerabilities.importer import Reference
27-
from vulnerabilities.package_managers import NpmVersionAPI
24+
from vulnerabilities.importer import VulnerabilitySeverity
25+
from vulnerabilities.severity_systems import CVSSV2
26+
from vulnerabilities.severity_systems import CVSSV3
27+
from vulnerabilities.utils import build_description
2828
from vulnerabilities.utils import load_json
29-
from vulnerabilities.utils import nearest_patched_package
30-
31-
NPM_URL = "https://registry.npmjs.org{}"
3229

3330

3431
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+
spdx_license_expression = "MIT"
33+
license_url = "https://github.com/nodejs/security-wg/blob/main/LICENSE.md"
34+
35+
def __init__(self):
36+
super().__init__(repo_url="git+https://github.com/nodejs/security-wg")
37+
38+
def advisory_data(self) -> Iterable[AdvisoryData]:
39+
try:
40+
self.clone()
41+
path = Path(self.vcs_response.dest_dir)
42+
43+
vuln = path / "vuln"
44+
npm_vulns = vuln / "npm"
45+
for file in npm_vulns.glob("*.json"):
46+
yield from self.to_advisory_data(file)
47+
finally:
48+
if self.vcs_response:
49+
self.vcs_response.delete()
50+
51+
def to_advisory_data(self, file: Path) -> AdvisoryData:
52+
data = load_json(file)
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.startswith("CVSS:3.0/"):
61+
severities.append(
62+
VulnerabilitySeverity(
63+
system=CVSSV3,
64+
score=cvss_score,
65+
)
4066
)
41-
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
92-
)
93-
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"],
67+
if cvss_vector.startswith("CVSS:2.0/"):
68+
severities.append(
69+
VulnerabilitySeverity(
70+
system=CVSSV2,
71+
score=cvss_score,
72+
)
10073
)
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,
74+
for ref in data.get("references", []):
75+
references.append(
76+
Reference(
77+
url=ref,
78+
severities=severities,
11079
)
11180
)
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
81+
package_name = data.get("module_name")
82+
affected_packages = []
83+
if package_name:
84+
vulnerable_range = data.get("vulnerable_versions")
85+
affected_packages.append(
86+
AffectedPackage(
87+
package_url=PackageURL(
88+
type="npm",
89+
name=package_name,
90+
),
91+
affected_version_range=NpmVersionRange.from_native(vulnerable_range),
92+
# fixed_version= ??
93+
)
94+
)
95+
for alias in data.get("cves", []):
96+
yield AdvisoryData(
97+
summary=build_description(summary=summary, description=description),
98+
references=references,
99+
date_published=date_published,
100+
affected_packages=affected_packages,
101+
aliases=[alias],
102+
)

0 commit comments

Comments
 (0)