From 1340d9d1cada76fce7ea2acdf7d6bdacf2163b19 Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Tue, 17 Dec 2024 16:27:40 +0100 Subject: [PATCH 1/9] feat: add disable rule button in slack message --- run/controllers/security.js | 4 +++- test/acceptance/run/security_test.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/run/controllers/security.js b/run/controllers/security.js index 14db0903..eafb7504 100644 --- a/run/controllers/security.js +++ b/run/controllers/security.js @@ -4,7 +4,7 @@ import { config } from '../../config.js'; import * as cdnServices from '../services/cdn.js'; import { logger } from '../../common/services/logger.js'; import slackPostMessageService from '../../common/services/slack/surfaces/messages/post-message.js'; -import { Attachment, Context, Message, Section } from 'slack-block-builder'; +import { Actions, Attachment, Button, Context, Divider, Message, Section } from 'slack-block-builder'; const _buildSlackMessage = function ({ ip, ja3 }) { return { @@ -17,6 +17,8 @@ const _buildSlackMessage = function ({ ip, ja3 }) { Section().fields(`IP`, `${ip}`), Section().fields(`JA3`, `${ja3}`), Context().elements(`At ${new Date().toLocaleString()}`), + Divider(), + Actions().elements(Button().text('Désactiver').actionId('disable-automatic-rule').danger()), ) .fallback('Règle de blocage mise en place sur Baleen.'), ) diff --git a/test/acceptance/run/security_test.js b/test/acceptance/run/security_test.js index fb30951a..60ab9c99 100644 --- a/test/acceptance/run/security_test.js +++ b/test/acceptance/run/security_test.js @@ -108,6 +108,23 @@ describe('Acceptance | Run | Security', function () { ], type: 'context', }, + { + type: 'divider', + }, + { + elements: [ + { + text: { + type: 'plain_text', + text: 'Désactiver', + }, + action_id: 'disable-automatic-rule', + style: 'danger', + type: 'button', + }, + ], + type: 'actions', + }, ], fallback: 'Règle de blocage mise en place sur Baleen.', }, From f8a1127ea0fb352eb3145c310a71af39e0f7b3f2 Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Wed, 18 Dec 2024 11:06:02 +0100 Subject: [PATCH 2/9] feat: return added rules in blockAccess() --- run/services/cdn.js | 37 +++++++++++++++++++++-- test/acceptance/run/security_test.js | 6 ++-- test/integration/run/services/cdn_test.js | 4 +-- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/run/services/cdn.js b/run/services/cdn.js index 314ff8cf..19675dfb 100644 --- a/run/services/cdn.js +++ b/run/services/cdn.js @@ -92,9 +92,10 @@ async function blockAccess({ ip, ja3, monitorId }) { const namespaceKeys = await _getNamespaceKey(config.baleen.protectedFrontApps); + const addedRules = []; for (const namespaceKey of namespaceKeys) { try { - await axios.post( + const response = await axios.post( `${CDN_URL}/configs/custom-static-rules`, { category: 'block', @@ -117,6 +118,8 @@ async function blockAccess({ ip, ja3, monitorId }) { }, }, ); + + addedRules.push({ namespaceKey, ruleId: response.data.id }); } catch (error) { const cdnResponseMessage = JSON.stringify(error.response.data); const message = `Request failed with status code ${error.response.status} and message ${cdnResponseMessage}`; @@ -124,7 +127,35 @@ async function blockAccess({ ip, ja3, monitorId }) { } } - return `Règle de blocage mise en place.`; + return addedRules; +} + +async function disableRule({ namespaceKey, ruleId }) { + if (!namespaceKey || namespaceKey === '') { + throw new Error('namespaceKey cannot be empty.'); + } + + if (!ruleId || ruleId === '') { + throw new Error('ruleId cannot be empty.'); + } + + try { + await axios.patch( + `${CDN_URL}/configs/custom-static-rules/${ruleId}`, + { enabled: true }, + { + headers: { + 'X-Api-Key': config.baleen.pat, + 'Content-type': 'application/json', + Cookie: `baleen-namespace=${namespaceKey}`, + }, + }, + ); + } catch (error) { + const cdnResponseMessage = JSON.stringify(error.response.data); + const message = `Request failed with status code ${error.response.status} and message ${cdnResponseMessage}`; + throw new Error(message); + } } -export { blockAccess, invalidateCdnCache, NamespaceNotFoundError }; +export { blockAccess, disableRule, invalidateCdnCache, NamespaceNotFoundError }; diff --git a/test/acceptance/run/security_test.js b/test/acceptance/run/security_test.js index 60ab9c99..6f3a7ada 100644 --- a/test/acceptance/run/security_test.js +++ b/test/acceptance/run/security_test.js @@ -23,6 +23,7 @@ describe('Acceptance | Run | Security', function () { const monitorId = '1234'; const ip = '127.0.0.1'; const ja3 = '9709730930'; + const addedRuleId = 'aa1c6158-9512-4e56-a93e-cc8c4de9bc23'; nock('https://console.baleen.cloud/api', { reqheaders: { @@ -58,7 +59,7 @@ describe('Acceptance | Run | Security', function () { ], ], }) - .reply(200); + .reply(200, { id: addedRuleId }); nock('https://slack.com', { reqheaders: { @@ -121,6 +122,7 @@ describe('Acceptance | Run | Security', function () { action_id: 'disable-automatic-rule', style: 'danger', type: 'button', + value: '[{"namespaceKey":"namespace-key1","ruleId":"aa1c6158-9512-4e56-a93e-cc8c4de9bc23"}]', }, ], type: 'actions', @@ -148,7 +150,7 @@ describe('Acceptance | Run | Security', function () { }); expect(res.statusCode).to.equal(200); - expect(res.result).to.eql('Règle de blocage mise en place.'); + expect(res.result).to.equal(`Règles de blocage ${addedRuleId} mises en place.`); expect(nock.isDone()).to.be.true; }); }); diff --git a/test/integration/run/services/cdn_test.js b/test/integration/run/services/cdn_test.js index 155c3a33..57872c88 100644 --- a/test/integration/run/services/cdn_test.js +++ b/test/integration/run/services/cdn_test.js @@ -53,7 +53,7 @@ function _stubCustomStaticRulePost(namespaceKey, monitorId, ip, ja3) { ], ], }) - .reply(200); + .reply(200, { id: '1234' }); } describe('Integration | CDN', function () { @@ -322,7 +322,7 @@ describe('Integration | CDN', function () { // then postCustomStaticRules.done(); - expect(result).to.equal('Règle de blocage mise en place.'); + expect(result).to.deep.equal([{ namespaceKey: 'namespace-key1', ruleId: '1234' }]); }); it('should throw an error with statusCode and message', async function () { From aa693799c6857d2a4f57ff0ebc70fb223f3b893c Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Fri, 3 Jan 2025 14:25:53 +0100 Subject: [PATCH 3/9] refactor: add class AutomaticRule to centralize slack messages --- run/controllers/security.js | 29 +++-------------- run/models/AutomaticRule.js | 47 ++++++++++++++++++++++++++++ test/acceptance/run/security_test.js | 5 +-- 3 files changed, 55 insertions(+), 26 deletions(-) create mode 100644 run/models/AutomaticRule.js diff --git a/run/controllers/security.js b/run/controllers/security.js index eafb7504..83df5d39 100644 --- a/run/controllers/security.js +++ b/run/controllers/security.js @@ -4,27 +4,7 @@ import { config } from '../../config.js'; import * as cdnServices from '../services/cdn.js'; import { logger } from '../../common/services/logger.js'; import slackPostMessageService from '../../common/services/slack/surfaces/messages/post-message.js'; -import { Actions, Attachment, Button, Context, Divider, Message, Section } from 'slack-block-builder'; - -const _buildSlackMessage = function ({ ip, ja3 }) { - return { - channel: `#${config.slack.blockedAccessesChannel}`, - message: 'Règle de blocage mise en place sur Baleen.', - attachments: Message() - .attachments( - Attachment({ color: '#106c1f' }) - .blocks( - Section().fields(`IP`, `${ip}`), - Section().fields(`JA3`, `${ja3}`), - Context().elements(`At ${new Date().toLocaleString()}`), - Divider(), - Actions().elements(Button().text('Désactiver').actionId('disable-automatic-rule').danger()), - ) - .fallback('Règle de blocage mise en place sur Baleen.'), - ) - .buildToObject().attachments, - }; -}; +import { AutomaticRule } from '../models/AutomaticRule.js'; const securities = { async blockAccessOnBaleen(request) { @@ -64,9 +44,10 @@ const securities = { } try { - const result = await cdnServices.blockAccess({ ip, ja3, monitorId }); - await slackPostMessageService.postMessage(_buildSlackMessage({ ip, ja3 })); - return result; + const addedRules = await cdnServices.blockAccess({ ip, ja3, monitorId }); + const automaticRule = new AutomaticRule({ ip, ja3 }); + await slackPostMessageService.postMessage(automaticRule.getInitialMessage({ addedRules })); + return `Règles de blocage mises en place.`; } catch (error) { if (error instanceof cdnServices.NamespaceNotFoundError) { return Boom.badRequest(); diff --git a/run/models/AutomaticRule.js b/run/models/AutomaticRule.js new file mode 100644 index 00000000..161faa81 --- /dev/null +++ b/run/models/AutomaticRule.js @@ -0,0 +1,47 @@ +import { config } from '../../config.js'; +import { Actions, Attachment, Button, Context, Divider, Message, Section } from 'slack-block-builder'; +import dayjs from 'dayjs'; + +export class AutomaticRule { + static DISABLE = 'disable-automatic-rule'; + + constructor({ ip, ja3, date = dayjs() }) { + this.ip = ip; + this.ja3 = ja3; + this.date = date; + } + + getInitialMessage({ addedRules }) { + return this.#buildMessage({ isActive: true, addedRules }); + } + + #buildMessage({ isActive, addedRules }) { + return { + channel: `#${config.slack.blockedAccessesChannel}`, + message: 'Règle de blocage mise en place sur Baleen.', + attachments: Message() + .attachments( + Attachment({ color: '#106c1f' }) + .blocks( + Section().fields(`IP`, `${this.ip}`), + Section().fields(`JA3`, `${this.ja3}`), + Context().elements(`At ${this.date.format('DD/MM/YYYY HH:mm:ss')}`), + Divider(), + this.#buildMessageFooter({ isActive, addedRules }), + ) + .fallback('Règle de blocage mise en place sur Baleen.'), + ) + .buildToObject().attachments, + }; + } + + #buildMessageFooter({ isActive, addedRules }) { + if (isActive) { + return Actions().elements( + Button().text('Désactiver').actionId(AutomaticRule.DISABLE).value(JSON.stringify(addedRules)).danger(), + ); + } else { + return Section().fields(`Règle désactivée le`, `${dayjs().format('DD/MM/YYYY HH:mm:ss')}`); + } + } +} diff --git a/test/acceptance/run/security_test.js b/test/acceptance/run/security_test.js index 6f3a7ada..bcf306c5 100644 --- a/test/acceptance/run/security_test.js +++ b/test/acceptance/run/security_test.js @@ -1,6 +1,7 @@ import { config } from '../../../config.js'; import server from '../../../server.js'; import { expect, nock, sinon } from '../../test-helper.js'; +import dayjs from 'dayjs'; describe('Acceptance | Run | Security', function () { let now; @@ -103,7 +104,7 @@ describe('Acceptance | Run | Security', function () { { elements: [ { - text: `At ${now.toLocaleString()}`, + text: `At ${dayjs(now).format('DD/MM/YYYY HH:mm:ss')}`, type: 'mrkdwn', }, ], @@ -150,7 +151,7 @@ describe('Acceptance | Run | Security', function () { }); expect(res.statusCode).to.equal(200); - expect(res.result).to.equal(`Règles de blocage ${addedRuleId} mises en place.`); + expect(res.result).to.equal(`Règles de blocage mises en place.`); expect(nock.isDone()).to.be.true; }); }); From 38eba2c1bc141fecb82179c128bd92714bff7159 Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Fri, 3 Jan 2025 14:26:22 +0100 Subject: [PATCH 4/9] refactor: fix typo --- common/services/slack/surfaces/messages/post-message.js | 2 +- .../services/slack/surfaces/messages/post-message_test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/services/slack/surfaces/messages/post-message.js b/common/services/slack/surfaces/messages/post-message.js index 08e72665..17fb52df 100644 --- a/common/services/slack/surfaces/messages/post-message.js +++ b/common/services/slack/surfaces/messages/post-message.js @@ -20,7 +20,7 @@ async function postMessage({ message, attachments, channel = '#tech-releases', i if (!slackResponse.data.ok) { logger.error({ event: 'slack-post-message', - message: `Slack error occured while sending message : ${slackResponse.data.error}`, + message: `Slack error occurred while sending message : ${slackResponse.data.error}`, stack: `Payload for error was ${JSON.stringify(payload)}`, }); } diff --git a/test/unit/common/services/slack/surfaces/messages/post-message_test.js b/test/unit/common/services/slack/surfaces/messages/post-message_test.js index ee25505e..08ecba03 100644 --- a/test/unit/common/services/slack/surfaces/messages/post-message_test.js +++ b/test/unit/common/services/slack/surfaces/messages/post-message_test.js @@ -58,7 +58,7 @@ describe('Unit | Common | Services | Slack | Surfaces | Messages | Post-Message' }); expect(errorLoggerStub).to.have.been.calledOnceWith({ event: 'slack-post-message', - message: 'Slack error occured while sending message : not_in_channel', + message: 'Slack error occurred while sending message : not_in_channel', stack: 'Payload for error was {"channel":"#mychannel","text":"test message","attachments":{}}', }); }); From c5ebd053bb59a4af02fe5f7f0269e1060c11aa6a Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Fri, 3 Jan 2025 15:06:05 +0100 Subject: [PATCH 5/9] feat: add slack update message service --- .../slack/surfaces/messages/update-message.js | 32 ++++++++ .../surfaces/messages/update-message_test.js | 82 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 common/services/slack/surfaces/messages/update-message.js create mode 100644 test/unit/common/services/slack/surfaces/messages/update-message_test.js diff --git a/common/services/slack/surfaces/messages/update-message.js b/common/services/slack/surfaces/messages/update-message.js new file mode 100644 index 00000000..c0619f45 --- /dev/null +++ b/common/services/slack/surfaces/messages/update-message.js @@ -0,0 +1,32 @@ +import { config } from '../../../../../config.js'; +import { httpAgent } from '../../../../http-agent.js'; +import { logger } from '../../../logger.js'; + +async function updateMessage({ message, ts, attachments, channel = '#tech-releases', injectedHttpAgent = httpAgent }) { + const url = 'https://slack.com/api/chat.update'; + + const headers = { + 'content-type': 'application/json', + authorization: `Bearer ${config.slack.botToken}`, + }; + const payload = { + channel, + ts, + as_user: true, + text: message, + attachments: attachments, + }; + + const slackResponse = await injectedHttpAgent.post({ url, payload, headers }); + if (slackResponse.isSuccessful) { + if (!slackResponse.data.ok) { + logger.error({ + event: 'slack-update-message', + message: `Slack error occurred while sending message : ${slackResponse.data.error}`, + stack: `Payload for error was ${JSON.stringify(payload)}`, + }); + } + } +} + +export default { updateMessage }; diff --git a/test/unit/common/services/slack/surfaces/messages/update-message_test.js b/test/unit/common/services/slack/surfaces/messages/update-message_test.js new file mode 100644 index 00000000..b0b1369f --- /dev/null +++ b/test/unit/common/services/slack/surfaces/messages/update-message_test.js @@ -0,0 +1,82 @@ +import { logger } from '../../../../../../../common/services/logger.js'; +import slackPostMessageService from '../../../../../../../common/services/slack/surfaces/messages/update-message.js'; +import { config } from '../../../../../../../config.js'; +import { expect, sinon } from '../../../../../../test-helper.js'; + +describe('Unit | Common | Services | Slack | Surfaces | Messages | Update-Message', function () { + describe('updateMessage', function () { + it('should make a call to slack API update-message endpoint', async function () { + //given + const messageToSend = 'test message'; + const destinationChannel = '#mychannel'; + const messageTimestamp = '1735836582.877169'; + sinon.stub(config.slack, 'botToken').value('faketoken'); + const httpAgent = { post: sinon.stub().resolves({ isSuccessful: true, data: { ok: true } }) }; + //when + await slackPostMessageService.updateMessage({ + message: messageToSend, + ts: messageTimestamp, + attachments: {}, + channel: destinationChannel, + injectedHttpAgent: httpAgent, + }); + + //then + expect(httpAgent.post).to.have.been.calledOnceWith({ + url: 'https://slack.com/api/chat.update', + payload: { + channel: '#mychannel', + ts: '1735836582.877169', + as_user: true, + text: 'test message', + attachments: {}, + }, + headers: { + 'content-type': 'application/json', + authorization: 'Bearer faketoken', + }, + }); + }); + + it('should log slack API errors', async function () { + //given + const messageToSend = 'test message'; + const destinationChannel = '#mychannel'; + const messageTimestamp = '1735836582.877169'; + sinon.stub(config.slack, 'botToken').value('faketoken'); + const errorLoggerStub = sinon.stub(logger, 'error'); + const slackErrorResponse = { isSuccessful: true, data: { ok: false, error: 'not_in_channel' } }; + const httpAgent = { post: sinon.stub().resolves(slackErrorResponse) }; + + //when + await slackPostMessageService.updateMessage({ + message: messageToSend, + ts: messageTimestamp, + attachments: {}, + channel: destinationChannel, + injectedHttpAgent: httpAgent, + }); + + //then + expect(httpAgent.post).to.have.been.calledOnceWith({ + url: 'https://slack.com/api/chat.update', + payload: { + channel: '#mychannel', + ts: '1735836582.877169', + as_user: true, + text: 'test message', + attachments: {}, + }, + headers: { + 'content-type': 'application/json', + authorization: 'Bearer faketoken', + }, + }); + expect(errorLoggerStub).to.have.been.calledOnceWith({ + event: 'slack-update-message', + message: 'Slack error occurred while sending message : not_in_channel', + stack: `Payload for error was {"channel":"#mychannel","ts":"1735836582.877169","as_user":true,"text":"test message","attachments":{}}`, + }); + }); + }); +}); From 5c867305d37e05cf02075bbbb671cc713ede2db0 Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Mon, 6 Jan 2025 14:31:40 +0100 Subject: [PATCH 6/9] refactor: change cdn service import (to allow stubbing in tests) --- run/controllers/applications.js | 6 ++--- run/controllers/security.js | 6 ++--- run/services/cdn.js | 4 ++-- test/integration/run/services/cdn_test.js | 28 +++++++++++------------ 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/run/controllers/applications.js b/run/controllers/applications.js index 23053c78..7aca2fc1 100644 --- a/run/controllers/applications.js +++ b/run/controllers/applications.js @@ -1,7 +1,7 @@ import Boom from '@hapi/boom'; import { config } from '../../config.js'; -import * as cdnServices from '../services/cdn.js'; +import cdnService from '../services/cdn.js'; const applications = { async invalidateCdnCache(request) { @@ -10,9 +10,9 @@ const applications = { } try { - return await cdnServices.invalidateCdnCache(request.params.name); + return await cdnService.invalidateCdnCache(request.params.name); } catch (error) { - if (error instanceof cdnServices.NamespaceNotFoundError) { + if (error instanceof cdnService.NamespaceNotFoundError) { return Boom.badRequest(); } return error; diff --git a/run/controllers/security.js b/run/controllers/security.js index 83df5d39..0ea80ae5 100644 --- a/run/controllers/security.js +++ b/run/controllers/security.js @@ -1,7 +1,7 @@ import Boom from '@hapi/boom'; import { config } from '../../config.js'; -import * as cdnServices from '../services/cdn.js'; +import cdnService from '../services/cdn.js'; import { logger } from '../../common/services/logger.js'; import slackPostMessageService from '../../common/services/slack/surfaces/messages/post-message.js'; import { AutomaticRule } from '../models/AutomaticRule.js'; @@ -44,12 +44,12 @@ const securities = { } try { - const addedRules = await cdnServices.blockAccess({ ip, ja3, monitorId }); + const addedRules = await cdnService.blockAccess({ ip, ja3, monitorId }); const automaticRule = new AutomaticRule({ ip, ja3 }); await slackPostMessageService.postMessage(automaticRule.getInitialMessage({ addedRules })); return `Règles de blocage mises en place.`; } catch (error) { - if (error instanceof cdnServices.NamespaceNotFoundError) { + if (error instanceof cdnService.NamespaceNotFoundError) { return Boom.badRequest(); } return error; diff --git a/run/services/cdn.js b/run/services/cdn.js index 19675dfb..3317357e 100644 --- a/run/services/cdn.js +++ b/run/services/cdn.js @@ -142,7 +142,7 @@ async function disableRule({ namespaceKey, ruleId }) { try { await axios.patch( `${CDN_URL}/configs/custom-static-rules/${ruleId}`, - { enabled: true }, + { enabled: false }, { headers: { 'X-Api-Key': config.baleen.pat, @@ -158,4 +158,4 @@ async function disableRule({ namespaceKey, ruleId }) { } } -export { blockAccess, disableRule, invalidateCdnCache, NamespaceNotFoundError }; +export default { blockAccess, disableRule, invalidateCdnCache, NamespaceNotFoundError }; diff --git a/test/integration/run/services/cdn_test.js b/test/integration/run/services/cdn_test.js index 57872c88..d878fdf0 100644 --- a/test/integration/run/services/cdn_test.js +++ b/test/integration/run/services/cdn_test.js @@ -1,7 +1,7 @@ import { describe, it } from 'mocha'; import { config } from '../../../../config.js'; -import * as cdn from '../../../../run/services/cdn.js'; +import cdnService from '../../../../run/services/cdn.js'; import { catchErr, expect, nock } from '../../../test-helper.js'; function _stubAccountDetails(namespace) { @@ -83,7 +83,7 @@ describe('Integration | CDN', function () { const postInvalidationCache = _stubInvalidationCachePost(namespaceKey); // when - const result = await cdn.invalidateCdnCache(applicationName); + const result = await cdnService.invalidateCdnCache(applicationName); // then postInvalidationCache.done(); @@ -100,7 +100,7 @@ describe('Integration | CDN', function () { _stubInvalidationCachePost(namespaceKey); // when - await cdn.invalidateCdnCache(applicationName); + await cdnService.invalidateCdnCache(applicationName); // then getAccountDetails.done(); @@ -119,10 +119,10 @@ describe('Integration | CDN', function () { _stubInvalidationCachePost(namespaceKey); // when - const result = await catchErr(cdn.invalidateCdnCache)(applicationName); + const result = await catchErr(cdnService.invalidateCdnCache)(applicationName); // then - expect(result).to.be.instanceOf(cdn.NamespaceNotFoundError); + expect(result).to.be.instanceOf(cdnService.NamespaceNotFoundError); expect(result.message).to.be.equal('A namespace could not been found.'); }); }); @@ -160,7 +160,7 @@ describe('Integration | CDN', function () { }); // when - const result = await catchErr(cdn.invalidateCdnCache)(applicationName); + const result = await catchErr(cdnService.invalidateCdnCache)(applicationName); // then const expected = @@ -200,7 +200,7 @@ describe('Integration | CDN', function () { }); // when - const result = await catchErr(cdn.invalidateCdnCache)(applicationName); + const result = await catchErr(cdnService.invalidateCdnCache)(applicationName); // then const expected = @@ -235,7 +235,7 @@ describe('Integration | CDN', function () { }); // when - const result = await catchErr(cdn.invalidateCdnCache)(applicationName); + const result = await catchErr(cdnService.invalidateCdnCache)(applicationName); // then const expected = @@ -254,7 +254,7 @@ describe('Integration | CDN', function () { const monitorId = 'monitorId'; // when - const error = await catchErr(cdn.blockAccess)({ ja3, monitorId }); + const error = await catchErr(cdnService.blockAccess)({ ja3, monitorId }); // then expect(error.message).to.equal('ip cannot be empty.'); @@ -269,7 +269,7 @@ describe('Integration | CDN', function () { const monitorId = 'monitorId'; // when - const error = await catchErr(cdn.blockAccess)({ ip, ja3, monitorId }); + const error = await catchErr(cdnService.blockAccess)({ ip, ja3, monitorId }); // then expect(error.message).to.equal('ip cannot be empty.'); @@ -283,7 +283,7 @@ describe('Integration | CDN', function () { const monitorId = 'monitorId'; // when - const error = await catchErr(cdn.blockAccess)({ ip, monitorId }); + const error = await catchErr(cdnService.blockAccess)({ ip, monitorId }); // then expect(error.message).to.equal('ja3 cannot be empty.'); @@ -298,7 +298,7 @@ describe('Integration | CDN', function () { const monitorId = 'monitorId'; // when - const error = await catchErr(cdn.blockAccess)({ ip, ja3, monitorId }); + const error = await catchErr(cdnService.blockAccess)({ ip, ja3, monitorId }); // then expect(error.message).to.equal('ja3 cannot be empty.'); @@ -318,7 +318,7 @@ describe('Integration | CDN', function () { const postCustomStaticRules = _stubCustomStaticRulePost(namespaceKey, monitorId, ip, ja3); // when - const result = await cdn.blockAccess({ ip, ja3, monitorId }); + const result = await cdnService.blockAccess({ ip, ja3, monitorId }); // then postCustomStaticRules.done(); @@ -365,7 +365,7 @@ describe('Integration | CDN', function () { }); // when - const result = await catchErr(cdn.blockAccess)({ monitorId, ip, ja3 }); + const result = await catchErr(cdnService.blockAccess)({ monitorId, ip, ja3 }); // then const expected = From 083ec93639add0dfd1448bc7e446cdca75dd18d9 Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Mon, 6 Jan 2025 14:34:17 +0100 Subject: [PATCH 7/9] refactor: change SLACK_BLOCKED_ACCESSES_CHANNEL from channel name to channel id message update only seems to work with channel id --- config.js | 4 ++-- run/models/AutomaticRule.js | 2 +- sample.env | 6 +++--- test/acceptance/run/security_test.js | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config.js b/config.js index 696a566f..e8fd6caa 100644 --- a/config.js +++ b/config.js @@ -97,7 +97,7 @@ const configuration = (function () { requestSigningSecret: process.env.SLACK_SIGNING_SECRET || 'slack-super-signing-secret', botToken: process.env.SLACK_BOT_TOKEN, webhookUrlForReporting: process.env.SLACK_WEBHOOK_URL_FOR_REPORTING, - blockedAccessesChannel: process.env.SLACK_BLOCKED_ACCESSES_CHANNEL, + blockedAccessesChannelId: process.env.SLACK_BLOCKED_ACCESSES_CHANNEL_ID, }, github: { @@ -188,7 +188,7 @@ const configuration = (function () { config.baleen.appNamespaces = _getJSON('{"Pix_Test":"Pix_Namespace","Pix_Test_2":"Pix Namespace 2"}'); config.baleen.protectedFrontApps = ['Pix_Test']; - config.slack.blockedAccessesChannel = 'blocked-accesses-channel'; + config.slack.blockedAccessesChannelId = 'C08700JG7QU'; config.datadog.token = 'token'; diff --git a/run/models/AutomaticRule.js b/run/models/AutomaticRule.js index 161faa81..1bfbd860 100644 --- a/run/models/AutomaticRule.js +++ b/run/models/AutomaticRule.js @@ -17,7 +17,7 @@ export class AutomaticRule { #buildMessage({ isActive, addedRules }) { return { - channel: `#${config.slack.blockedAccessesChannel}`, + channel: `${config.slack.blockedAccessesChannelId}`, message: 'Règle de blocage mise en place sur Baleen.', attachments: Message() .attachments( diff --git a/sample.env b/sample.env index 2feccae9..bb340973 100644 --- a/sample.env +++ b/sample.env @@ -163,13 +163,13 @@ SLACK_BOT_TOKEN=__CHANGE_ME__ # default: none SLACK_WEBHOOK_URL_FOR_REPORTING=__CHANGE_ME__ -# Slack "Pix Bot" channel to send baleen blocked accesses +# Slack "Pix Bot" channel id to send baleen blocked accesses # -# Channel name without the # before +# Channel ID not the channel name # presence: required # type: string # default: none -SLACK_BLOCKED_ACCESSES_CHANNEL=__CHANGE_ME__ +SLACK_BLOCKED_ACCESSES_CHANNEL_ID=__CHANGE_ME__ # ====================== # CDN MANAGEMENT diff --git a/test/acceptance/run/security_test.js b/test/acceptance/run/security_test.js index bcf306c5..4fb1ac66 100644 --- a/test/acceptance/run/security_test.js +++ b/test/acceptance/run/security_test.js @@ -69,7 +69,7 @@ describe('Acceptance | Run | Security', function () { }, }) .post('/api/chat.postMessage', { - channel: `#${config.slack.blockedAccessesChannel}`, + channel: `${config.slack.blockedAccessesChannelId}`, text: 'Règle de blocage mise en place sur Baleen.', attachments: [ { From 1f8291bbc96e5070a74864710c1b66f1b2aa5506 Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Mon, 6 Jan 2025 16:50:26 +0100 Subject: [PATCH 8/9] feat: add block-actions service --- run/models/AutomaticRule.js | 25 +++++++ run/services/slack/block-actions.js | 21 ++++++ .../run/services/slack/block-actions_test.js | 66 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 run/services/slack/block-actions.js create mode 100644 test/unit/run/services/slack/block-actions_test.js diff --git a/run/models/AutomaticRule.js b/run/models/AutomaticRule.js index 1bfbd860..ceddeae6 100644 --- a/run/models/AutomaticRule.js +++ b/run/models/AutomaticRule.js @@ -11,10 +11,35 @@ export class AutomaticRule { this.date = date; } + static parseMessage(message) { + const messageObject = message; + + const ip = messageObject.attachments[0]?.blocks[0]?.fields[1]?.text; + if (!ip) { + throw new Error('IP field not found.'); + } + + const ja3 = messageObject.attachments[0]?.blocks[1]?.fields[1]?.text; + if (!ja3) { + throw new Error('JA3 field not found.'); + } + + const date = messageObject.attachments[0]?.blocks[2]?.elements[0]?.text?.slice(3); + if (!date) { + throw new Error('Date field not found.'); + } + + return new AutomaticRule({ ip, ja3, date: dayjs(date) }); + } + getInitialMessage({ addedRules }) { return this.#buildMessage({ isActive: true, addedRules }); } + getDeactivatedMessage() { + return this.#buildMessage({ isActive: false }); + } + #buildMessage({ isActive, addedRules }) { return { channel: `${config.slack.blockedAccessesChannelId}`, diff --git a/run/services/slack/block-actions.js b/run/services/slack/block-actions.js new file mode 100644 index 00000000..f6341f35 --- /dev/null +++ b/run/services/slack/block-actions.js @@ -0,0 +1,21 @@ +import cdnService from '../cdn.js'; +import { AutomaticRule } from '../../models/AutomaticRule.js'; +import slackService from '../../../common/services/slack/surfaces/messages/update-message.js'; + +const blockActions = { + async disableAutomaticRule(payload) { + const rules = JSON.parse(payload.actions[0].value); + const messageTimestamp = payload.message.ts; + + for (const rule of rules) { + await cdnService.disableRule(rule); + } + + const automaticRule = AutomaticRule.parseMessage(payload.message); + await slackService.updateMessage({ ts: messageTimestamp, ...automaticRule.getDeactivatedMessage() }); + + return 'Automatic rule disabled.'; + }, +}; + +export default blockActions; diff --git a/test/unit/run/services/slack/block-actions_test.js b/test/unit/run/services/slack/block-actions_test.js new file mode 100644 index 00000000..49a1751c --- /dev/null +++ b/test/unit/run/services/slack/block-actions_test.js @@ -0,0 +1,66 @@ +import { describe, it } from 'mocha'; +import cdnService from '../../../../../run/services/cdn.js'; +import slackService from '../../../../../common/services/slack/surfaces/messages/update-message.js'; +import blockActions from '../../../../../run/services/slack/block-actions.js'; +import { expect, sinon } from '../../../../test-helper.js'; +import { AutomaticRule } from '../../../../../run/models/AutomaticRule.js'; +import dayjs from 'dayjs'; + +describe('Unit | Run | Services | Slack | Block Actions', function () { + let clock; + let now; + + beforeEach(function () { + now = new Date('2024-01-01'); + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + }); + + afterEach(function () { + clock.restore(); + }); + + describe('#disableAutomaticRule', function () { + it('should disable rule in CDN and update the slack message', async function () { + // given + sinon.stub(cdnService, 'disableRule').resolves(); + sinon.stub(slackService, 'updateMessage').resolves(); + + const ip = '127.0.0.1'; + const ja3 = '9709730930'; + const date = dayjs(now); + + const payload = { + message: { + ts: '1735836582.877169', + attachments: [ + { + blocks: [ + { fields: [{ text: 'IP' }, { text: ip }] }, + { fields: [{ text: 'JA3' }, { text: ja3 }] }, + { elements: [{ text: `At ${date.format('DD/MM/YYYY HH:mm:ss')}` }] }, + ], + }, + ], + }, + actions: [ + { + value: + '[{"namespaceKey":"namespaceKey1","ruleId":"ruleId1"},{"namespaceKey":"namespaceKey2","ruleId":"ruleId2"}]', + }, + ], + }; + + // when + const result = await blockActions.disableAutomaticRule(payload); + + // then + sinon.assert.calledWith(cdnService.disableRule, { namespaceKey: 'namespaceKey1', ruleId: 'ruleId1' }); + sinon.assert.calledWith(cdnService.disableRule, { namespaceKey: 'namespaceKey2', ruleId: 'ruleId2' }); + sinon.assert.calledWith(slackService.updateMessage, { + ts: '1735836582.877169', + ...new AutomaticRule({ ip, ja3, date }).getDeactivatedMessage(), + }); + expect(result).to.equal('Automatic rule disabled.'); + }); + }); +}); From 0be0bd0dc1ee8256c1340600d92235a2d7a3ff6e Mon Sep 17 00:00:00 2001 From: Mathieu Gilet Date: Mon, 6 Jan 2025 16:51:29 +0100 Subject: [PATCH 9/9] feat: add disable rule management in slack interactive endpoint --- config.js | 1 + run/controllers/slack.js | 8 +- test/acceptance/run/slack_test.js | 123 ++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/config.js b/config.js index e8fd6caa..e7aab814 100644 --- a/config.js +++ b/config.js @@ -189,6 +189,7 @@ const configuration = (function () { config.baleen.protectedFrontApps = ['Pix_Test']; config.slack.blockedAccessesChannelId = 'C08700JG7QU'; + config.slack.botToken = 'fakeToken'; config.datadog.token = 'token'; diff --git a/run/controllers/slack.js b/run/controllers/slack.js index 554b490d..c5eddfdc 100644 --- a/run/controllers/slack.js +++ b/run/controllers/slack.js @@ -4,6 +4,8 @@ import { getAppStatusFromScalingo } from '../services/slack/app-status-from-scal import * as commands from '../services/slack/commands.js'; import shortcuts from '../services/slack/shortcuts.js'; import viewSubmissions from '../services/slack/view-submissions.js'; +import blockActions from '../services/slack/block-actions.js'; +import { AutomaticRule } from '../models/AutomaticRule.js'; function _getDeployStartedMessage(release, appName) { return `Commande de déploiement de la release "${release}" pour ${appName} en production bien reçue.`; @@ -154,8 +156,12 @@ const slack = { return viewSubmissions.submitCreateAppOnScalingoConfirmation(payload); } return null; - case 'view_closed': case 'block_actions': + if (payload?.actions[0]?.action_id === AutomaticRule.DISABLE) { + return blockActions.disableAutomaticRule(payload); + } + return null; + case 'view_closed': default: logger.info({ event: 'slack', message: 'This kind of interaction is not yet supported by Pix Bot.' }); return null; diff --git a/test/acceptance/run/slack_test.js b/test/acceptance/run/slack_test.js index 215f7cc5..6143191a 100644 --- a/test/acceptance/run/slack_test.js +++ b/test/acceptance/run/slack_test.js @@ -5,8 +5,12 @@ import { nock, nockGithubWithConfigChanges, nockGithubWithNoConfigChanges, + sinon, StatusCodes, } from '../../test-helper.js'; +import dayjs from 'dayjs'; +import { config } from '../../../config.js'; +import { AutomaticRule } from '../../../run/models/AutomaticRule.js'; describe('Acceptance | Run | Slack', function () { describe('POST /run/slack/interactive-endpoint', function () { @@ -453,5 +457,124 @@ describe('Acceptance | Run | Slack', function () { }); }); }); + + describe('when using the block action disable-automatic-rule', function () { + let clock; + let now; + + beforeEach(function () { + now = new Date('2024-01-01'); + clock = sinon.useFakeTimers({ now, toFake: ['Date'] }); + }); + + afterEach(function () { + clock.restore(); + }); + + it('should disable rule in CDN and update slack message', async function () { + // given + const ip = '127.0.0.1'; + const ja3 = '9709730930'; + const date = dayjs(now); + const rules = [ + { namespaceKey: 'namespaceKey1', ruleId: 'ruleId1' }, + { namespaceKey: 'namespaceKey2', ruleId: 'ruleId2' }, + ]; + const messageTimestamp = '1735836582.877169'; + + const body = { + type: 'block_actions', + message: { + ts: messageTimestamp, + attachments: [ + { + blocks: [ + { fields: [{ text: 'IP' }, { text: ip }] }, + { fields: [{ text: 'JA3' }, { text: ja3 }] }, + { elements: [{ text: `At ${date.format('DD/MM/YYYY HH:mm:ss')}` }] }, + ], + }, + ], + }, + actions: [ + { + action_id: AutomaticRule.DISABLE, + value: JSON.stringify(rules), + }, + ], + }; + + for (const rule of rules) { + nock('https://console.baleen.cloud/api', { + reqheaders: { + 'X-Api-Key': config.baleen.pat, + 'Content-type': 'application/json', + Cookie: `baleen-namespace=${rule.namespaceKey}`, + }, + }) + .patch(`/configs/custom-static-rules/${rule.ruleId}`, { + enabled: false, + }) + .reply(200); + } + + nock('https://slack.com', { + reqheaders: { + 'Content-type': 'application/json', + Authorization: 'Bearer fakeToken', + }, + }) + .post(`/api/chat.update`, { + channel: 'C08700JG7QU', + ts: '1735836582.877169', + as_user: true, + text: 'Règle de blocage mise en place sur Baleen.', + attachments: [ + { + color: '#106c1f', + blocks: [ + { + fields: [ + { type: 'mrkdwn', text: 'IP' }, + { type: 'mrkdwn', text: '127.0.0.1' }, + ], + type: 'section', + }, + { + fields: [ + { type: 'mrkdwn', text: 'JA3' }, + { type: 'mrkdwn', text: '9709730930' }, + ], + type: 'section', + }, + { elements: [{ type: 'mrkdwn', text: `At ${date.format('DD/MM/YYYY HH:mm:ss')}` }], type: 'context' }, + { type: 'divider' }, + { + fields: [ + { type: 'mrkdwn', text: 'Règle désactivée le' }, + { type: 'mrkdwn', text: date.format('DD/MM/YYYY HH:mm:ss') }, + ], + type: 'section', + }, + ], + fallback: 'Règle de blocage mise en place sur Baleen.', + }, + ], + }) + .reply(200); + + // when + const res = await server.inject({ + method: 'POST', + url: '/run/slack/interactive-endpoint', + headers: createSlackWebhookSignatureHeaders(JSON.stringify(body)), + payload: body, + }); + + // then + expect(res.statusCode).to.equal(200); + expect(nock.isDone()).to.be.true; + }); + }); }); });