diff --git a/CHANGELOG.md b/CHANGELOG.md index 85da369d..21bb1a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## New features - [MS Power Automate] New Alert Channel with Microsoft Power Automate - [#1505](https://github.com/jertel/elastalert2/pull/1505) [#1513](https://github.com/jertel/elastalert2/pull/1513) [#1519](https://github.com/jertel/elastalert2/pull/1519) - @marssilva, @jertel +- [Matrix Hookshot] New Alerter for sending alerts to Matrix via Hookshot - [#1525](https://github.com/jertel/elastalert2/pull/1525) - @jertel ## Other changes - [Indexer] Fixed fields types error on instance indexer_alert_config in schema.yml - [#1499](https://github.com/jertel/elastalert2/pull/1499) - @olehpalanskyi diff --git a/docs/source/alerts.rst b/docs/source/alerts.rst index 8d476d11..4bac3aaf 100644 --- a/docs/source/alerts.rst +++ b/docs/source/alerts.rst @@ -33,6 +33,7 @@ or - jira - lark - linenotify + - matrixhookshot - mattermost - ms_teams - ms_power_automate @@ -1424,6 +1425,33 @@ Example usage:: - "linenotify" linenotify_access_token: "Your linenotify access token" +Matrix Hookshot +~~~~~~~~~~~~~~~ + +The Matrix Hookshot alerter will send a notification to a Hookshot server that's already setup within the Matrix server. The body of the notification is formatted the same as with other alerters. + +See the Hookshot Webhook documentation for more information: https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html#webhook-handling + +The alerter requires the following option: + +``matrixhookshot_webhook_url``: The webhook URL that was provided to you by the hookshot bot. Ex: https://XXXXX.com/webhook/6de1f483-5c4b-4bb8-784a-f09129f45225. You can also use a list of URLs to send to multiple webhooks. + +Optional: + +``matrixhookshot_username``: Optional username to prepend to the text body. + +``matrixhookshot_text``: Override the default alert text with custom text formatting. + +``matrixhookshot_html``: Specify HTML alert content to use instead of the default alert text. + +``matrixhookshot_proxy``: By default ElastAlert 2 will not use a network proxy to send notifications to Hookshot. Set this option using ``hostname:port`` if you need to use a proxy. only supports https. + +``matrixhookshot_ignore_ssl_errors``: By default ElastAlert 2 will verify SSL certificate. Set this option to ``True`` if you want to ignore SSL errors. + +``matrixhookshot_timeout``: You can specify a timeout value, in seconds, for making communicating with Hookshot. The default is 10. If a timeout occurs, the alert will be retried next time ElastAlert 2 cycles. + +``matrixhookshot_ca_certs``: Set this option to ``True`` or a path to a CA cert bundle or directory (eg: ``/etc/ssl/certs/ca-certificates.crt``) to validate the SSL certificate. + Mattermost ~~~~~~~~~~ diff --git a/docs/source/elastalert.rst b/docs/source/elastalert.rst index da49c6fd..7df839d2 100755 --- a/docs/source/elastalert.rst +++ b/docs/source/elastalert.rst @@ -49,6 +49,7 @@ Currently, we have support built in for these alert types: - Jira - Lark - Line Notify +- Matrix Hookshot - Mattermost - Microsoft Teams - Microsoft Power Automate diff --git a/elastalert/alerters/matrixhookshot.py b/elastalert/alerters/matrixhookshot.py new file mode 100644 index 00000000..dc4da224 --- /dev/null +++ b/elastalert/alerters/matrixhookshot.py @@ -0,0 +1,81 @@ +import copy +import json +import requests +import warnings + +from elastalert.alerts import Alerter, DateTimeEncoder +from elastalert.util import elastalert_logger, EAException, lookup_es_key +from requests.exceptions import RequestException + + +class MatrixHookshotAlerter(Alerter): + """ Creates a Matrix Hookshot room message for each alert """ + required_options = frozenset(['matrixhookshot_webhook_url']) + + def __init__(self, rule): + super(MatrixHookshotAlerter, self).__init__(rule) + self.matrixhookshot_webhook_url = self.rule.get('matrixhookshot_webhook_url', None) + if isinstance(self.matrixhookshot_webhook_url, str): + self.matrixhookshot_webhook_url = [self.matrixhookshot_webhook_url] + self.matrixhookshot_proxy = self.rule.get('matrixhookshot_proxy', None) + self.matrixhookshot_username = self.rule.get('matrixhookshot_username', '') + self.matrixhookshot_text = self.rule.get('matrixhookshot_text', '') + self.matrixhookshot_html = self.rule.get('matrixhookshot_html', '') + self.matrixhookshot_ignore_ssl_errors = self.rule.get('matrixhookshot_ignore_ssl_errors', False) + self.matrixhookshot_timeout = self.rule.get('matrixhookshot_timeout', 10) + self.matrixhookshot_ca_certs = self.rule.get('matrixhookshot_ca_certs') + + def format_body(self, body): + # https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html + return body + + def get_aggregation_summary_text__maximum_width(self): + width = super(MatrixHookshotAlerter, self).get_aggregation_summary_text__maximum_width() + # Reduced maximum width for prettier MatrixHookshot display. + return min(width, 75) + + def get_aggregation_summary_text(self, matches): + text = super(MatrixHookshotAlerter, self).get_aggregation_summary_text(matches) + if text: + text = '```\n{0}```\n'.format(text) + return text + + def alert(self, matches): + body = self.create_alert_body(matches) + + body = self.format_body(body) + # post to matrixhookshot + headers = {'content-type': 'application/json'} + # set https proxy, if it was provided + proxies = {'https': self.matrixhookshot_proxy} if self.matrixhookshot_proxy else None + payload = { + 'text': body + } + if self.matrixhookshot_username: + payload['username'] = self.matrixhookshot_username + if self.matrixhookshot_html: + payload['html'] = self.matrixhookshot_html + if self.matrixhookshot_text: + payload['text'] = self.matrixhookshot_text + + for url in self.matrixhookshot_webhook_url: + try: + if self.matrixhookshot_ca_certs: + verify = self.matrixhookshot_ca_certs + else: + verify = not self.matrixhookshot_ignore_ssl_errors + if self.matrixhookshot_ignore_ssl_errors: + requests.packages.urllib3.disable_warnings() + response = requests.post( + url, data=json.dumps(payload, cls=DateTimeEncoder), + headers=headers, verify=verify, + proxies=proxies, + timeout=self.matrixhookshot_timeout) + warnings.resetwarnings() + response.raise_for_status() + except RequestException as e: + raise EAException("Error posting to matrixhookshot: %s" % e) + elastalert_logger.info("Alert '%s' sent to MatrixHookshot" % self.rule['name']) + + def get_info(self): + return {'type': 'matrixhookshot' } diff --git a/elastalert/loaders.py b/elastalert/loaders.py index ac377960..7075dd5a 100644 --- a/elastalert/loaders.py +++ b/elastalert/loaders.py @@ -44,6 +44,7 @@ from elastalert.alerters.alertmanager import AlertmanagerAlerter from elastalert.alerters.email import EmailAlerter from elastalert.alerters.jira import JiraAlerter +from elastalert.alerters.matrixhookshot import MatrixHookshotAlerter from elastalert.alerters.mattermost import MattermostAlerter from elastalert.alerters.opsgenie import OpsGenieAlerter from elastalert.alerters.pagerduty import PagerDutyAlerter @@ -141,6 +142,7 @@ class RulesLoader(object): 'gelf': elastalert.alerters.gelf.GelfAlerter, 'iris': elastalert.alerters.iris.IrisAlerter, 'indexer': IndexerAlerter, + 'matrixhookshot': MatrixHookshotAlerter, } # A partial ordering of alert types. Relative order will be preserved in the resulting alerts list diff --git a/elastalert/schema.yaml b/elastalert/schema.yaml index b0e94818..aee33262 100644 --- a/elastalert/schema.yaml +++ b/elastalert/schema.yaml @@ -642,6 +642,16 @@ properties: ### Line Notify linenotify_access_token: {type: string} + ### Matrix Hookshot + matrixhookshot_ca_certs: {type: [boolean, string]} + matrixhookshot_webhook_url: *arrayOfString + matrixhookshot_username: {type: string} + matrixhookshot_html: {type: string} + matrixhookshot_text: {type: string} + matrixhookshot_proxy: {type: string} + matrixhookshot_ignore_ssl_errors: {type: boolean} + matrixhookshot_timeout: {type: integer} + ### Mattermost mattermost_webhook_url: *arrayOfString mattermost_proxy: {type: string} diff --git a/tests/alerters/matrixhookshot_test.py b/tests/alerters/matrixhookshot_test.py new file mode 100644 index 00000000..a0c58b83 --- /dev/null +++ b/tests/alerters/matrixhookshot_test.py @@ -0,0 +1,334 @@ +import json +import logging +import pytest + +from unittest import mock + +from requests import RequestException + +from elastalert.alerters.matrixhookshot import MatrixHookshotAlerter +from elastalert.loaders import FileRulesLoader +from elastalert.util import EAException + + +def test_matrixhookshot_uses_custom_title(caplog): + caplog.set_level(logging.INFO) + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'alert': [] + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + match = { + '@timestamp': '2017-01-01T00:00:00', + 'somefield': 'foobarbaz' + } + with mock.patch('requests.post') as mock_post_request: + alert.alert([match]) + + expected_data = { + 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n', + } + mock_post_request.assert_called_once_with( + rule['matrixhookshot_webhook_url'], + data=mock.ANY, + headers={'content-type': 'application/json'}, + proxies=None, + verify=True, + timeout=10 + ) + assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data']) + assert ('elastalert', logging.INFO, "Alert 'Test Rule' sent to MatrixHookshot") == caplog.record_tuples[0] + + +def test_matrixhookshot_uses_custom_timeout(): + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'alert': [], + 'matrixhookshot_timeout': 20 + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + match = { + '@timestamp': '2017-01-01T00:00:00', + 'somefield': 'foobarbaz' + } + with mock.patch('requests.post') as mock_post_request: + alert.alert([match]) + + expected_data = { + 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n' + } + mock_post_request.assert_called_once_with( + rule['matrixhookshot_webhook_url'], + data=mock.ANY, + headers={'content-type': 'application/json'}, + proxies=None, + verify=True, + timeout=20 + ) + assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data']) + + +def test_matrixhookshot_uses_rule_name_when_custom_title_is_not_provided(): + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': ['http://please.dontgohere.matrixhookshot'], + 'alert': [] + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + match = { + '@timestamp': '2017-01-01T00:00:00', + 'somefield': 'foobarbaz' + } + with mock.patch('requests.post') as mock_post_request: + alert.alert([match]) + + expected_data = { + 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n', + } + mock_post_request.assert_called_once_with( + rule['matrixhookshot_webhook_url'][0], + data=mock.ANY, + headers={'content-type': 'application/json'}, + proxies=None, + verify=True, + timeout=10 + ) + assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data']) + + +def test_matrixhookshot_proxy(): + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'matrixhookshot_proxy': 'http://proxy.url', + 'alert': [] + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + match = { + '@timestamp': '2017-01-01T00:00:00', + 'somefield': 'foobarbaz' + } + with mock.patch('requests.post') as mock_post_request: + alert.alert([match]) + + expected_data = { + 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n', + } + mock_post_request.assert_called_once_with( + rule['matrixhookshot_webhook_url'], + data=mock.ANY, + headers={'content-type': 'application/json'}, + proxies={'https': rule['matrixhookshot_proxy']}, + verify=True, + timeout=10 + ) + assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data']) + + +def test_matrixhookshot_username(): + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'matrixhookshot_username': 'test elastalert', + 'alert': [] + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + match = { + '@timestamp': '2017-01-01T00:00:00', + 'somefield': 'foobarbaz' + } + with mock.patch('requests.post') as mock_post_request: + alert.alert([match]) + + expected_data = { + 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n', + 'username': 'test elastalert', + } + mock_post_request.assert_called_once_with( + rule['matrixhookshot_webhook_url'], + data=mock.ANY, + headers={'content-type': 'application/json'}, + proxies=None, + verify=True, + timeout=10 + ) + assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data']) + + +def test_matrixhookshot_text_html(): + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'matrixhookshot_text': 'Hello', + 'matrixhookshot_html': 'Hello', + 'alert': [] + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + match = { + '@timestamp': '2017-01-01T00:00:00', + 'somefield': 'foobarbaz' + } + with mock.patch('requests.post') as mock_post_request: + alert.alert([match]) + + expected_data = { + 'text': 'Hello', + 'html': 'Hello', + } + mock_post_request.assert_called_once_with( + rule['matrixhookshot_webhook_url'], + data=mock.ANY, + headers={'content-type': 'application/json'}, + proxies=None, + verify=True, + timeout=10 + ) + assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data']) + + +@pytest.mark.parametrize('ca_certs, ignore_ssl_errors, expect_verify', [ + ('', '', True), + ('', True, False), + ('', False, True), + (True, '', True), + (True, True, True), + (True, False, True), + (False, '', True), + (False, True, False), + (False, False, True) +]) +def test_matrixhookshot_ca_certs(ca_certs, ignore_ssl_errors, expect_verify): + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'alert': [] + } + if ca_certs: + rule['matrixhookshot_ca_certs'] = ca_certs + + if ignore_ssl_errors: + rule['matrixhookshot_ignore_ssl_errors'] = ignore_ssl_errors + + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + match = { + '@timestamp': '2017-01-01T00:00:00', + 'somefield': 'foobarbaz' + } + with mock.patch('requests.post') as mock_post_request: + alert.alert([match]) + + expected_data = { + 'text': 'Test Rule\n\n@timestamp: 2017-01-01T00:00:00\nsomefield: foobarbaz\n', + } + mock_post_request.assert_called_once_with( + rule['matrixhookshot_webhook_url'], + data=mock.ANY, + headers={'content-type': 'application/json'}, + proxies=None, + verify=expect_verify, + timeout=10 + ) + assert expected_data == json.loads(mock_post_request.call_args_list[0][1]['data']) + + +def test_matrixhookshot_ea_exception(): + with pytest.raises(EAException) as ea: + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'alert': [] + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + match = { + '@timestamp': '2017-01-01T00:00:00', + 'somefield': 'foobarbaz' + } + mock_run = mock.MagicMock(side_effect=RequestException) + with mock.patch('requests.post', mock_run), pytest.raises(RequestException): + alert.alert([match]) + assert 'Error posting to matrixhookshot: ' in str(ea) + + +def test_matrixhookshot_get_aggregation_summary_text__maximum_width(): + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'alert': [] + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + assert 75 == alert.get_aggregation_summary_text__maximum_width() + + +def test_matrixhookshot_getinfo(): + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'matrixhookshot_webhook_url': 'http://please.dontgohere.matrixhookshot', + 'alert': [] + } + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + + expected_data = { + 'type': 'matrixhookshot' + } + actual_data = alert.get_info() + assert expected_data == actual_data + + +@pytest.mark.parametrize('matrixhookshot_webhook_url, expected_data', [ + ('', 'Missing required option(s): matrixhookshot_webhook_url'), + ('http://please.dontgohere.matrixhookshot', + { + 'type': 'matrixhookshot', + }), +]) +def test_matrixhookshot_required_error(matrixhookshot_webhook_url, expected_data): + try: + rule = { + 'name': 'Test Rule', + 'type': 'any', + 'alert': [] + } + + if matrixhookshot_webhook_url: + rule['matrixhookshot_webhook_url'] = matrixhookshot_webhook_url + + rules_loader = FileRulesLoader({}) + rules_loader.load_modules(rule) + alert = MatrixHookshotAlerter(rule) + + actual_data = alert.get_info() + assert expected_data == actual_data + except Exception as ea: + assert expected_data in str(ea)