Skip to content

Commit cf63af2

Browse files
committed
add Snyk DataSource
- closes #834 Signed-off-by: Keshav Priyadarshi <git@keshav.space>
1 parent 78dd5ae commit cf63af2

3 files changed

Lines changed: 317 additions & 1 deletion

File tree

vulntotal/datasources/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
2323

2424

25-
DATASOURCE_REGISTRY = []
25+
from vulntotal.datasources import snyk
26+
27+
DATASOURCE_REGISTRY = [
28+
snyk.SnykDataSource,
29+
]
2630

2731
DATASOURCE_REGISTRY = {x.__module__.split(".")[-1]: x for x in DATASOURCE_REGISTRY}

vulntotal/datasources/snyk.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# http://nexb.com and https://github.com/nexB/vulnerablecode/
4+
# The VulnTotal software is licensed under the Apache License version 2.0.
5+
# Data generated with VulnTotal require an acknowledgment.
6+
#
7+
# You may not use this software except in compliance with the License.
8+
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
9+
# Unless required by applicable law or agreed to in writing, software distributed
10+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
11+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
12+
# specific language governing permissions and limitations under the License.
13+
#
14+
# When you publish or redistribute any data created with VulnTotal or any VulnTotal
15+
# derivative work, you must accompany this data with the following acknowledgment:
16+
#
17+
# Generated with VulnTotal and provided on an "AS IS" BASIS, WITHOUT WARRANTIES
18+
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
19+
# VulnTotal should be considered or used as legal advice. Consult an Attorney
20+
# for any legal advice.
21+
# VulnTotal is a free software tool from nexB Inc. and others.
22+
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
23+
24+
import json
25+
import logging
26+
from typing import Iterable
27+
from urllib.parse import quote
28+
29+
import requests
30+
from bs4 import BeautifulSoup
31+
from packageurl import PackageURL
32+
33+
from vulntotal.validator import DataSource
34+
from vulntotal.validator import VendorData
35+
from vulntotal.vulntotal_utils import snky_constraints_satisfied
36+
37+
logger = logging.getLogger(__name__)
38+
39+
40+
class SnykDataSource(DataSource):
41+
spdx_license_expression = "TODO"
42+
license_url = "TODO"
43+
44+
def fetch(self, url):
45+
response = requests.get(url)
46+
if not response.status_code == 200:
47+
logger.error(f"Error while fetching {url}")
48+
return
49+
if response.headers["content-type"] == "application/json, charset=utf-8":
50+
return response.json()
51+
return response.text
52+
53+
def datasource_advisory(self, purl) -> Iterable[VendorData]:
54+
package_advisory_url = generate_package_advisory_url(purl)
55+
package_advisories_list = self.fetch(package_advisory_url)
56+
self._raw_dump.append(package_advisories_list)
57+
if package_advisories_list:
58+
advisories = extract_html_json_advisories(package_advisories_list)
59+
for snyk_id, affected in advisories.items():
60+
if "*" in affected or is_purl_in_affected(purl.version, affected):
61+
advisory_payload = generate_advisory_payload(snyk_id)
62+
advisory_html = self.fetch(advisory_payload)
63+
self._raw_dump.append(advisory_html)
64+
if advisory_html:
65+
yield parse_html_advisory(advisory_html, snyk_id, affected)
66+
67+
@classmethod
68+
def supported_ecosystem(cls):
69+
return {
70+
"cocoapods": "cocoapods",
71+
"composer": "composer",
72+
"golang": "golang",
73+
"hex": "hex",
74+
"linux": "linux",
75+
"maven": "maven",
76+
"npm": "npm",
77+
"nuget": "nuget",
78+
"pypi": "pip",
79+
"rubygems": "rubygems",
80+
# any purl.type not in supported_ecosystem shall implicitly be treated as unmanaged type
81+
"unmanaged": "unmanaged",
82+
}
83+
84+
85+
def generate_package_advisory_url(purl):
86+
url_package_advisories = "https://security.snyk.io/package/{ecosystem}/{package}"
87+
88+
# Pseudo API, unfortunately gives only 30 vulnerability per package, but this is the best we have for unmanaged packages
89+
url_unmanaged_package_advisories = (
90+
"https://security.snyk.io/api/listing?search={package}&type=unmanaged"
91+
)
92+
supported_ecosystem = SnykDataSource.supported_ecosystem()
93+
94+
if purl.type == "unmanaged" or purl.type not in supported_ecosystem:
95+
return url_unmanaged_package_advisories.format(
96+
package=purl.name if not purl.namespace else f"{purl.namespace}/{purl.name}",
97+
)
98+
99+
purl_name = purl.name
100+
if purl.type == "maven":
101+
if not purl.namespace:
102+
logger.error(f"Invalid Maven PURL {str(purl)}")
103+
return
104+
purl_name = quote(f"{purl.namespace}:{purl.name}", safe="")
105+
106+
elif purl.type in ("golang", "composer"):
107+
if purl.namespace:
108+
purl_name = quote(f"{purl.namespace}/{purl.name}", safe="")
109+
110+
elif purl.type == "linux":
111+
distro = purl.qualifiers["distro"]
112+
purl_name = f"{distro}/{purl.name}"
113+
114+
return url_package_advisories.format(
115+
ecosystem=supported_ecosystem[purl.type],
116+
package=purl_name,
117+
)
118+
119+
120+
def extract_html_json_advisories(package_advisories):
121+
vulnerablity = {}
122+
123+
# If advisories are json and is obtained through pseudo API
124+
if isinstance(package_advisories, dict):
125+
if package_advisories["status"] == "ok":
126+
for vuln in package_advisories["vulnerabilities"]:
127+
vulnerablity[vuln["id"]] = vuln["semver"]["vulnerable"]
128+
else:
129+
soup = BeautifulSoup(package_advisories, "html.parser")
130+
vulns_table = soup.find("tbody", class_="vue--table__tbody")
131+
if vulns_table:
132+
vulns_rows = vulns_table.find_all("tr", class_="vue--table__row")
133+
for row in vulns_rows:
134+
anchor = row.find(class_="vue--anchor")
135+
ranges = row.find_all(
136+
"span", class_="vue--chip vulnerable-versions__chip vue--chip--default"
137+
)
138+
affected_versions = [vers.text.strip() for vers in ranges]
139+
vulnerablity[anchor["href"].rsplit("/", 1)[-1]] = affected_versions
140+
return vulnerablity
141+
142+
143+
def parse_html_advisory(advisory_html, snyk_id, affected) -> VendorData:
144+
aliases = []
145+
fixed_versions = []
146+
147+
advisory_soup = BeautifulSoup(advisory_html, "html.parser")
148+
cve_span = advisory_soup.find("span", class_="cve")
149+
if cve_span:
150+
cve_anchor = cve_span.find("a", class_="vue--anchor")
151+
aliases.append(cve_anchor["id"])
152+
153+
how_to_fix = advisory_soup.find(
154+
"div", class_="vue--block vuln-page__instruction-block vue--block--instruction"
155+
)
156+
if how_to_fix:
157+
fixed = how_to_fix.find("p").text.split(" ")
158+
if "Upgrade" in fixed:
159+
lower = fixed.index("version") if "version" in fixed else fixed.index("versions")
160+
upper = fixed.index("or")
161+
fixed_versions = "".join(fixed[lower + 1 : upper]).split(",")
162+
aliases.append(snyk_id)
163+
return VendorData(
164+
aliases=aliases,
165+
affected_versions=affected,
166+
fixed_versions=fixed_versions,
167+
)
168+
169+
170+
def is_purl_in_affected(version, affected):
171+
for affected_range in affected:
172+
if snky_constraints_satisfied(affected_range, version):
173+
return True
174+
return False
175+
176+
177+
def generate_advisory_payload(snyk_id):
178+
return f"https://security.snyk.io/vuln/{snyk_id}"

