Skip to content

Commit 94b5e74

Browse files
authored
Merge pull request #204 from sbs2001/github_api_importer
Add GitHub API importer
2 parents 478b7e8 + fe442bf commit 94b5e74

9 files changed

Lines changed: 9147 additions & 0 deletions

File tree

.travis.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ install:
88
- pip install -r requirements.txt
99

1010
env:
11+
global:
1112
- SECRET_KEY="i1bn=oly)w*2yl-5yc&f!vvgt)p)fh3_2$r#spa!*sw36f5ov7"
13+
- GH_TOKEN="dummygithubtoken"
1214

1315
before_script:
1416
- pycodestyle --exclude=migrations,settings.py,venv,lib_oval.py,test_ubuntu.py,test_suse.py,test_data_source.py --max-line-length=100 .

vulnerabilities/importer_yielder.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@
157157
'etags': {},
158158
'db_url': 'https://usn.ubuntu.com/usn-db/database-all.json.bz2'
159159
},
160+
},
161+
162+
{
163+
'name': 'github',
164+
'license': '',
165+
'last_run': None,
166+
'data_source': 'GitHubAPIDataSource',
167+
'data_source_cfg': {
168+
'endpoint': 'https://api.github.com/graphql',
169+
'ecosystems': ['MAVEN', 'NUGET', 'COMPOSER']
170+
}
160171
}
161172

162173
]

