Coverage for webapp/api/github.py: 65%
205 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 12:33 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 12:33 +0000
1import hmac
2from hashlib import sha1
3from os import getenv
5from webapp import api
6from webapp.helpers import get_yaml_loader
7from werkzeug.exceptions import Unauthorized, Forbidden
8from requests.exceptions import HTTPError
10import gzip
11from io import BytesIO
12import json
14GITHUB_WEBHOOK_SECRET = getenv("GITHUB_WEBHOOK_SECRET")
17class InvalidYAML(Exception):
18 pass
21class GitHub:
22 """
23 Provides authentication for GitHub users. Helper methods are also provided
24 for checking organization access and getting user data from the Github API.
25 """
27 REST_API_URL = "https://api.github.com"
28 GRAPHQL_API_URL = "https://api.github.com/graphql"
29 RAW_CONTENT_URL = "https://raw.githubusercontent.com"
31 YAML_LOCATIONS = [
32 "snapcraft.yaml",
33 ".snapcraft.yaml",
34 "snap/snapcraft.yaml",
35 "build-aux/snap/snapcraft.yaml",
36 ]
38 def __init__(self, access_token=None, session=api.requests.Session()):
39 self.access_token = access_token
40 self.session = session
41 self.session.headers["Accept"] = "application/json"
43 def _request(
44 self, method="GET", url="", params={}, data={}, raise_exceptions=True
45 ):
46 """
47 Makes a raw HTTP request and returns the response.
48 """
49 if self.access_token:
50 headers = {"Authorization": f"token {self.access_token}"}
51 else:
52 headers = {}
54 response = self.session.request(
55 method,
56 f"{self.REST_API_URL}/{url}",
57 headers=headers,
58 params=params,
59 json=data,
60 )
62 if raise_exceptions:
63 if response.status_code == 401:
64 raise Unauthorized(response=response)
65 if response.status_code == 403:
66 raise Forbidden(response=response)
68 response.raise_for_status()
70 return response
72 def decompress_data(self, data, encoding):
73 if encoding == "gzip":
74 with gzip.GzipFile(fileobj=BytesIO(data)) as f:
75 return f.read().decode(
76 "utf-8"
77 ) # Decompress and decode as UTF-8
78 return data.decode("utf-8")
80 def get_data_from_response(self, response):
81 content_encoding = response.headers.get("Content-Encoding", "")
82 if content_encoding == "gzip":
83 try:
84 content = response.content
85 decompressed_data = self.decompress_data(
86 content, content_encoding
87 )
88 data = json.loads(decompressed_data)
89 except Exception:
90 data = response.json()
91 else:
92 data = response.json()
93 return data
95 def _gql_request(self, query={}):
96 """
97 Makes a raw HTTP request and returns the response.
98 """
99 if self.access_token:
100 headers = {"Authorization": f"token {self.access_token}"}
101 else:
102 headers = {}
104 response = self.session.request(
105 "POST",
106 self.GRAPHQL_API_URL,
107 json={"query": query},
108 headers=headers,
109 )
111 if response.status_code == 401:
112 raise Unauthorized(response=response)
113 if response.status_code == 403:
114 raise Forbidden
116 response.raise_for_status()
118 data = self.get_data_from_response(response)
119 return data["data"]
121 def _get_nodes(self, edges):
122 """
123 GraphQL: Return the list of nodes from the edges
124 """
125 return [i["node"] for i in edges]
127 def get_user(self):
128 """
129 Return some user properties of the current user
130 """
131 gql = """
132 {
133 viewer {
134 login
135 name
136 avatarUrl(size: 100)
137 }
138 }
139 """
141 return self._gql_request(gql)["viewer"]
143 def get_orgs(self, end_cursor=None):
144 """
145 Lists of organizations that the authenticated user has explicit
146 permission to access.
147 """
148 gql = (
149 """
150 {
151 viewer {
152 organizations(first: 100,"""
153 + (f'after: "{end_cursor}"' if end_cursor else "")
154 + """) {
155 edges {
156 node {
157 login
158 name
159 }
160 }
161 pageInfo {
162 hasNextPage
163 endCursor
164 }
165 }
166 }
167 }
168 """
169 )
171 gql_response = self._gql_request(gql)["viewer"]["organizations"]
172 page_info = gql_response["pageInfo"]
173 orgs = self._get_nodes(gql_response["edges"])
175 if page_info["hasNextPage"]:
176 next_page = self.get_orgs(page_info["endCursor"])
177 orgs.extend(next_page)
179 return orgs
181 def get_user_repositories(self, end_cursor=None):
182 """
183 Lists of public repositories from the authenticated user
184 """
185 gql = (
186 """{
187 viewer {
188 repositories(
189 first: 100,
190 privacy: PUBLIC,
191 """
192 + (f'after: "{end_cursor}"' if end_cursor else "")
193 + """
194 ) {
195 edges {
196 node {
197 name
198 nameWithOwner
199 }
200 }
201 pageInfo {
202 hasNextPage
203 endCursor
204 }
205 }
206 }
207 }"""
208 )
210 gql_response = self._gql_request(gql)["viewer"]["repositories"]
211 page_info = gql_response["pageInfo"]
212 repositories = self._get_nodes(gql_response["edges"])
214 if page_info["hasNextPage"]:
215 next_page = self.get_user_repositories(page_info["endCursor"])
216 repositories.extend(next_page)
218 repos = [
219 (
220 {**repo, "owner": repo.get("nameWithOwner", "").split("/")[0]}
221 if "nameWithOwner" in repo and repo.get("nameWithOwner")
222 else {**repo, "owner": None}
223 )
224 for repo in repositories
225 ]
227 return repos
229 def get_org_repositories(self, org_login, end_cursor=None):
230 """
231 Lists of public repositories from the authenticated user
232 """
233 gql = (
234 """{
235 viewer {
236 organization(login: \""""
237 + org_login
238 + """") {
239 repositories(
240 first: 100,
241 privacy: PUBLIC
242 """
243 + (f'after: "{end_cursor}"' if end_cursor else "")
244 + """
245 ) {
246 edges {
247 node {
248 name
249 nameWithOwner
250 }
251 }
252 pageInfo {
253 hasNextPage
254 endCursor
255 }
256 }
257 }
258 }
259 }"""
260 )
262 response = self._gql_request(gql)["viewer"]["organization"][
263 "repositories"
264 ]
266 page_info = response["pageInfo"]
267 repositories = self._get_nodes(response["edges"])
269 if page_info["hasNextPage"]:
270 next_page = self.get_org_repositories(
271 org_login, page_info["endCursor"]
272 )
273 repositories.extend(next_page)
275 repos = [
276 (
277 {**repo, "owner": repo.get("nameWithOwner", "").split("/")[0]}
278 if "nameWithOwner" in repo and repo.get("nameWithOwner")
279 else {**repo, "owner": None}
280 )
281 for repo in repositories
282 ]
284 return repos
286 def check_permissions_over_repo(self, owner, repo, permission="push"):
287 """
288 Return True when the current user has the requested permissions
289 Possible values: "admin", "push" or "pull"
290 """
291 try:
292 response = self._request(
293 "GET",
294 f"repos/{owner}/{repo}",
295 raise_exceptions=True,
296 )
297 except Unauthorized:
298 return False
299 except Forbidden:
300 return False
301 except HTTPError as e:
302 if e.response.status_code == 404:
303 return False
305 data = self.get_data_from_response(response)
306 response_permissions = data["permissions"]
307 user_permissions = [
308 p for p in response_permissions if response_permissions[p]
309 ]
311 return permission in user_permissions
313 def check_if_repo_exists(self, owner, repo):
314 """
315 Return True if GitHub repo exists
316 """
317 response = self._request(
318 "GET",
319 f"repos/{owner}/{repo}",
320 raise_exceptions=False,
321 )
322 if response.status_code == 404:
323 return False
324 elif response.status_code == 200:
325 return True
326 elif response.status_code == 401:
327 raise Unauthorized
328 elif response.status_code == 403:
329 raise Forbidden
331 response.raise_for_status()
333 def get_snapcraft_yaml_location(self, owner, repo):
334 """
335 Return the snapcraft.yaml file location in the GitHub repo
336 """
338 # It is not possible to use GraphQL without authentication
339 # for that reason we are doing a call for each location to the REST API
340 for loc in self.YAML_LOCATIONS:
341 response = self._request(
342 "GET",
343 f"repos/{owner}/{repo}/contents/{loc}",
344 raise_exceptions=False,
345 )
346 if response.status_code == 404:
347 continue
348 elif response.status_code == 200:
349 return loc
350 elif response.status_code == 401:
351 raise Unauthorized
352 elif response.status_code == 403:
353 raise Forbidden
355 response.raise_for_status()
357 return False
359 def get_default_branch(self, owner, repo):
360 response = self._request("GET", f"repos/{owner}/{repo}")
361 data = self.get_data_from_response(response)
362 return data["default_branch"]
364 def get_last_commit(self, owner, repo, branch=None):
365 if not branch:
366 branch = self.get_default_branch(owner, repo)
368 response = self._request(
369 "GET", f"repos/{owner}/{repo}/commits/{branch}"
370 )
371 data = self.get_data_from_response(response)
372 return data["sha"]
374 def get_snapcraft_yaml_data(self, owner, repo, location=None):
375 """
376 Parse the snapcraft.yaml from the repo and return a dict
377 """
378 if not location:
379 location = self.get_snapcraft_yaml_location(owner, repo)
381 if location:
382 # Get last commit to avoid cache issues with raw.github.com
383 last_commit = self.get_last_commit(owner, repo)
385 response = self.session.request(
386 "GET",
387 f"{self.RAW_CONTENT_URL}/{owner}/{repo}"
388 f"/{last_commit}/{location}",
389 )
391 yaml = get_yaml_loader()
392 try:
393 content_encoding = response.headers.get("Content-Encoding", "")
394 if content_encoding == "gzip":
395 try:
396 content = response.content
397 data = self.decompress_data(content, content_encoding)
398 except Exception:
399 data = response.content
400 else:
401 data = response.content
402 return yaml.load(data)
403 except Exception:
404 raise InvalidYAML
406 return {}
408 def generate_webhook_secret_for_repo(self, owner, name):
409 key = bytes(GITHUB_WEBHOOK_SECRET, "UTF-8")
410 hmac_gen = hmac.new(key, None, sha1)
411 hmac_gen.update(bytes(owner, "UTF-8"))
412 hmac_gen.update(bytes(name, "UTF-8"))
413 return hmac_gen.hexdigest()
415 def validate_webhook_signature(self, payload, signature):
416 """
417 Generate the payload signature and compare with the given one
418 """
419 key = bytes(GITHUB_WEBHOOK_SECRET, "UTF-8")
420 hmac_gen = hmac.new(key, payload, sha1)
422 # Add append prefix to match the GitHub request format
423 digest = f"sha1={hmac_gen.hexdigest()}"
425 return hmac.compare_digest(digest, signature)
427 def validate_bsi_webhook_secret(self, owner, name, payload, signature):
428 """
429 Return True if the webhook contain a valid secret in BSI
430 """
431 secret = self.generate_webhook_secret_for_repo(owner, name)
432 final_key = bytes(secret, "UTF-8")
433 final_hmac = hmac.new(final_key, payload, sha1)
435 # Add append prefix to match the GitHub request format
436 digest = f"sha1={final_hmac.hexdigest()}"
438 return hmac.compare_digest(digest, signature)
440 def get_hooks(self, owner, repo, page=1):
441 """
442 Return all the webhooks in the repo
443 """
444 response = self._request(
445 "GET",
446 f"repos/{owner}/{repo}/hooks",
447 params={"per_page": 100, "page": page},
448 )
449 hooks = response.json()
451 if "next" in response.links:
452 hooks.extend(self.get_hooks(page=page + 1))
454 return hooks
456 def get_hook_by_url(self, owner, repo, url):
457 """
458 Return a webhook from the repo with the url
459 """
460 hooks = self.get_hooks(owner, repo)
462 for hook in hooks:
463 if hook["config"]["url"] == url:
464 return hook
466 return None
468 def update_hook_url(self, owner, repo, hook_id, new_url):
469 """
470 Update a webhook to activate it and update the URL
471 """
472 data = {
473 "active": True,
474 "config": {
475 "url": new_url,
476 "content_type": "json",
477 "secret": GITHUB_WEBHOOK_SECRET,
478 },
479 }
481 self._request(
482 "PATCH", f"repos/{owner}/{repo}/hooks/{hook_id}", data=data
483 )
485 return True
487 def create_hook(self, owner, repo, hook_url):
488 """
489 Create the webhook in the repo
490 """
491 secret = self.generate_webhook_secret_for_repo(owner, repo)
492 data = {
493 "config": {
494 "url": hook_url,
495 "content_type": "json",
496 "secret": secret,
497 },
498 }
500 self._request("POST", f"repos/{owner}/{repo}/hooks", data=data)
502 return True
504 def remove_hook(self, owner, repo, hook_id):
505 """
506 Remove GitHub webhook in a repo
507 """
508 self._request("DELETE", f"repos/{owner}/{repo}/hooks/{hook_id}")
510 return True