vulntotal/vulntotal_utils.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# http://nexb.com and https://github.com/nexB/vulnerablecode/
4+
# The VulnTotal software is licensed under the Apache License version 2.0.
5+
# Data generated with VulnTotal require an acknowledgment.
6+
#
7+
# You may not use this software except in compliance with the License.
8+
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
9+
# Unless required by applicable law or agreed to in writing, software distributed
10+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
11+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
12+
# specific language governing permissions and limitations under the License.
13+
#
14+
# When you publish or redistribute any data created with VulnTotal or any VulnTotal
15+
# derivative work, you must accompany this data with the following acknowledgment:
16+
#
17+
# Generated with VulnTotal and provided on an "AS IS" BASIS, WITHOUT WARRANTIES
18+
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
19+
# VulnTotal should be considered or used as legal advice. Consult an Attorney
20+
# for any legal advice.
21+
# VulnTotal is a free software tool from nexB Inc. and others.
22+
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
23+
24+
import operator
25+
26+
27+
class GenericVersion:
28+
def __init__(self, version):
29+
self.value = version.replace(" ", "").lstrip("v")
30+
31+
self.decomposed = tuple(
32+
[int(com) if com.isnumeric() else com for com in self.value.split(".")]
33+
)
34+
35+
def __str__(self):
36+
return str(self.value)
37+
38+
def __eq__(self, other):
39+
if not isinstance(other, self.__class__):
40+
return NotImplemented
41+
return self.value.__eq__(other.value)
42+
43+
def __lt__(self, other):
44+
if not isinstance(other, self.__class__):
45+
return NotImplemented
46+
for i, j in zip(self.decomposed, other.decomposed):
47+
if not isinstance(i, type(j)):
48+
continue
49+
if i.__gt__(j):
50+
return False
51+
return True
52+
53+
def __le__(self, other):
54+
if not isinstance(other, self.__class__):
55+
return NotImplemented
56+
return self.__lt__(other) or self.__eq__(other)
57+
58+
59+
def compare(version, package_comparator, package_version):
60+
operator_comparator = {
61+
"<": operator.lt,
62+
">": operator.gt,
63+
"=": operator.eq,
64+
"<=": operator.le,
65+
">=": operator.ge,
66+
"==": operator.eq,
67+
"!=": operator.ne,
68+
")": operator.lt,
69+
"]": operator.le,
70+
"(": operator.gt,
71+
"[": operator.ge,
72+
}
73+
compare = operator_comparator[package_comparator]
74+
return compare(version, package_version)
75+
76+
77+
def parse_constraint(constraint):
78+
if constraint.startswith(("<=", ">=", "==", "!=")):
79+
return constraint[:2], constraint[2:]
80+
81+
if constraint.startswith(("<", ">", "=", "[", "]", "(", ")")):
82+
return constraint[0], constraint[1:]
83+
84+
if constraint.endswith(("[", "]", "(", ")")):
85+
return constraint[-1], constraint[:-1]
86+
87+
88+
def github_constraints_satisfied(github_constrain, version):
89+
gh_constraints = github_constrain.strip().replace(" ", "")
90+
constraints = gh_constraints.split(",")
91+
for constraint in constraints:
92+
gh_comparator, gh_version = parse_constraint(constraint)
93+
if not gh_version:
94+
continue
95+
# TODO: Replace the GenericVersion with ecosystem specific from univers
96+
if not compare(GenericVersion(version), gh_comparator, GenericVersion(gh_version)):
97+
return False
98+
return True
99+
100+
101+
def snky_constraints_satisfied(snyk_constrain, version):
102+
snyk_constraints = snyk_constrain.strip().replace(" ", "")
103+
constraints = snyk_constraints.split(",")
104+
for constraint in constraints:
105+
snyk_comparator, snyk_version = parse_constraint(constraint)
106+
if not snyk_version:
107+
continue
108+
# TODO: Replace the GenericVersion with ecosystem specific from univers or maybe not if snyk is normalizing versions to semver
109+
if not compare(GenericVersion(version), snyk_comparator, GenericVersion(snyk_version)):
110+
return False
111+
return True
112+
113+
114+
def gitlab_constraints_satisfied(gitlab_constrain, version):
115+
gitlab_constraints = gitlab_constrain.strip()
116+
constraints = gitlab_constraints.split("||")
117+
118+
for constraint in constraints:
119+
is_constraint_satisfied = True
120+
121+
for subcontraint in constraint.strip().split(" "):
122+
123+
gitlab_comparator, gitlab_version = parse_constraint(subcontraint.strip())
124+
if not gitlab_version:
125+
continue
126+
# TODO: Replace the GenericVersion with ecosystem specific from univers
127+
if not compare(
128+
GenericVersion(version), gitlab_comparator, GenericVersion(gitlab_version)
129+
):
130+
is_constraint_satisfied = False
131+
break
132+
133+
if is_constraint_satisfied:
134+
return True

0 commit comments

Comments
 (0)