vulnerabilities/importers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@
3636
from vulnerabilities.importers.gentoo import GentooDataSource
3737
from vulnerabilities.importers.openssl import OpenSSLDataSource
3838
from vulnerabilities.importers.ubuntu_usn import UbuntuUSNDataSource
39+
from vulnerabilities.importers.github import GitHubAPIDataSource
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
# Copyright (c) nexB Inc. and others. All rights reserved.
2+
# http://nexb.com and https://github.com/nexB/vulnerablecode/
3+
# The VulnerableCode software is licensed under the Apache License version 2.0.
4+
# Data generated with VulnerableCode require an acknowledgment.
5+
#
6+
# You may not use this software except in compliance with the License.
7+
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
8+
# Unless required by applicable law or agreed to in writing, software distributed
9+
# under the License is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR
10+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
11+
# specific language governing permissions and limitations under the License.
12+
#
13+
# When you publish or redistribute any data created with VulnerableCode or any VulnerableCode
14+
# derivative work, you must accompany this data with the following acknowledgment:
15+
#
16+
# Generated with VulnerableCode and provided on an 'AS IS' BASIS, WITHOUT WARRANTIES
17+
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
18+
# VulnerableCode should be considered or used as legal advice. Consult an Attorney
19+
# for any legal advice.
20+
# VulnerableCode is a free software code scanning tool from nexB Inc. and others.
21+
# Visit https://github.com/nexB/vulnerablecode/ for support and download.
22+
23+
24+
import os
25+
import dataclasses
26+
import json
27+
from typing import Set
28+
from typing import Tuple
29+
from typing import List
30+
from typing import Mapping
31+
from typing import Optional
32+
import xml.etree.ElementTree as ET
33+
34+
import requests
35+
from dephell_specifier import RangeSpecifier
36+
from packageurl import PackageURL
37+
38+
from vulnerabilities.data_source import Advisory
39+
from vulnerabilities.data_source import DataSource
40+
from vulnerabilities.data_source import DataSourceConfiguration
41+
42+
43+
# set of all possible values of first '%s' = {'MAVEN','COMPOSER', 'NUGET'}
44+
# second '%s' is interesting, it will have the value '' for the first request,
45+
# since we don't have any value for endCursor at the beginning
46+
# for all the subsequent requests it will have value 'after: "{endCursor}""
47+
query = """
48+
query{
49+
securityVulnerabilities(first: 100, ecosystem: %s, %s) {
50+
edges {
51+
node {
52+
advisory {
53+
identifiers {
54+
type
55+
value
56+
}
57+
summary
58+
}
59+
package {
60+
name
61+
}
62+
vulnerableVersionRange
63+
}
64+
}
65+
pageInfo {
66+
hasNextPage
67+
endCursor
68+
}
69+
}
70+
}
71+
"""
72+
73+
74+
class GitHubTokenError(Exception):
75+
pass
76+
77+
78+
@dataclasses.dataclass
79+
class GitHubAPIDataSourceConfiguration(DataSourceConfiguration):
80+
endpoint: str
81+
ecosystems: list
82+
83+
84+
class GitHubAPIDataSource(DataSource):
85+
86+
CONFIG_CLASS = GitHubAPIDataSourceConfiguration
87+
88+
def __init__(self, *args, **kwargs):
89+
super().__init__(*args, **kwargs)
90+
try:
91+
self.gh_token = os.environ["GH_TOKEN"]
92+
except KeyError:
93+
raise GitHubTokenError("Environment variable GH_TOKEN is missing")
94+
95+
def __enter__(self):
96+
self.advisories = self.fetch()
97+
98+
def updated_advisories(self) -> Set[Advisory]:
99+
return self.batch_advisories(self.process_response())
100+
101+
def fetch(self) -> Mapping[str, List[Mapping]]:
102+
headers = {"Authorization": "token " + self.gh_token}
103+
api_data = {}
104+
for ecosystem in self.config.ecosystems:
105+
106+
api_data[ecosystem] = []
107+
end_cursor_exp = ""
108+
109+
while True:
110+
111+
query_json = {"query": query % (ecosystem, end_cursor_exp)}
112+
resp = requests.post(self.config.endpoint, headers=headers, json=query_json).json()
113+
print(resp)
114+
if resp.get("message") == "Bad credentials":
115+
raise GitHubTokenError("Invalid GitHub token")
116+
117+
end_cursor = resp["data"]["securityVulnerabilities"]["pageInfo"]["endCursor"]
118+
end_cursor_exp = "after: {}".format('"{}"'.format(end_cursor))
119+
api_data[ecosystem].append(resp)
120+
121+
if not resp["data"]["securityVulnerabilities"]["pageInfo"]["hasNextPage"]:
122+
break
123+
return api_data
124+
125+
def set_version_api(self, ecosystem: str) -> None:
126+
versioners = {
127+
"MAVEN": MavenVersionAPI,
128+
"NUGET": NugetVersionAPI,
129+
"COMPOSER": ComposerVersionAPI,
130+
}
131+
versioner = versioners.get(ecosystem)
132+
if versioner:
133+
self.version_api = versioner()
134+
135+
@staticmethod
136+
def process_name(ecosystem: str, pkg_name: str) -> Optional[Tuple[Optional[str], str]]:
137+
if ecosystem == "MAVEN":
138+
artifact_comps = pkg_name.split(":")
139+
if len(artifact_comps) != 2:
140+
return
141+
ns, name = artifact_comps
142+
return ns, name
143+
144+
if ecosystem == "NUGET":
145+
return None, pkg_name
146+
147+
if ecosystem == "COMPOSER":
148+
vendor, name = pkg_name.split("/")
149+
return vendor, name
150+
151+
def process_response(self) -> List[Advisory]:
152+
adv_list = []
153+
for ecosystem in self.advisories:
154+
self.set_version_api(ecosystem)
155+
pkg_type = ecosystem.lower()
156+
for resp_page in self.advisories[ecosystem]:
157+
for adv in resp_page["data"]["securityVulnerabilities"]["edges"]:
158+
name = adv["node"]["package"]["name"]
159+
160+
if self.process_name(ecosystem, name):
161+
ns, pkg_name = self.process_name(ecosystem, name)
162+
else:
163+
continue
164+
aff_range = adv["node"]["vulnerableVersionRange"]
165+
self.version_api.load_to_api(name)
166+
aff_vers, unaff_vers = self.categorize_versions(
167+
aff_range, self.version_api.get(name)
168+
)
169+
170+
affected_purls = {
171+
PackageURL(name=pkg_name, namespace=ns, version=version, type=pkg_type)
172+
for version in aff_vers
173+
}
174+
175+
unaffected_purls = {
176+
PackageURL(name=pkg_name, namespace=ns, version=version, type=pkg_type)
177+
for version in unaff_vers
178+
}
179+
180+
cve_ids = set()
181+
ref_ids = set()
182+
vuln_desc = adv["node"]["advisory"]["summary"]
183+
184+
for vuln in adv["node"]["advisory"]["identifiers"]:
185+
if vuln["type"] == "CVE":
186+
cve_ids.add(vuln["value"])
187+
else:
188+
ref_ids.add(vuln["value"])
189+
for cve_id in cve_ids:
190+
adv_list.append(
191+
Advisory(
192+
cve_id=cve_id,
193+
summary=vuln_desc,
194+
impacted_package_urls=affected_purls,
195+
resolved_package_urls=unaffected_purls,
196+
reference_ids=ref_ids,
197+
)
198+
)
199+
return adv_list
200+
201+
@staticmethod
202+
def categorize_versions(version_range: str, all_versions: Set[str]) -> Tuple[Set[str], Set[str]]: # nopep8
203+
version_range = RangeSpecifier(version_range)
204+
affected_versions = {version for version in all_versions if version in version_range}
205+
return (affected_versions, all_versions - affected_versions)
206+
207+
208+
class MavenVersionAPI:
209+
def __init__(self):
210+
self.cache = {}
211+
212+
def get(self, pkg_name: str) -> Set[str]:
213+
return self.cache.get(pkg_name, set())
214+
215+
def load_to_api(self, pkg_name: str) -> None:
216+
if pkg_name in self.cache:
217+
return
218+
219+
artifact_comps = pkg_name.split(":")
220+
endpoint = self.artifact_url(artifact_comps)
221+
resp = requests.get(endpoint).content
222+
223+
try:
224+
xml_resp = ET.ElementTree(ET.fromstring(resp.decode("utf-8")))
225+
self.cache[pkg_name] = self.extract_versions(xml_resp)
226+
except ET.ParseError:
227+
self.cache[pkg_name] = set()
228+
229+
@staticmethod
230+
def artifact_url(artifact_comps: List[str]) -> str:
231+
base_url = "https://repo.maven.apache.org/maven2/{}"
232+
group_id, artifact_id = artifact_comps
233+
group_url = group_id.replace(".", "/")
234+
suffix = group_url + "/" + artifact_id + "/" + "maven-metadata.xml"
235+
endpoint = base_url.format(suffix)
236+
237+
return endpoint
238+
239+
@staticmethod
240+
def extract_versions(xml_response: ET.ElementTree) -> Set[str]:
241+
all_versions = set()
242+
for child in xml_response.getroot().iter():
243+
if child.tag == "version":
244+
all_versions.add(child.text)
245+
246+
return all_versions
247+
248+
249+
class NugetVersionAPI:
250+
def __init__(self):
251+
self.cache = {}
252+
253+
def get(self, pkg_name: str) -> Set[str]:
254+
return self.cache.get(pkg_name.lower(), set())
255+
256+
def load_to_api(self, pkg_name: str) -> None:
257+
if pkg_name in self.cache:
258+
return
259+
endpoint = self.nuget_url(pkg_name)
260+
try:
261+
resp = requests.get(endpoint).json()
262+
# pkg_name=Microsoft.NETCore.UniversalWindowsPlatform triggers
263+
# JSONDecodeError.
264+
except json.decoder.JSONDecodeError:
265+
self.cache[pkg_name.lower()] = set()
266+
return
267+
268+
self.cache[pkg_name.lower()] = self.extract_versions(resp)
269+
270+
@staticmethod
271+
def nuget_url(pkg_name: str) -> str:
272+
base_url = "https://api.nuget.org/v3/registration5-semver1/{}/index.json"
273+
return base_url.format(pkg_name.lower())
274+
275+
@staticmethod
276+
def extract_versions(resp: dict) -> Set[str]:
277+
all_versions = set()
278+
279+
try:
280+
for entry in resp["items"][0]["items"]:
281+
all_versions.add(entry["catalogEntry"]["version"])
282+
# json response for YamlDotNet.Signed triggers this exception
283+
except KeyError:
284+
pass
285+
286+
return all_versions
287+
288+
289+
class ComposerVersionAPI:
290+
def __init__(self):
291+
self.cache = {}
292+
293+
def get(self, pkg_name: str) -> Set[str]:
294+
return self.cache.get(pkg_name.lower(), set())
295+
296+
def load_to_api(self, pkg_name: str) -> None:
297+
if pkg_name in self.cache:
298+
return
299+
300+
endpoint = self.composer_url(pkg_name)
301+
json_resp = requests.get(endpoint).json()
302+
self.cache[pkg_name] = self.extract_versions(json_resp, pkg_name)
303+
304+
@staticmethod
305+
def composer_url(pkg_name: str) -> str:
306+
vendor, name = pkg_name.split("/")
307+
return f"https://repo.packagist.org/p/{vendor}/{name}.json"
308+
309+
@staticmethod
310+
def extract_versions(resp: dict, pkg_name: str) -> Set[str]:
311+
all_versions = resp["packages"][pkg_name].keys()
312+
# This filter ensures, that all_versions contains only released versions
313+
all_versions = set(filter(lambda x: "dev" not in x, all_versions))
314+
# See https://github.com/composer/composer/blob/44a4429978d1b3c6223277b875762b2930e83e8c/doc/articles/versions.md#tags # nopep8
315+
# for explanation of removing 'v'
316+
all_versions = set(map(lambda x: x.replace("v", ""), all_versions))
317+
318+
return all_versions

0 commit comments

Comments
 (0)