From bd4ab122206c446bd4fe554e1344d26a64b232d3 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:35:27 +0100 Subject: [PATCH 1/6] Add GitLab support --- pyghee/lib.py | 76 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/pyghee/lib.py b/pyghee/lib.py index a7ccdd9..eeb5a91 100644 --- a/pyghee/lib.py +++ b/pyghee/lib.py @@ -3,6 +3,7 @@ # see https://github.com/boegel/pyghee # # author: Kenneth Hoste (@boegel) +# author: Sondre Bergsvaag Risanger (@sondrebr) # # license: GPLv2 # @@ -20,23 +21,38 @@ from .utils import create_file, error, log, log_warning EVENTS_LOG_DIR = os.path.join(os.getcwd(), 'events_log') +GITHUB = "github" +GITLAB = "gitlab" SHA1 = 'sha1' UNKNOWN = 'UNKNOWN' -def get_event_info(request): +def get_event_info(request, event_source=GITHUB): """ Extract event info from raw header data, and return result as Python dictionary value """ - event_info = { - 'action': request.json.get('action', UNKNOWN), - 'id': request.headers['X-Github-Delivery'], - 'signature-sha1': request.headers['X-Hub-Signature'], - 'timestamp_raw': request.headers['Timestamp'], - 'type': request.headers['X-GitHub-Event'], - } + if event_source == GITHUB: + event_info = { + 'action': request.json.get('action', UNKNOWN), + 'id': request.headers['X-Github-Delivery'], + 'signature-sha1': request.headers['X-Hub-Signature'], + 'type': request.headers['X-GitHub-Event'], + } + elif event_source == GITLAB: + object_attributes = request.json.get('object_attributes', {}) + event_info = { + 'action': object_attributes.get('action', UNKNOWN), + 'id': request.headers['Idempotency-Key'], + # GitLab does not yet support signatures in webhooks + # However, it is possible to use e.g. a custom smee.io server to sign the event + 'signature-sha1': request.headers.get('X-Gitlab-Signature', None), + 'type': request.json.get("event_type", UNKNOWN), + } + else: + error(f"Unsupported event source: {event_source}") event_info.update({ + 'timestamp_raw': request.headers['Timestamp'], 'raw_request_body': request.json, 'raw_request_data': request.data, 'raw_request_headers': dict(request.headers), @@ -64,26 +80,38 @@ def read_event_from_json(jsonfile): class PyGHee(flask.Flask): - def __init__(self, *args, **kwargs): + def __init__(self, event_source=GITHUB, *args, **kwargs): """ PyGHee constructor. """ super(PyGHee, self).__init__('PyGHee', *args, **kwargs) + self.event_source = event_source.lower() - github_token = os.getenv('GITHUB_TOKEN') - if github_token is None: - error("GitHub token is not available via $GITHUB_TOKEN!") - else: - del os.environ['GITHUB_TOKEN'] + if self.event_source == GITHUB: + github_token = os.getenv('GITHUB_TOKEN') + if github_token is None: + error("GitHub token is not available via $GITHUB_TOKEN!") + else: + del os.environ['GITHUB_TOKEN'] - self.gh = github.Github(github_token) + self.gh = github.Github(github_token) - # see https://docs.github.com/en/developers/webhooks-and-events/securing-your-webhooks - self.github_app_secret_token = os.getenv('GITHUB_APP_SECRET_TOKEN') - if self.github_app_secret_token is None: - error("Webhook secret is not available via $GITHUB_APP_SECRET_TOKEN!") + # see https://docs.github.com/en/developers/webhooks-and-events/securing-your-webhooks + self.github_app_secret_token = os.getenv('GITHUB_APP_SECRET_TOKEN') + if self.github_app_secret_token is None: + error("Webhook secret is not available via $GITHUB_APP_SECRET_TOKEN!") + else: + del os.environ['GITHUB_APP_SECRET_TOKEN'] + elif self.event_source == GITLAB: + self.gitlab_webhook_secret_token = os.getenv('GITLAB_WEBHOOK_SECRET_TOKEN') + if self.gitlab_webhook_secret_token is None: + error("Webhook secret is not available via $GITLAB_WEBHOOK_SECRET_TOKEN!") + else: + del os.environ['GITLAB_WEBHOOK_SECRET_TOKEN'] else: - del os.environ['GITHUB_APP_SECRET_TOKEN'] + error_msg = f"'{self.event_source}' is not a supported webhook source." + error_msg += " Supported event_source values are 'github' and 'gitlab'." + error(error_msg) self.registered_events = [] @@ -164,7 +192,11 @@ def verify_request(self, event_info, abort_function, log_file=None): if signature_type == SHA1: # see https://docs.python.org/3/library/hmac.html request_data = event_info['raw_request_data'] - mac = hmac.new(self.github_app_secret_token.encode(), msg=request_data, digestmod=SHA1) + if self.event_source == GITHUB: + secret_token = self.github_app_secret_token + elif self.event_source == GITLAB: + secret_token = self.gitlab_webhook_secret_token + mac = hmac.new(secret_token.encode(), msg=request_data, digestmod=SHA1) if hmac.compare_digest(str(mac.hexdigest()), str(signature)): log("Request verified: signature OK!", log_file=log_file) else: @@ -185,7 +217,7 @@ def process_event(self, request, abort_function, Logs a warning in case of crash while processing event. """ try: - event_info = get_event_info(request) + event_info = get_event_info(request, self.event_source) event_id = event_info['id'] if self.register_event(event_id): self.log_event(event_info, events_log_dir=events_log_dir, log_file=log_file) From 50a4e86ad7de3d7dc0e149b0c7741b3e911f154b Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:45:58 +0200 Subject: [PATCH 2/6] Update GitLab signature verification The signature verification now matches planned implementation: https://gitlab.com/gitlab-org/gitlab/-/work_items/19367 --- pyghee/lib.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pyghee/lib.py b/pyghee/lib.py index eeb5a91..5e11be0 100644 --- a/pyghee/lib.py +++ b/pyghee/lib.py @@ -24,6 +24,7 @@ GITHUB = "github" GITLAB = "gitlab" SHA1 = 'sha1' +SHA256 = 'sha256' UNKNOWN = 'UNKNOWN' @@ -36,6 +37,7 @@ def get_event_info(request, event_source=GITHUB): 'action': request.json.get('action', UNKNOWN), 'id': request.headers['X-Github-Delivery'], 'signature-sha1': request.headers['X-Hub-Signature'], + 'timestamp_raw': request.headers['Timestamp'], 'type': request.headers['X-GitHub-Event'], } elif event_source == GITLAB: @@ -45,14 +47,14 @@ def get_event_info(request, event_source=GITHUB): 'id': request.headers['Idempotency-Key'], # GitLab does not yet support signatures in webhooks # However, it is possible to use e.g. a custom smee.io server to sign the event - 'signature-sha1': request.headers.get('X-Gitlab-Signature', None), + 'signature-sha1': request.headers['X-Gitlab-Signature-256'], + 'timestamp_raw': request.headers['X-Gitlab-Timestamp'], 'type': request.json.get("event_type", UNKNOWN), } else: error(f"Unsupported event source: {event_source}") event_info.update({ - 'timestamp_raw': request.headers['Timestamp'], 'raw_request_body': request.json, 'raw_request_data': request.data, 'raw_request_headers': dict(request.headers), @@ -189,21 +191,27 @@ def verify_request(self, event_info, abort_function, log_file=None): header_parts = header_signature.split('=') if len(header_parts) == 2: signature_type, signature = header_parts - if signature_type == SHA1: + if signature_type in (SHA1, SHA256): # see https://docs.python.org/3/library/hmac.html request_data = event_info['raw_request_data'] if self.event_source == GITHUB: secret_token = self.github_app_secret_token elif self.event_source == GITLAB: + # GitLab creates the signature from '{timestamp}.{webhook_uuid}.{payload}', + # see https://gitlab.com/gitlab-org/gitlab/-/work_items/19367 secret_token = self.gitlab_webhook_secret_token - mac = hmac.new(secret_token.encode(), msg=request_data, digestmod=SHA1) + ts = event_info['timestamp_raw'] + webhook_uuid = event_info['raw_request_headers'].get('X-Gitlab-Webhook-UUID') + components = [ts.encode('utf8'), webhook_uuid.encode('utf8'), request_data] + request_data = b'.'.join(components) + mac = hmac.new(secret_token.encode(), msg=request_data, digestmod=signature_type) if hmac.compare_digest(str(mac.hexdigest()), str(signature)): log("Request verified: signature OK!", log_file=log_file) else: log_warning("Faulty signature in request header => 403", log_file=log_file) abort_function(403) else: - # we only know how to verify a SHA1 signature + # we only know how to verify SHA1 and SHA256 signatures log_warning("Uknown type of signature (%s) => 501" % signature_type, log_file=log_file) abort_function(501) else: From c5e10632a45db5ca94f5cbf87ac7a90a2064e8fd Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:59:37 +0200 Subject: [PATCH 3/6] Fix header capitalization --- pyghee/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyghee/lib.py b/pyghee/lib.py index 5e11be0..9e6dce2 100644 --- a/pyghee/lib.py +++ b/pyghee/lib.py @@ -201,7 +201,7 @@ def verify_request(self, event_info, abort_function, log_file=None): # see https://gitlab.com/gitlab-org/gitlab/-/work_items/19367 secret_token = self.gitlab_webhook_secret_token ts = event_info['timestamp_raw'] - webhook_uuid = event_info['raw_request_headers'].get('X-Gitlab-Webhook-UUID') + webhook_uuid = event_info['raw_request_headers'].get('X-Gitlab-Webhook-Uuid') components = [ts.encode('utf8'), webhook_uuid.encode('utf8'), request_data] request_data = b'.'.join(components) mac = hmac.new(secret_token.encode(), msg=request_data, digestmod=signature_type) From e128a2f4437531652c78e2ce476cb20151c03eba Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:09:06 +0200 Subject: [PATCH 4/6] Use Standard Webhooks spec. for GitLab --- pyghee/lib.py | 73 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/pyghee/lib.py b/pyghee/lib.py index 9e6dce2..70a7009 100644 --- a/pyghee/lib.py +++ b/pyghee/lib.py @@ -7,6 +7,7 @@ # # license: GPLv2 # +import base64 import datetime import flask import hmac @@ -25,6 +26,8 @@ GITLAB = "gitlab" SHA1 = 'sha1' SHA256 = 'sha256' +STANDARD_WEBHOOKS_SIGN_PREFIX = 'v1,' +STANDARD_WEBHOOKS_SECRET_PREFIX = 'whsec_' UNKNOWN = 'UNKNOWN' @@ -44,11 +47,11 @@ def get_event_info(request, event_source=GITHUB): object_attributes = request.json.get('object_attributes', {}) event_info = { 'action': object_attributes.get('action', UNKNOWN), - 'id': request.headers['Idempotency-Key'], + 'id': request.headers['Webhook-Id'], # GitLab does not yet support signatures in webhooks # However, it is possible to use e.g. a custom smee.io server to sign the event - 'signature-sha1': request.headers['X-Gitlab-Signature-256'], - 'timestamp_raw': request.headers['X-Gitlab-Timestamp'], + 'signature-sha1': request.headers['Webhook-Signature'], + 'timestamp_raw': request.headers['Webhook-Timestamp'], 'type': request.json.get("event_type", UNKNOWN), } else: @@ -105,10 +108,13 @@ def __init__(self, event_source=GITHUB, *args, **kwargs): else: del os.environ['GITHUB_APP_SECRET_TOKEN'] elif self.event_source == GITLAB: - self.gitlab_webhook_secret_token = os.getenv('GITLAB_WEBHOOK_SECRET_TOKEN') - if self.gitlab_webhook_secret_token is None: + secret_token = os.getenv('GITLAB_WEBHOOK_SECRET_TOKEN') + if secret_token is None: error("Webhook secret is not available via $GITLAB_WEBHOOK_SECRET_TOKEN!") else: + # Strip 'whsec_' prefix if present + secret_token = secret_token.removeprefix(STANDARD_WEBHOOKS_SECRET_PREFIX) + self.gitlab_webhook_secret_token = secret_token del os.environ['GITLAB_WEBHOOK_SECRET_TOKEN'] else: error_msg = f"'{self.event_source}' is not a supported webhook source." @@ -187,37 +193,58 @@ def verify_request(self, event_info, abort_function, log_file=None): if header_signature is None: log_warning("Missing signature in request header => 403", log_file=log_file) abort_function(403) + # GitLab uses "v1," format for signatures (Standard Webhooks specification) + # https://www.standardwebhooks.com/ + elif header_signature.startswith(STANDARD_WEBHOOKS_SIGN_PREFIX): + if self.event_source != GITLAB: + log_warning("Received Standard Webhook while configured for '%s' => 501" % self.event_source, + log_file=log_file) + abort_function(501) + + user_agent = event_info['raw_request_headers'].get("User-Agent", "") + if not user_agent.lower().startswith(GITLAB): + log_warning("Webhook user agent not supported (%s) => 501" % user_agent, log_file=log_file) + abort_function(501) + + secret_token = self.gitlab_webhook_secret_token + try: + key = base64.standard_b64decode(secret_token) + except Exception as err: + raise Exception(f"Configured key is not base64-encoded: {err}") + + timestamp = event_info['timestamp_raw'].encode('utf8') + webhook_id = event_info['id'].encode('utf8') + request_data = event_info['raw_request_data'] + components = (webhook_id, timestamp, request_data) + mac = hmac.new(key, msg=b'.'.join(components), digestmod=SHA256) + actual_signature = base64.standard_b64encode(mac.digest()).decode() + expected_signature = header_signature.removeprefix(STANDARD_WEBHOOKS_SIGN_PREFIX) + verified = hmac.compare_digest(str(actual_signature), str(expected_signature)) + # GitHub uses "sha<#>=" signature format else: header_parts = header_signature.split('=') if len(header_parts) == 2: - signature_type, signature = header_parts - if signature_type in (SHA1, SHA256): + signature_type, expected_signature = header_parts + if signature_type == SHA1: # see https://docs.python.org/3/library/hmac.html request_data = event_info['raw_request_data'] - if self.event_source == GITHUB: - secret_token = self.github_app_secret_token - elif self.event_source == GITLAB: - # GitLab creates the signature from '{timestamp}.{webhook_uuid}.{payload}', - # see https://gitlab.com/gitlab-org/gitlab/-/work_items/19367 - secret_token = self.gitlab_webhook_secret_token - ts = event_info['timestamp_raw'] - webhook_uuid = event_info['raw_request_headers'].get('X-Gitlab-Webhook-Uuid') - components = [ts.encode('utf8'), webhook_uuid.encode('utf8'), request_data] - request_data = b'.'.join(components) + secret_token = self.github_app_secret_token mac = hmac.new(secret_token.encode(), msg=request_data, digestmod=signature_type) - if hmac.compare_digest(str(mac.hexdigest()), str(signature)): - log("Request verified: signature OK!", log_file=log_file) - else: - log_warning("Faulty signature in request header => 403", log_file=log_file) - abort_function(403) + verified = hmac.compare_digest(str(mac.hexdigest()), str(expected_signature)) else: - # we only know how to verify SHA1 and SHA256 signatures + # we only know how to verify SHA1 signatures log_warning("Uknown type of signature (%s) => 501" % signature_type, log_file=log_file) abort_function(501) else: log_warning("Type of signature not specified (%s) => 501" % header_signature, log_file=log_file) abort_function(501) + if verified: + log("Request verified: signature OK!", log_file=log_file) + else: + log_warning("Faulty signature in request header => 403", log_file=log_file) + abort_function(403) + def process_event(self, request, abort_function, events_log_dir=None, log_file=None, raise_error=False, verify=True): """ From 514bdf6b7db1ed2a965ccdabaf45f9e9b1d825b7 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Fri, 22 May 2026 12:57:39 +0200 Subject: [PATCH 5/6] Support receiving a list of signatures from GitLab Also change some of the comments now that webhook signatures on GitLab are released and documented. --- pyghee/lib.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pyghee/lib.py b/pyghee/lib.py index 70a7009..f9a8394 100644 --- a/pyghee/lib.py +++ b/pyghee/lib.py @@ -48,8 +48,6 @@ def get_event_info(request, event_source=GITHUB): event_info = { 'action': object_attributes.get('action', UNKNOWN), 'id': request.headers['Webhook-Id'], - # GitLab does not yet support signatures in webhooks - # However, it is possible to use e.g. a custom smee.io server to sign the event 'signature-sha1': request.headers['Webhook-Signature'], 'timestamp_raw': request.headers['Webhook-Timestamp'], 'type': request.json.get("event_type", UNKNOWN), @@ -185,7 +183,8 @@ def log_event(self, event_info, events_log_dir=None, log_file=None): def verify_request(self, event_info, abort_function, log_file=None): """ Verify request by checking webhook secret in request header. - Webhook secret must also be available in $GITHUB_APP_SECRET_TOKEN environment variable. + Webhook secret must also be available in $GITHUB_APP_SECRET_TOKEN + or $GITLAB_WEBHOOK_SECRET_TOKEN environment variable. """ header_signature = event_info['signature-sha1'] @@ -193,12 +192,14 @@ def verify_request(self, event_info, abort_function, log_file=None): if header_signature is None: log_warning("Missing signature in request header => 403", log_file=log_file) abort_function(403) - # GitLab uses "v1," format for signatures (Standard Webhooks specification) - # https://www.standardwebhooks.com/ - elif header_signature.startswith(STANDARD_WEBHOOKS_SIGN_PREFIX): - if self.event_source != GITLAB: - log_warning("Received Standard Webhook while configured for '%s' => 501" % self.event_source, - log_file=log_file) + # GitLab uses "v1," format for signatures + # https://docs.gitlab.com/user/project/integrations/webhooks/#signing-tokens + elif self.event_source == GITLAB: + # GitLab sends a space-separated list of signatures + signatures = header_signature.split(" ") + + if not any(signature.startswith("v1,") for signature in signatures): + log_warning("No known signature types in request header => 501", log_file=log_file) abort_function(501) user_agent = event_info['raw_request_headers'].get("User-Agent", "") @@ -217,20 +218,19 @@ def verify_request(self, event_info, abort_function, log_file=None): request_data = event_info['raw_request_data'] components = (webhook_id, timestamp, request_data) mac = hmac.new(key, msg=b'.'.join(components), digestmod=SHA256) - actual_signature = base64.standard_b64encode(mac.digest()).decode() - expected_signature = header_signature.removeprefix(STANDARD_WEBHOOKS_SIGN_PREFIX) - verified = hmac.compare_digest(str(actual_signature), str(expected_signature)) + expected_signature = "v1," + base64.standard_b64encode(mac.digest()).decode() + verified = any(hmac.compare_digest(expected_signature, signature) for signature in signatures) # GitHub uses "sha<#>=" signature format else: header_parts = header_signature.split('=') if len(header_parts) == 2: - signature_type, expected_signature = header_parts + signature_type, signature = header_parts if signature_type == SHA1: # see https://docs.python.org/3/library/hmac.html request_data = event_info['raw_request_data'] secret_token = self.github_app_secret_token mac = hmac.new(secret_token.encode(), msg=request_data, digestmod=signature_type) - verified = hmac.compare_digest(str(mac.hexdigest()), str(expected_signature)) + verified = hmac.compare_digest(str(mac.hexdigest()), str(signature)) else: # we only know how to verify SHA1 signatures log_warning("Uknown type of signature (%s) => 501" % signature_type, log_file=log_file) From c66e2e264b4bf6aff1455910604b4aba8abf1d88 Mon Sep 17 00:00:00 2001 From: "Sondre B. Risanger" <168830227+sondrebr@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:08:49 +0200 Subject: [PATCH 6/6] Fix GitLab timestamp handling --- pyghee/lib.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyghee/lib.py b/pyghee/lib.py index f9a8394..b9c83d4 100644 --- a/pyghee/lib.py +++ b/pyghee/lib.py @@ -43,6 +43,7 @@ def get_event_info(request, event_source=GITHUB): 'timestamp_raw': request.headers['Timestamp'], 'type': request.headers['X-GitHub-Event'], } + ts = int(event_info['timestamp_raw'])/1000. elif event_source == GITLAB: object_attributes = request.json.get('object_attributes', {}) event_info = { @@ -52,6 +53,7 @@ def get_event_info(request, event_source=GITHUB): 'timestamp_raw': request.headers['Webhook-Timestamp'], 'type': request.json.get("event_type", UNKNOWN), } + ts = int(event_info['timestamp_raw']) else: error(f"Unsupported event source: {event_source}") @@ -61,10 +63,11 @@ def get_event_info(request, event_source=GITHUB): 'raw_request_headers': dict(request.headers), }) - timestamp = datetime.datetime.utcfromtimestamp(int(event_info['timestamp_raw'])/1000.) + timestamp = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC) + iso_timestamp = timestamp.isoformat(timespec="seconds") event_info['timestamp'] = timestamp - event_info['date'] = timestamp.isoformat().split('T')[0] - event_info['time'] = timestamp.isoformat().split('T')[1].split('.')[0].replace(':', '-') + event_info['date'] = iso_timestamp.split('T')[0] + event_info['time'] = iso_timestamp.split('T')[1].split('+')[0].replace(':', '-') return event_info