mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[ADD] runbot_merge: webhook signature support
This commit is contained in:
parent
35f33cee6d
commit
6494ea6cb0
@ -1,6 +1,10 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import werkzeug.exceptions
|
||||||
|
|
||||||
from odoo.http import Controller, request, route
|
from odoo.http import Controller, request, route
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@ -8,10 +12,22 @@ _logger = logging.getLogger(__name__)
|
|||||||
class MergebotController(Controller):
|
class MergebotController(Controller):
|
||||||
@route('/runbot_merge/hooks', auth='none', type='json', csrf=False, methods=['POST'])
|
@route('/runbot_merge/hooks', auth='none', type='json', csrf=False, methods=['POST'])
|
||||||
def index(self):
|
def index(self):
|
||||||
event = request.httprequest.headers['X-Github-Event']
|
req = request.httprequest
|
||||||
|
event = req.headers['X-Github-Event']
|
||||||
|
|
||||||
c = EVENTS.get(event)
|
c = EVENTS.get(event)
|
||||||
if c:
|
if c:
|
||||||
|
repo = request.jsonrequest['repository']['full_name']
|
||||||
|
secret = request.env(user=1)['runbot_merge.repository'].search([
|
||||||
|
('name', '=', repo),
|
||||||
|
]).project_id.secret
|
||||||
|
if secret:
|
||||||
|
signature = 'sha1=' + hmac.new(secret.encode('ascii'), req.get_data(), hashlib.sha1).hexdigest()
|
||||||
|
if not hmac.compare_digest(signature, req.headers.get('X-Hub-Signature', '')):
|
||||||
|
_logger.warn("Ignored hook with incorrect signature %s",
|
||||||
|
req.headers.get('X-Hub-Signature'))
|
||||||
|
return werkzeug.exceptions.Forbidden()
|
||||||
|
|
||||||
return c(request.jsonrequest)
|
return c(request.jsonrequest)
|
||||||
_logger.warn('Unknown event %s', event)
|
_logger.warn('Unknown event %s', event)
|
||||||
return 'Unknown event {}'.format(event)
|
return 'Unknown event {}'.format(event)
|
||||||
|
@ -49,6 +49,12 @@ class Project(models.Model):
|
|||||||
batch_limit = fields.Integer(
|
batch_limit = fields.Integer(
|
||||||
default=8, help="Maximum number of PRs staged together")
|
default=8, help="Maximum number of PRs staged together")
|
||||||
|
|
||||||
|
secret = fields.Char(
|
||||||
|
help="Webhook secret. If set, will be checked against the signature "
|
||||||
|
"of (valid) incoming webhook signatures, failing signatures "
|
||||||
|
"will lead to webhook rejection. Should only use ASCII."
|
||||||
|
)
|
||||||
|
|
||||||
def _check_progress(self):
|
def _check_progress(self):
|
||||||
logger = _logger.getChild('cron')
|
logger = _logger.getChild('cron')
|
||||||
Batch = self.env['runbot_merge.batch']
|
Batch = self.env['runbot_merge.batch']
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import collections
|
import collections
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@ -96,6 +98,11 @@ class Repo(object):
|
|||||||
for client in self.hooks.get(event_type, []):
|
for client in self.hooks.get(event_type, []):
|
||||||
getattr(client, event_type)(*payload)
|
getattr(client, event_type)(*payload)
|
||||||
|
|
||||||
|
def set_secret(self, secret):
|
||||||
|
for clients in self.hooks.values():
|
||||||
|
for client in clients:
|
||||||
|
client.secret = secret
|
||||||
|
|
||||||
def issue(self, number):
|
def issue(self, number):
|
||||||
return self.issues[number]
|
return self.issues[number]
|
||||||
|
|
||||||
@ -552,16 +559,28 @@ committer {}
|
|||||||
class Client(werkzeug.test.Client):
|
class Client(werkzeug.test.Client):
|
||||||
def __init__(self, application, path):
|
def __init__(self, application, path):
|
||||||
self._webhook_path = path
|
self._webhook_path = path
|
||||||
|
self.secret = None
|
||||||
super(Client, self).__init__(application, werkzeug.wrappers.BaseResponse)
|
super(Client, self).__init__(application, werkzeug.wrappers.BaseResponse)
|
||||||
|
|
||||||
def _make_env(self, event_type, data):
|
def _make_env(self, event_type, data):
|
||||||
|
headers = [('X-Github-Event', event_type)]
|
||||||
|
body = json.dumps(data).encode('utf-8')
|
||||||
|
if self.secret:
|
||||||
|
sig = hmac.new(self.secret.encode('ascii'), body, hashlib.sha1).hexdigest()
|
||||||
|
headers.append(('X-Hub-Signature', 'sha1=' + sig))
|
||||||
|
|
||||||
return werkzeug.test.EnvironBuilder(
|
return werkzeug.test.EnvironBuilder(
|
||||||
path=self._webhook_path,
|
path=self._webhook_path,
|
||||||
method='POST',
|
method='POST',
|
||||||
headers=[('X-Github-Event', event_type)],
|
headers=headers,
|
||||||
content_type='application/json',
|
content_type='application/json',
|
||||||
data=json.dumps(data),
|
data=body,
|
||||||
)
|
)
|
||||||
|
def _repo(self, name):
|
||||||
|
return {
|
||||||
|
'name': name.split('/')[1],
|
||||||
|
'full_name': name,
|
||||||
|
}
|
||||||
|
|
||||||
def pull_request(self, action, pr, changes=None):
|
def pull_request(self, action, pr, changes=None):
|
||||||
assert action in ('opened', 'reopened', 'closed', 'synchronize', 'edited')
|
assert action in ('opened', 'reopened', 'closed', 'synchronize', 'edited')
|
||||||
@ -569,6 +588,7 @@ class Client(werkzeug.test.Client):
|
|||||||
'pull_request', {
|
'pull_request', {
|
||||||
'action': action,
|
'action': action,
|
||||||
'pull_request': self._pr(pr),
|
'pull_request': self._pr(pr),
|
||||||
|
'repository': self._repo(pr.repo.name),
|
||||||
**({'changes': changes} if changes else {})
|
**({'changes': changes} if changes else {})
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
@ -589,10 +609,7 @@ class Client(werkzeug.test.Client):
|
|||||||
'user': {'login': user},
|
'user': {'login': user},
|
||||||
},
|
},
|
||||||
'pull_request': self._pr(pr),
|
'pull_request': self._pr(pr),
|
||||||
'repository': {
|
'repository': self._repo(pr.repo.name),
|
||||||
'name': pr.repo.name.split('/')[1],
|
|
||||||
'full_name': pr.repo.name,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
@ -604,6 +621,7 @@ class Client(werkzeug.test.Client):
|
|||||||
'context': context,
|
'context': context,
|
||||||
'state': state,
|
'state': state,
|
||||||
'sha': sha,
|
'sha': sha,
|
||||||
|
'repository': self._repo(repository),
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
@ -611,7 +629,7 @@ class Client(werkzeug.test.Client):
|
|||||||
contents = {
|
contents = {
|
||||||
'action': 'created',
|
'action': 'created',
|
||||||
'issue': { 'number': issue.number },
|
'issue': { 'number': issue.number },
|
||||||
'repository': { 'name': issue.repo.name.split('/')[1], 'full_name': issue.repo.name },
|
'repository': self._repo(issue.repo.name),
|
||||||
'sender': { 'login': user },
|
'sender': { 'login': user },
|
||||||
'comment': { 'body': body },
|
'comment': { 'body': body },
|
||||||
}
|
}
|
||||||
@ -631,10 +649,7 @@ class Client(werkzeug.test.Client):
|
|||||||
},
|
},
|
||||||
'base': {
|
'base': {
|
||||||
'ref': pr.base,
|
'ref': pr.base,
|
||||||
'repo': {
|
'repo': self._repo(pr.repo.name),
|
||||||
'name': pr.repo.name.split('/')[1],
|
|
||||||
'full_name': pr.repo.name,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'title': pr.title,
|
'title': pr.title,
|
||||||
'body': pr.body,
|
'body': pr.body,
|
||||||
|
@ -398,6 +398,18 @@ class Repo:
|
|||||||
self._session = session
|
self._session = session
|
||||||
self._tokens = user_tokens
|
self._tokens = user_tokens
|
||||||
|
|
||||||
|
def set_secret(self, secret):
|
||||||
|
r = self._session.get(
|
||||||
|
'https://api.github.com/repos/{}/hooks'.format(self.name))
|
||||||
|
response = r.json()
|
||||||
|
assert 200 <= r.status_code < 300, response
|
||||||
|
[hook] = response
|
||||||
|
|
||||||
|
r = self._session.patch('https://api.github.com/repos/{}/hooks/{}'.format(self.name, hook['id']), json={
|
||||||
|
'config': {**hook['config'], 'secret': secret},
|
||||||
|
})
|
||||||
|
assert 200 <= r.status_code < 300, r.json()
|
||||||
|
|
||||||
def get_ref(self, ref):
|
def get_ref(self, ref):
|
||||||
if re.match(r'[0-9a-f]{40}', ref):
|
if re.match(r'[0-9a-f]{40}', ref):
|
||||||
return ref
|
return ref
|
||||||
|
@ -60,6 +60,53 @@ def test_trivial_flow(env, repo):
|
|||||||
}
|
}
|
||||||
assert master.message, "gibberish\n\nblahblah\n\ncloses odoo/odoo#1"
|
assert master.message, "gibberish\n\nblahblah\n\ncloses odoo/odoo#1"
|
||||||
|
|
||||||
|
class TestWebhookSecurity:
|
||||||
|
def test_no_secret(self, env, project, repo):
|
||||||
|
""" Test 1: didn't add a secret to the repo, should be ignored
|
||||||
|
"""
|
||||||
|
project.secret = "a secret"
|
||||||
|
|
||||||
|
m = repo.make_commit(None, "initial", None, tree={'a': 'some content'})
|
||||||
|
repo.make_ref('heads/master', m)
|
||||||
|
|
||||||
|
c0 = repo.make_commit(m, 'replace file contents', None, tree={'a': 'some other content'})
|
||||||
|
pr0 = repo.make_pr("gibberish", "blahblah", target='master', ctid=c0, user='user')
|
||||||
|
|
||||||
|
assert not env['runbot_merge.pull_requests'].search([
|
||||||
|
('repository.name', '=', repo.name),
|
||||||
|
('number', '=', pr0.number),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_wrong_secret(self, env, project, repo):
|
||||||
|
repo.set_secret("wrong secret")
|
||||||
|
project.secret = "a secret"
|
||||||
|
|
||||||
|
m = repo.make_commit(None, "initial", None, tree={'a': 'some content'})
|
||||||
|
repo.make_ref('heads/master', m)
|
||||||
|
|
||||||
|
c0 = repo.make_commit(m, 'replace file contents', None, tree={'a': 'some other content'})
|
||||||
|
pr0 = repo.make_pr("gibberish", "blahblah", target='master', ctid=c0, user='user')
|
||||||
|
|
||||||
|
assert not env['runbot_merge.pull_requests'].search([
|
||||||
|
('repository.name', '=', repo.name),
|
||||||
|
('number', '=', pr0.number),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_correct_secret(self, env, project, repo):
|
||||||
|
repo.set_secret("a secret")
|
||||||
|
project.secret = "a secret"
|
||||||
|
|
||||||
|
m = repo.make_commit(None, "initial", None, tree={'a': 'some content'})
|
||||||
|
repo.make_ref('heads/master', m)
|
||||||
|
|
||||||
|
c0 = repo.make_commit(m, 'replace file contents', None, tree={'a': 'some other content'})
|
||||||
|
pr0 = repo.make_pr("gibberish", "blahblah", target='master', ctid=c0, user='user')
|
||||||
|
|
||||||
|
assert env['runbot_merge.pull_requests'].search([
|
||||||
|
('repository.name', '=', repo.name),
|
||||||
|
('number', '=', pr0.number),
|
||||||
|
])
|
||||||
|
|
||||||
def test_staging_conflict(env, repo):
|
def test_staging_conflict(env, repo):
|
||||||
# create base branch
|
# create base branch
|
||||||
m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'})
|
m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'})
|
||||||
|
Loading…
Reference in New Issue
Block a user