diff --git a/awx/api/urls/webhooks.py b/awx/api/urls/webhooks.py index b57ca135d8..bbbf1ebd2d 100644 --- a/awx/api/urls/webhooks.py +++ b/awx/api/urls/webhooks.py @@ -1,10 +1,11 @@ from django.urls import re_path -from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver +from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver, BitbucketDcWebhookReceiver urlpatterns = [ re_path(r'^webhook_key/$', WebhookKeyView.as_view(), name='webhook_key'), re_path(r'^github/$', GithubWebhookReceiver.as_view(), name='webhook_receiver_github'), re_path(r'^gitlab/$', GitlabWebhookReceiver.as_view(), name='webhook_receiver_gitlab'), + re_path(r'^bitbucket_dc/$', BitbucketDcWebhookReceiver.as_view(), name='webhook_receiver_bitbucket_dc'), ] diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py index a1d3e27203..c0fa81380e 100644 --- a/awx/api/views/webhooks.py +++ b/awx/api/views/webhooks.py @@ -1,4 +1,4 @@ -from hashlib import sha1 +from hashlib import sha1, sha256 import hmac import logging import urllib.parse @@ -99,14 +99,31 @@ class WebhookReceiverBase(APIView): def get_signature(self): raise NotImplementedError + def must_check_signature(self): + return True + + def is_ignored_request(self): + return False + def check_signature(self, obj): if not obj.webhook_key: raise PermissionDenied + if not self.must_check_signature(): + logger.debug("skipping signature validation") + return - mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha1) - logger.debug("header signature: %s", self.get_signature()) + hash_alg, expected_digest = self.get_signature() + if hash_alg == 'sha1': + mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha1) + elif hash_alg == 'sha256': + mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha256) + else: + logger.debug("Unsupported signature type, supported: sha1, sha256, received: {}".format(hash_alg)) + raise PermissionDenied + + logger.debug("header signature: %s", expected_digest) logger.debug("calculated signature: %s", force_bytes(mac.hexdigest())) - if not hmac.compare_digest(force_bytes(mac.hexdigest()), self.get_signature()): + if not hmac.compare_digest(force_bytes(mac.hexdigest()), expected_digest): raise PermissionDenied @csrf_exempt @@ -118,6 +135,10 @@ class WebhookReceiverBase(APIView): obj = self.get_object() self.check_signature(obj) + if self.is_ignored_request(): + # This was an ignored request type (e.g. ping), don't act on it + return Response({'message': _("Webhook ignored")}, status=status.HTTP_200_OK) + event_type = self.get_event_type() event_guid = self.get_event_guid() event_ref = self.get_event_ref() @@ -186,7 +207,7 @@ class GithubWebhookReceiver(WebhookReceiverBase): if hash_alg != 'sha1': logger.debug("Unsupported signature type, expected: sha1, received: {}".format(hash_alg)) raise PermissionDenied - return force_bytes(signature) + return hash_alg, force_bytes(signature) class GitlabWebhookReceiver(WebhookReceiverBase): @@ -214,15 +235,73 @@ class GitlabWebhookReceiver(WebhookReceiverBase): return "{}://{}/api/v4/projects/{}/statuses/{}".format(parsed.scheme, parsed.netloc, project['id'], self.get_event_ref()) - def get_signature(self): - return force_bytes(self.request.META.get('HTTP_X_GITLAB_TOKEN') or '') - def check_signature(self, obj): if not obj.webhook_key: raise PermissionDenied + token_from_request = force_bytes(self.request.META.get('HTTP_X_GITLAB_TOKEN') or '') + # GitLab only returns the secret token, not an hmac hash. Use # the hmac `compare_digest` helper function to prevent timing # analysis by attackers. - if not hmac.compare_digest(force_bytes(obj.webhook_key), self.get_signature()): + if not hmac.compare_digest(force_bytes(obj.webhook_key), token_from_request): raise PermissionDenied + + +class BitbucketDcWebhookReceiver(WebhookReceiverBase): + service = 'bitbucket_dc' + + ref_keys = { + 'repo:refs_changed': 'changes.0.toHash', + 'mirror:repo_synchronized': 'changes.0.toHash', + 'pr:opened': 'pullRequest.toRef.latestCommit', + 'pr:from_ref_updated': 'pullRequest.toRef.latestCommit', + 'pr:modified': 'pullRequest.toRef.latestCommit', + } + + def get_event_type(self): + return self.request.META.get('HTTP_X_EVENT_KEY') + + def get_event_guid(self): + return self.request.META.get('HTTP_X_REQUEST_ID') + + def get_event_status_api(self): + # https:///rest/build-status/1.0/commits/ + if self.get_event_type() not in self.ref_keys.keys(): + return + if self.get_event_ref() is None: + return + any_url = None + if 'actor' in self.request.data: + any_url = self.request.data['actor'].get('links', {}).get('self') + if any_url is None and 'repository' in self.request.data: + any_url = self.request.data['repository'].get('links', {}).get('self') + if any_url is None: + return + any_url = any_url[0].get('href') + if any_url is None: + return + parsed = urllib.parse.urlparse(any_url) + + return "{}://{}/rest/build-status/1.0/commits/{}".format(parsed.scheme, parsed.netloc, self.get_event_ref()) + + def is_ignored_request(self): + return self.get_event_type() not in [ + 'repo:refs_changed', + 'mirror:repo_synchronized', + 'pr:opened', + 'pr:from_ref_updated', + 'pr:modified', + ] + + def must_check_signature(self): + # Bitbucket does not sign ping requests... + return self.get_event_type() != 'diagnostics:ping' + + def get_signature(self): + header_sig = self.request.META.get('HTTP_X_HUB_SIGNATURE') + if not header_sig: + logger.debug("Expected signature missing from header key HTTP_X_HUB_SIGNATURE") + raise PermissionDenied + hash_alg, signature = header_sig.split('=') + return hash_alg, force_bytes(signature) diff --git a/awx/main/migrations/0188_add_bitbucket_dc_webhook.py b/awx/main/migrations/0188_add_bitbucket_dc_webhook.py new file mode 100644 index 0000000000..ae067b2cbe --- /dev/null +++ b/awx/main/migrations/0188_add_bitbucket_dc_webhook.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.6 on 2023-11-16 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0187_hop_nodes'), + ] + + operations = [ + migrations.AlterField( + model_name='job', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + migrations.AlterField( + model_name='jobtemplate', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + migrations.AlterField( + model_name='workflowjob', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + migrations.AlterField( + model_name='workflowjobtemplate', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + ] diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 5de77ff62d..c731001f42 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -953,6 +953,25 @@ ManagedCredentialType( }, ) +ManagedCredentialType( + namespace='bitbucket_dc_token', + kind='token', + name=gettext_noop('Bitbucket Data Center HTTP Access Token'), + managed=True, + inputs={ + 'fields': [ + { + 'id': 'token', + 'label': gettext_noop('Token'), + 'type': 'string', + 'secret': True, + 'help_text': gettext_noop('This token needs to come from your user settings in Bitbucket'), + } + ], + 'required': ['token'], + }, +) + ManagedCredentialType( namespace='insights', kind='insights', diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index fd92b0b5c3..a2b7873967 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -562,6 +562,7 @@ class WebhookTemplateMixin(models.Model): SERVICES = [ ('github', "GitHub"), ('gitlab', "GitLab"), + ('bitbucket_dc', "BitBucket DataCenter"), ] webhook_service = models.CharField(max_length=16, choices=SERVICES, blank=True, help_text=_('Service that webhook requests will be accepted from')) @@ -622,6 +623,7 @@ class WebhookMixin(models.Model): service_header = { 'github': ('Authorization', 'token {}'), 'gitlab': ('PRIVATE-TOKEN', '{}'), + 'bitbucket_dc': ('Authorization', 'Bearer {}'), } service_statuses = { 'github': { @@ -639,6 +641,14 @@ class WebhookMixin(models.Model): 'error': 'failed', # GitLab doesn't have an 'error' status distinct from 'failed' :( 'canceled': 'canceled', }, + 'bitbucket_dc': { + 'pending': 'INPROGRESS', # Bitbucket DC doesn't have any other statuses distinct from INPROGRESS, SUCCESSFUL, FAILED :( + 'running': 'INPROGRESS', + 'successful': 'SUCCESSFUL', + 'failed': 'FAILED', + 'error': 'FAILED', + 'canceled': 'FAILED', + }, } statuses = service_statuses[self.webhook_service] @@ -647,11 +657,18 @@ class WebhookMixin(models.Model): return try: license_type = get_licenser().validate().get('license_type') - data = { - 'state': statuses[status], - 'context': 'ansible/awx' if license_type == 'open' else 'ansible/tower', - 'target_url': self.get_ui_url(), - } + if self.webhook_service == 'bitbucket_dc': + data = { + 'state': statuses[status], + 'key': 'ansible/awx' if license_type == 'open' else 'ansible/tower', + 'url': self.get_ui_url(), + } + else: + data = { + 'state': statuses[status], + 'context': 'ansible/awx' if license_type == 'open' else 'ansible/tower', + 'target_url': self.get_ui_url(), + } k, v = service_header[self.webhook_service] headers = {k: v.format(self.webhook_credential.get_input('token')), 'Content-Type': 'application/json'} response = requests.post(status_api, data=json.dumps(data), headers=headers, timeout=30) diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index d61f2e09ba..c018e735bf 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -81,6 +81,7 @@ def test_default_cred_types(): 'aws_secretsmanager_credential', 'azure_kv', 'azure_rm', + 'bitbucket_dc_token', 'centrify_vault_kv', 'conjur', 'controller', diff --git a/awx/ui/src/screens/Template/shared/WebhookSubForm.js b/awx/ui/src/screens/Template/shared/WebhookSubForm.js index ed5cf7a825..0f64ffde65 100644 --- a/awx/ui/src/screens/Template/shared/WebhookSubForm.js +++ b/awx/ui/src/screens/Template/shared/WebhookSubForm.js @@ -112,6 +112,12 @@ function WebhookSubForm({ templateType }) { label: t`GitLab`, isDisabled: false, }, + { + value: 'bitbucket_dc', + key: 'bitbucket_dc', + label: t`Bitbucket Data Center`, + isDisabled: false, + }, ]; if (error || webhookKeyError) {