Skip to content

Commit 09fe9b3

Browse files
facutuescadanielleadams
authored andcommitted
tools: add script for vulnerability checking
This change adds a new script that queries vulnerability databases in order to find if any of Node's dependencies is vulnerable. The `deps/` directory of Node's repo is scanned to gather the currently used version of each dependency, and if any vulnerability is found for that version a message is printed out with its ID and a link to a description of the issue. Refs: nodejs/security-wg#802 PR-URL: #43362 Reviewed-By: Michael Dawson <midawson@redhat.com> Reviewed-By: Vladimir de Turckheim <vlad2t@hotmail.com> Reviewed-By: Richard Lau <rlau@redhat.com> Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
1 parent 9b5b8d7 commit 09fe9b3

File tree

5 files changed

+499
-0
lines changed

5 files changed

+499
-0
lines changed

tools/dep_checker/README.md

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Node.js dependency vulnerability checker
2+
3+
This script queries the [National Vulnerability Database (NVD)](https://nvd.nist.gov/) and
4+
the [GitHub Advisory Database](https://github.com/advisories) for vulnerabilities found
5+
in Node's dependencies.
6+
7+
## How to use
8+
9+
In order to query the GitHub Advisory Database,
10+
a [Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token)
11+
has to be created (no permissions need to be given to the token, since it's only used to query the public database).
12+
Once acquired, the script can be run as follows:
13+
14+
```shell
15+
cd node/tools/dep_checker/
16+
pip install -r requirements.txt
17+
18+
# Python >= 3.9 required
19+
python main.py --gh-token=$PERSONAL_ACCESS_TOKEN
20+
21+
# or to skip querying the GitHub Advisory Database, simply run:
22+
python main.py
23+
```
24+
25+
## Example output
26+
27+
```
28+
WARNING: New vulnerabilities found
29+
- npm (version 1.2.1) :
30+
- GHSA-v3jv-wrf4-5845: https://github.com/advisories/GHSA-v3jv-wrf4-5845
31+
- GHSA-93f3-23rq-pjfp: https://github.com/advisories/GHSA-93f3-23rq-pjfp
32+
- GHSA-m6cx-g6qm-p2cx: https://github.com/advisories/GHSA-m6cx-g6qm-p2cx
33+
- GHSA-4328-8hgf-7wjr: https://github.com/advisories/GHSA-4328-8hgf-7wjr
34+
- GHSA-x8qc-rrcw-4r46: https://github.com/advisories/GHSA-x8qc-rrcw-4r46
35+
- GHSA-m5h6-hr3q-22h5: https://github.com/advisories/GHSA-m5h6-hr3q-22h5
36+
- acorn (version 6.0.0) :
37+
- GHSA-6chw-6frg-f759: https://github.com/advisories/GHSA-6chw-6frg-f759
38+
39+
For each dependency and vulnerability, check the following:
40+
- Check the vulnerability's description to see if it applies to the dependency as
41+
used by Node. If not, the vulnerability ID (either a CVE or a GHSA) can be added to the ignore list in
42+
dependencies.py. IMPORTANT: Only do this if certain that the vulnerability found is a false positive.
43+
- Otherwise, the vulnerability found must be remediated by updating the dependency in the Node repo to a
44+
non-affected version.
45+
```
46+
47+
## Implementation details
48+
49+
- For each dependency in Node's `deps/` folder, the script parses their version number and queries the databases to find
50+
vulnerabilities for that specific version.
51+
- The queries can return false positives (
52+
see [this](https://github.com/nodejs/security-wg/issues/802#issuecomment-1144207417) comment for an example). These
53+
can be ignored by adding the vulnerability to the `ignore_list` in `dependencies.py`
54+
- The script takes a while to finish (~2 min) because queries to the NVD
55+
are [rate-limited](https://nvd.nist.gov/developers)
56+
- If any vulnerabilities are found, the script returns 1 and prints out a list with the ID and a link to a description
57+
of
58+
the vulnerability. This is the case except when the ID matches one in the ignore-list (inside `dependencies.py`) in
59+
which case the vulnerability is ignored.
60+
61+
62+

tools/dep_checker/dependencies.py

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""A list of dependencies, including their CPE, names and keywords for querying different vulnerability databases"""
2+
3+
from typing import Optional
4+
import versions_parser as vp
5+
6+
7+
class CPE:
8+
def __init__(self, vendor: str, product: str):
9+
self.vendor = vendor
10+
self.product = product
11+
12+
13+
class Dependency:
14+
def __init__(
15+
self,
16+
version: str,
17+
cpe: Optional[CPE] = None,
18+
npm_name: Optional[str] = None,
19+
keyword: Optional[str] = None,
20+
):
21+
self.version = version
22+
self.cpe = cpe
23+
self.npm_name = npm_name
24+
self.keyword = keyword
25+
26+
def get_cpe(self) -> Optional[str]:
27+
if self.cpe:
28+
return f"cpe:2.3:a:{self.cpe.vendor}:{self.cpe.product}:{self.version}:*:*:*:*:*:*:*"
29+
else:
30+
return None
31+
32+
33+
ignore_list: list[str] = [
34+
"CVE-2018-25032", # zlib, already fixed in the fork Node uses (Chromium's)
35+
"CVE-2007-5536", # openssl, old and only in combination with HP-UX
36+
"CVE-2019-0190", # openssl, can be only triggered in combination with Apache HTTP Server version 2.4.37
37+
]
38+
39+
dependencies: dict[str, Dependency] = {
40+
"zlib": Dependency(
41+
version=vp.get_zlib_version(), cpe=CPE(vendor="zlib", product="zlib")
42+
),
43+
# TODO: Add V8
44+
# "V8": Dependency("cpe:2.3:a:google:chrome:*:*:*:*:*:*:*:*", "v8"),
45+
"uvwasi": Dependency(version=vp.get_uvwasi_version(), cpe=None, keyword="uvwasi"),
46+
"libuv": Dependency(
47+
version=vp.get_libuv_version(), cpe=CPE(vendor="libuv_project", product="libuv")
48+
),
49+
"undici": Dependency(
50+
version=vp.get_undici_version(), cpe=None, keyword="undici", npm_name="undici"
51+
),
52+
"OpenSSL": Dependency(
53+
version=vp.get_openssl_version(), cpe=CPE(vendor="openssl", product="openssl")
54+
),
55+
"npm": Dependency(
56+
version=vp.get_npm_version(),
57+
cpe=CPE(vendor="npmjs", product="npm"),
58+
npm_name="npm",
59+
),
60+
"nghttp3": Dependency(
61+
version=vp.get_nghttp3_version(), cpe=None, keyword="nghttp3"
62+
),
63+
"ngtcp2": Dependency(version=vp.get_ngtcp2_version(), cpe=None, keyword="ngtcp2"),
64+
"nghttp2": Dependency(
65+
version=vp.get_nghttp2_version(), cpe=CPE(vendor="nghttp2", product="nghttp2")
66+
),
67+
"llhttp": Dependency(
68+
version=vp.get_llhttp_version(),
69+
cpe=CPE(vendor="llhttp", product="llhttp"),
70+
npm_name="llhttp",
71+
),
72+
"ICU": Dependency(
73+
version=vp.get_icu_version(),
74+
cpe=CPE(vendor="icu-project", product="international_components_for_unicode"),
75+
),
76+
"HdrHistogram": Dependency(version="0.11.2", cpe=None, keyword="hdrhistogram"),
77+
"corepack": Dependency(
78+
version=vp.get_corepack_version(),
79+
cpe=None,
80+
keyword="corepack",
81+
npm_name="corepack",
82+
),
83+
"CJS Module Lexer": Dependency(
84+
version=vp.get_cjs_lexer_version(),
85+
cpe=None,
86+
keyword="cjs-module-lexer",
87+
npm_name="cjs-module-lexer",
88+
),
89+
"c-ares": Dependency(
90+
version=vp.get_c_ares_version(),
91+
cpe=CPE(vendor="c-ares_project", product="c-ares"),
92+
),
93+
"brotli": Dependency(
94+
version=vp.get_brotli_version(), cpe=CPE(vendor="google", product="brotli")
95+
),
96+
"acorn": Dependency(version=vp.get_acorn_version(), cpe=None, npm_name="acorn"),
97+
}

tools/dep_checker/main.py

+168
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
""" Node.js dependency vulnerability checker
2+
3+
This script queries the National Vulnerability Database (NVD) and the GitHub Advisory Database for vulnerabilities found
4+
in Node's dependencies.
5+
6+
For each dependency in Node's `deps/` folder, the script parses their version number and queries the databases to find
7+
vulnerabilities for that specific version.
8+
9+
If any vulnerabilities are found, the script returns 1 and prints out a list with the ID and a link to a description of
10+
the vulnerability. This is the case except when the ID matches one in the ignore-list (inside `dependencies.py`) in
11+
which case the vulnerability is ignored.
12+
"""
13+
14+
from argparse import ArgumentParser
15+
from collections import defaultdict
16+
from dependencies import ignore_list, dependencies
17+
from gql import gql, Client
18+
from gql.transport.aiohttp import AIOHTTPTransport
19+
from nvdlib import searchCVE # type: ignore
20+
from packaging.specifiers import SpecifierSet
21+
22+
23+
class Vulnerability:
24+
def __init__(self, id: str, url: str):
25+
self.id = id
26+
self.url = url
27+
28+
29+
vulnerability_found_message = """For each dependency and vulnerability, check the following:
30+
- Check that the dependency's version printed by the script corresponds to the version present in the Node repo.
31+
If not, update dependencies.py with the actual version number and run the script again.
32+
- If the version is correct, check the vulnerability's description to see if it applies to the dependency as
33+
used by Node. If not, the vulnerability ID (either a CVE or a GHSA) can be added to the ignore list in
34+
dependencies.py. IMPORTANT: Only do this if certain that the vulnerability found is a false positive.
35+
- Otherwise, the vulnerability found must be remediated by updating the dependency in the Node repo to a
36+
non-affected version, followed by updating dependencies.py with the new version.
37+
"""
38+
39+
40+
github_vulnerabilities_query = gql(
41+
"""
42+
query($package_name:String!) {
43+
securityVulnerabilities(package:$package_name, last:10) {
44+
nodes {
45+
vulnerableVersionRange
46+
advisory {
47+
ghsaId
48+
permalink
49+
withdrawnAt
50+
}
51+
}
52+
}
53+
}
54+
"""
55+
)
56+
57+
58+
def query_ghad(gh_token: str) -> dict[str, list[Vulnerability]]:
59+
"""Queries the GitHub Advisory Database for vulnerabilities reported for Node's dependencies.
60+
61+
The database supports querying by package name in the NPM ecosystem, so we only send queries for the dependencies
62+
that are also NPM packages.
63+
"""
64+
65+
deps_in_npm = {
66+
name: dep for name, dep in dependencies.items() if dep.npm_name is not None
67+
}
68+
69+
transport = AIOHTTPTransport(
70+
url="https://api.github.com/graphql",
71+
headers={"Authorization": f"bearer {gh_token}"},
72+
)
73+
client = Client(
74+
transport=transport,
75+
fetch_schema_from_transport=True,
76+
serialize_variables=True,
77+
parse_results=True,
78+
)
79+
80+
found_vulnerabilities: dict[str, list[Vulnerability]] = defaultdict(list)
81+
for name, dep in deps_in_npm.items():
82+
variables_package = {
83+
"package_name": dep.npm_name,
84+
}
85+
result = client.execute(
86+
github_vulnerabilities_query, variable_values=variables_package
87+
)
88+
matching_vulns = [
89+
v
90+
for v in result["securityVulnerabilities"]["nodes"]
91+
if v["advisory"]["withdrawnAt"] is None
92+
and dep.version in SpecifierSet(v["vulnerableVersionRange"])
93+
and v["advisory"]["ghsaId"] not in ignore_list
94+
]
95+
if matching_vulns:
96+
found_vulnerabilities[name].extend(
97+
[
98+
Vulnerability(
99+
id=vuln["advisory"]["ghsaId"], url=vuln["advisory"]["permalink"]
100+
)
101+
for vuln in matching_vulns
102+
]
103+
)
104+
105+
return found_vulnerabilities
106+
107+
108+
def query_nvd() -> dict[str, list[Vulnerability]]:
109+
"""Queries the National Vulnerability Database for vulnerabilities reported for Node's dependencies.
110+
111+
The database supports querying by CPE (Common Platform Enumeration) or by a keyword present in the CVE's
112+
description.
113+
Since some of Node's dependencies don't have an associated CPE, we use their name as a keyword in the query.
114+
"""
115+
deps_in_nvd = {
116+
name: dep
117+
for name, dep in dependencies.items()
118+
if dep.cpe is not None or dep.keyword is not None
119+
}
120+
found_vulnerabilities: dict[str, list[Vulnerability]] = defaultdict(list)
121+
for name, dep in deps_in_nvd.items():
122+
query_results = [
123+
cve
124+
for cve in searchCVE(cpeMatchString=dep.get_cpe(), keyword=dep.keyword)
125+
if cve.id not in ignore_list
126+
]
127+
if query_results:
128+
found_vulnerabilities[name].extend(
129+
[Vulnerability(id=cve.id, url=cve.url) for cve in query_results]
130+
)
131+
132+
return found_vulnerabilities
133+
134+
135+
def main():
136+
parser = ArgumentParser(
137+
description="Query the NVD and the GitHub Advisory Database for new vulnerabilities in Node's dependencies"
138+
)
139+
parser.add_argument(
140+
"--gh-token",
141+
help="the GitHub authentication token for querying the GH Advisory Database",
142+
)
143+
gh_token = parser.parse_args().gh_token
144+
if gh_token is None:
145+
print(
146+
"Warning: GitHub authentication token not provided, skipping GitHub Advisory Database queries"
147+
)
148+
ghad_vulnerabilities: dict[str, list[Vulnerability]] = (
149+
{} if gh_token is None else query_ghad(gh_token)
150+
)
151+
nvd_vulnerabilities = query_nvd()
152+
153+
if not ghad_vulnerabilities and not nvd_vulnerabilities:
154+
print(f"No new vulnerabilities found ({len(ignore_list)} ignored)")
155+
return 0
156+
else:
157+
print("WARNING: New vulnerabilities found")
158+
for source in (ghad_vulnerabilities, nvd_vulnerabilities):
159+
for name, vulns in source.items():
160+
print(f"- {name} (version {dependencies[name].version}) :")
161+
for v in vulns:
162+
print(f"\t- {v.id}: {v.url}")
163+
print(f"\n{vulnerability_found_message}")
164+
return 1
165+
166+
167+
if __name__ == "__main__":
168+
exit(main())

tools/dep_checker/requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
gql[aiohttp]
2+
nvdlib
3+
packaging

0 commit comments

Comments
 (0)