Skip to content

Commit 9352e62

Browse files
committed
Added safetydb datasource
Signed-off-by: Omkar Phansopkar <omkarphansopkar@gmail.com>
1 parent 045fedd commit 9352e62

7 files changed

Lines changed: 364 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ addopts = [
5959
"--ignore=vulnerabilities/importers/retiredotnet.py",
6060
"--ignore=vulnerabilities/importers/ruby.py",
6161
"--ignore=vulnerabilities/importers/rust.py",
62-
"--ignore=vulnerabilities/importers/safety_db.py",
6362
"--ignore=vulnerabilities/importers/suse_backports.py",
6463
"--ignore=vulnerabilities/importers/suse_scores.py",
6564
"--ignore=vulnerabilities/importers/ubuntu_usn.py",

vulntotal/datasources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
from vulntotal.datasources import gitlab
1313
from vulntotal.datasources import oss_index
1414
from vulntotal.datasources import osv
15+
from vulntotal.datasources import safetydb
1516
from vulntotal.datasources import snyk
1617
from vulntotal.datasources import vulnerablecode
1718
from vulntotal.validator import DataSource
1819

1920
DATASOURCE_REGISTRY = {
2021
"deps": deps.DepsDataSource,
2122
"github": github.GithubDataSource,
23+
"safetydb": safetydb.SafetydbDataSource,
2224
"gitlab": gitlab.GitlabDataSource,
2325
"oss_index": oss_index.OSSDataSource,
2426
"osv": osv.OSVDataSource,

vulntotal/datasources/safetydb.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/nexB/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import logging
11+
from typing import Iterable
12+
from typing import List
13+
14+
import requests
15+
from fetchcode.package_versions import versions
16+
from packageurl import PackageURL
17+
from univers.version_range import PypiVersionRange
18+
from univers.versions import PypiVersion
19+
20+
from vulntotal.validator import DataSource
21+
from vulntotal.validator import InvalidCVEError
22+
from vulntotal.validator import VendorData
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class SafetydbDataSource(DataSource):
28+
spdx_license_expression = "CC-BY-NC-4.0"
29+
license_url = "https://github.com/pyupio/safety-db/blob/master/LICENSE.txt"
30+
url = "https://raw.githubusercontent.com/pyupio/safety-db/master/data/insecure_full.json"
31+
32+
def fetch_advisory(self):
33+
"""
34+
Fetch entire JSON advisory from pyupio repository
35+
36+
Parameters:
37+
38+
Returns:
39+
A JSON object containing the advisory information for insecure packages, or None if an error occurs while fetching data from safetydb repo's URL.
40+
"""
41+
42+
response = requests.get(self.url)
43+
try:
44+
response.raise_for_status()
45+
except requests.exceptions.HTTPError as e:
46+
logger.error(f"Error while fetching safetydb advisories: {e}")
47+
return
48+
49+
return response.json()
50+
51+
def datasource_advisory(self, purl) -> Iterable[VendorData]:
52+
if purl.type != "pypi":
53+
return []
54+
advisory = self.fetch_advisory()
55+
self._raw_dump.append(advisory)
56+
self._versions = sorted([PypiVersion(ver.value) for ver in versions(str(purl))])
57+
return parse_advisory(advisory, purl, self._versions)
58+
59+
def datasource_advisory_from_cve(self, cve: str) -> Iterable[VendorData]:
60+
if not cve.upper().startswith("CVE-"):
61+
raise InvalidCVEError
62+
advisory = self.fetch_advisory()
63+
self._raw_dump.append(advisory)
64+
return parse_advisory_for_cve(advisory, cve)
65+
66+
@classmethod
67+
def supported_ecosystem(cls):
68+
# source - @TODO
69+
return {"pypi": "PyPI"}
70+
71+
72+
def parse_advisory(
73+
response, purl: PackageURL, all_versions: List[PypiVersion]
74+
) -> Iterable[VendorData]:
75+
"""
76+
Parse response from safetydb API and yield VendorData
77+
78+
Parameters:
79+
response: A JSON object containing the response data from the safetydb datasource.
80+
81+
Yields:
82+
VendorData instance containing the advisory information for the package.
83+
"""
84+
85+
for advisory in response.get(purl.name):
86+
vulnerable_version_range_string = "vers:pypi/" + advisory.get("v").replace(",", "|")
87+
vulnerable_version_range = PypiVersionRange.from_string(vulnerable_version_range_string)
88+
89+
yield VendorData(
90+
purl=PackageURL(purl.type, purl.namespace, purl.name),
91+
aliases=[advisory.get("cve"), advisory.get("id")],
92+
affected_versions=sorted(advisory.get("specs")),
93+
fixed_versions=get_patched_versions(all_versions, vulnerable_version_range),
94+
)
95+
96+
97+
def parse_advisory_for_cve(response, cve: str) -> Iterable[VendorData]:
98+
"""
99+
Parse response from safetydb API and yield VendorData with specified CVE
100+
101+
Parameters:
102+
response: A JSON object containing the response data from the safetydb datasource.
103+
104+
Yields:
105+
VendorData instance containing the advisory information for the package.
106+
"""
107+
108+
for package, advisories in response.items():
109+
if package == "$meta":
110+
continue
111+
112+
all_versions = sorted(
113+
[PypiVersion(ver.value) for ver in versions(str(PackageURL(type="pypi", name=package)))]
114+
)
115+
116+
for advisory in advisories:
117+
if advisory.get("cve") == cve:
118+
vulnerable_version_range_string = "vers:pypi/" + advisory.get("v").replace(",", "|")
119+
vulnerable_version_range = PypiVersionRange.from_string(
120+
vulnerable_version_range_string
121+
)
122+
123+
yield VendorData(
124+
purl=PackageURL(type="pypi", name=package),
125+
aliases=[advisory.get("cve"), advisory.get("id")],
126+
affected_versions=sorted(advisory.get("specs")),
127+
fixed_versions=get_patched_versions(all_versions, vulnerable_version_range),
128+
)
129+
130+
131+
def get_patched_versions(
132+
all_versions: List[PypiVersion],
133+
vulnerable_version_range: PypiVersionRange,
134+
):
135+
"""
136+
Get the first patched version from the list of all versions of a package
137+
138+
Parameters:
139+
all_versions: A list containing PackageVersion of a package
140+
vulnerable_version_range: A PypiVersionRange object specifying the vulnerable version range
141+
142+
Returns:
143+
A PackageVersion object containing the first patched version of the package
144+
"""
145+
146+
# last_patched = None
147+
# for version in reversed(all_versions):
148+
# if version in vulnerable_version_range:
149+
# if last_patched is not None:
150+
# return [str(last_patched.value)]
151+
# return []
152+
# last_patched = version
153+
# return []
154+
155+
patched_version_ranges: List[str] = []
156+
current_patched_range_start: PypiVersion = None
157+
current_patched_range_latest: PypiVersion = None
158+
159+
def resolve_patched_range():
160+
if current_patched_range_start is not None:
161+
if current_patched_range_latest == current_patched_range_start:
162+
patched_version_ranges.append(str(current_patched_range_start.value))
163+
else:
164+
patched_version_ranges.append(
165+
f">={current_patched_range_start.value},<={current_patched_range_latest.value}"
166+
)
167+
168+
for version in all_versions:
169+
if version in vulnerable_version_range:
170+
resolve_patched_range()
171+
current_patched_range_start = None
172+
current_patched_range_latest = None
173+
else:
174+
if current_patched_range_start is None:
175+
current_patched_range_start = version
176+
current_patched_range_latest = version
177+
resolve_patched_range()
178+
179+
# Remove upper bound from the last fixed range
180+
if len(patched_version_ranges) > 0:
181+
patched_version_ranges[-1] = patched_version_ranges[-1].split(",")[0]
182+
183+
# Ensure that >= is only present if there are fragmented fixed ranges
184+
# eg. For vulnerable spec "<2.2.5 >=2.3.0 <2.3.2",,fixed range => "2.2.5, >=2.3.2"
185+
# eg. For vulnerable spec "<2.2.5", fixed range => "2.2.5
186+
if len(patched_version_ranges) == 1:
187+
patched_version_ranges[-1] = patched_version_ranges[-1][2:]
188+
189+
return patched_version_ranges
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"$meta": {
3+
"advisory": "PyUp.io metadata",
4+
"base_domain": "https://pyup.io",
5+
"timestamp": 1714543250
6+
},
7+
"flask": [
8+
{
9+
"advisory": "flask version Before 0.12.3 contains a CWE-20: Improper Input Validation vulnerability in flask that can result in Large amount of memory usage possibly leading to denial of service. This attack appear to be exploitable via Attacker provides JSON data in incorrect encoding. This vulnerability appears to have been fixed in 0.12.3.",
10+
"cve": "CVE-2018-1000656",
11+
"id": "pyup.io-36388",
12+
"more_info_path": "/vulnerabilities/CVE-2018-1000656/36388",
13+
"specs": [
14+
"<0.12.3"
15+
],
16+
"v": "<0.12.3"
17+
},
18+
{
19+
"advisory": "Flask 0.12.3 includes a fix for CVE-2019-1010083: Unexpected memory usage. The impact is denial of service. The attack vector is crafted encoded JSON data. NOTE: this may overlap CVE-2018-1000656.\r\nhttps://github.com/pallets/flask/pull/2695/commits/0e1e9a04aaf29ab78f721cfc79ac2a691f6e3929",
20+
"cve": "CVE-2019-1010083",
21+
"id": "pyup.io-38654",
22+
"more_info_path": "/vulnerabilities/CVE-2019-1010083/38654",
23+
"specs": [
24+
"<0.12.3"
25+
],
26+
"v": "<0.12.3"
27+
},
28+
{
29+
"advisory": "flask 0.6.1 fixes a security problem that allowed clients to download arbitrary files if the host server was a windows based operating system and the client uses backslashes to escape the directory the files where exposed from.\r\nhttps://data.safetycli.com/vulnerabilities/PVE-2021-25820/25820/",
30+
"cve": "PVE-2021-25820",
31+
"id": "pyup.io-25820",
32+
"more_info_path": "/vulnerabilities/PVE-2021-25820/25820",
33+
"specs": [
34+
"<0.6.1"
35+
],
36+
"v": "<0.6.1"
37+
},
38+
{
39+
"advisory": "Flask 2.2.5 and 2.3.2 include a fix for CVE-2023-30861: When all of the following conditions are met, a response containing data intended for one client may be cached and subsequently sent by the proxy to other clients. If the proxy also caches 'Set-Cookie' headers, it may send one client's 'session' cookie to other clients. The severity depends on the application's use of the session and the proxy's behavior regarding cookies. The risk depends on all these conditions being met:\r\n1. The application must be hosted behind a caching proxy that does not strip cookies or ignore responses with cookies.\r\n2. The application sets 'session.permanent = True'\r\n3. The application does not access or modify the session at any point during a request.\r\n4. 'SESSION_REFRESH_EACH_REQUEST' enabled (the default).\r\n5. The application does not set a 'Cache-Control' header to indicate that a page is private or should not be cached.\r\nThis happens because vulnerable versions of Flask only set the 'Vary: Cookie' header when the session is accessed or modified, not when it is refreshed (re-sent to update the expiration) without being accessed or modified.\r\nhttps://github.com/pallets/flask/security/advisories/GHSA-m2qf-hxjv-5gpq",
40+
"cve": "CVE-2023-30861",
41+
"id": "pyup.io-55261",
42+
"more_info_path": "/vulnerabilities/CVE-2023-30861/55261",
43+
"specs": [
44+
"<2.2.5",
45+
">=2.3.0,<2.3.2"
46+
],
47+
"v": "<2.2.5,>=2.3.0,<2.3.2"
48+
}
49+
]
50+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[
2+
{
3+
"purl": "pkg:pypi/flask",
4+
"affected_versions": ["<0.12.3"],
5+
"fixed_versions": ["0.12.3"],
6+
"aliases": ["CVE-2018-1000656", "pyup.io-36388"]
7+
},
8+
{
9+
"purl": "pkg:pypi/flask",
10+
"affected_versions": ["<0.12.3"],
11+
"fixed_versions": ["0.12.3"],
12+
"aliases": ["CVE-2019-1010083", "pyup.io-38654"]
13+
},
14+
{
15+
"purl": "pkg:pypi/flask",
16+
"affected_versions": ["<0.6.1"],
17+
"fixed_versions": ["0.6.1"],
18+
"aliases": ["PVE-2021-25820", "pyup.io-25820"]
19+
},
20+
{
21+
"purl": "pkg:pypi/flask",
22+
"affected_versions": ["<2.2.5", ">=2.3.0,<2.3.2"],
23+
"fixed_versions": ["2.2.5", ">=2.3.2"],
24+
"aliases": ["CVE-2023-30861", "pyup.io-55261"]
25+
}
26+
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[
2+
{
3+
"purl": "pkg:pypi/flask",
4+
"affected_versions": ["<0.12.3"],
5+
"fixed_versions": ["0.12.3"],
6+
"aliases": ["CVE-2019-1010083", "pyup.io-38654"]
7+
}
8+
]

vulntotal/tests/test_safetydb.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/nexB/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import json
11+
from pathlib import Path
12+
13+
from commoncode import testcase
14+
from fetchcode.package_versions import versions
15+
from packageurl import PackageURL
16+
from univers.version_range import PypiVersionRange
17+
from univers.versions import PypiVersion
18+
19+
from vulnerabilities.tests import util_tests
20+
from vulntotal.datasources import safetydb
21+
22+
23+
class TestGithub(testcase.FileBasedTesting):
24+
test_data_dir = str(Path(__file__).resolve().parent / "test_data" / "safetydb")
25+
26+
def test_parse_advisory(self):
27+
purl = PackageURL.from_string("pkg:pypi/flask")
28+
advisory_file = self.get_test_loc("advisory.json")
29+
with open(advisory_file) as f:
30+
advisory = json.load(f)
31+
all_versions = sorted([PypiVersion(ver.value) for ver in versions(str(purl))])
32+
33+
results = [adv.to_dict() for adv in safetydb.parse_advisory(advisory, purl, all_versions)]
34+
expected_file = self.get_test_loc("parse_advisory-expected.json", must_exist=False)
35+
util_tests.check_results_against_json(results, expected_file)
36+
37+
def test_parse_advisory_for_cve(self):
38+
cve = "CVE-2019-1010083"
39+
advisory_file = self.get_test_loc("advisory.json")
40+
with open(advisory_file) as f:
41+
advisory = json.load(f)
42+
43+
results = [adv.to_dict() for adv in safetydb.parse_advisory_for_cve(advisory, cve)]
44+
expected_file = self.get_test_loc("parse_advisory_cve-expected.json", must_exist=False)
45+
util_tests.check_results_against_json(results, expected_file)
46+
47+
def test_get_patched_versions(self):
48+
# Ref - flask package
49+
all_versions = [
50+
PypiVersion(ver)
51+
for ver in [
52+
"0.12.2",
53+
"0.12.3",
54+
"0.12.4",
55+
"0.12.5",
56+
"1.0",
57+
"2.2.4",
58+
"2.2.5",
59+
"2.3.0",
60+
"2.3.1",
61+
"2.3.2",
62+
"2.3.3",
63+
"3.0.0",
64+
"3.0.1",
65+
"3.0.2",
66+
"3.0.3",
67+
]
68+
]
69+
70+
test_cases = [
71+
{
72+
"vulnerable_version_range": PypiVersionRange.from_string("vers:pypi/<0.12.3"),
73+
"expected_patched_version_ranges": ["0.12.3"],
74+
},
75+
{
76+
"vulnerable_version_range": PypiVersionRange.from_string(
77+
"vers:pypi/<2.2.5|>=2.3.0|<2.3.2"
78+
),
79+
"expected_patched_version_ranges": ["2.2.5", ">=2.3.2"],
80+
},
81+
]
82+
83+
for test_case in test_cases:
84+
results = safetydb.get_patched_versions(
85+
all_versions, test_case["vulnerable_version_range"]
86+
)
87+
util_tests.check_results_against_expected(
88+
results, test_case["expected_patched_version_ranges"]
89+
)

0 commit comments

Comments
 (0)