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..15446ecf --- /dev/null +++ b/common/services/slack/surfaces/messages/update-message.js @@ -0,0 +1,31 @@ +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, + 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 occured while sending message : ${slackResponse.data.error}`, + stack: `Payload for error was ${JSON.stringify(payload)}`, + }); + } + } +} + +export default { updateMessage }; 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/controllers/slack.js b/run/controllers/slack.js index 554b490d..89cce42a 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.`; @@ -131,6 +133,8 @@ const slack = { const interactionType = payload.type; + console.log(JSON.stringify(payload)); + switch (interactionType) { case 'shortcut': if (payload.callback_id === 'deploy-release') { @@ -156,6 +160,10 @@ const slack = { return null; case 'view_closed': case 'block_actions': + if (payload?.actions[0]?.action_id === AutomaticRule.DISABLE) { + return blockActions.removeAutomaticRule(payload); + } + return null; default: logger.info({ event: 'slack', message: 'This kind of interaction is not yet supported by Pix Bot.' }); return null; diff --git a/run/models/AutomaticRule.js b/run/models/AutomaticRule.js new file mode 100644 index 00000000..388cf2d7 --- /dev/null +++ b/run/models/AutomaticRule.js @@ -0,0 +1,72 @@ +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; + } + + static parseMessage(message) { + const messageObject = JSON.parse(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 }); + } + + getInitialMessage({ addedRules }) { + return this.#buildMessage({ isActive: true, addedRules }); + } + + getDeactivatedMessage() { + return this.#buildMessage({ isActive: false }); + } + + #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/run/services/cdn.js b/run/services/cdn.js index ca2a9cb2..19675dfb 100644 --- a/run/services/cdn.js +++ b/run/services/cdn.js @@ -92,10 +92,10 @@ async function blockAccess({ ip, ja3, monitorId }) { const namespaceKeys = await _getNamespaceKey(config.baleen.protectedFrontApps); - const addedRuleIds = []; + const addedRules = []; for (const namespaceKey of namespaceKeys) { try { - const response= await axios.post( + const response = await axios.post( `${CDN_URL}/configs/custom-static-rules`, { category: 'block', @@ -119,7 +119,7 @@ async function blockAccess({ ip, ja3, monitorId }) { }, ); - addedRuleIds.push(response.data.id); + 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}`; @@ -127,7 +127,35 @@ async function blockAccess({ ip, ja3, monitorId }) { } } - return addedRuleIds; + return addedRules; } -export { blockAccess, invalidateCdnCache, NamespaceNotFoundError }; +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, disableRule, invalidateCdnCache, NamespaceNotFoundError }; diff --git a/run/services/slack/block-actions.js b/run/services/slack/block-actions.js new file mode 100644 index 00000000..db3f8a4f --- /dev/null +++ b/run/services/slack/block-actions.js @@ -0,0 +1,21 @@ +import * as cdnServices from '../cdn.js'; +import { AutomaticRule } from '../../models/AutomaticRule.js'; +import slackService from '../../../common/services/slack/surfaces/messages/update-message.js'; + +const blockActions = { + async removeAutomaticRule(payload) { + const rules = JSON.parse(payload.actions[0].value); + const messageTimestamp = payload.message.ts; + + for (const rule of rules) { + await cdnServices.disableRule(rule); + } + + console.log("Message: " + JSON.parse(payload.message)); + + const automaticRule = AutomaticRule.parseMessage(payload.message); + await slackService.updateMessage({ ts: messageTimestamp, ...automaticRule.getDeactivatedMessage() }); + }, +}; + +export default blockActions; diff --git a/test/acceptance/run/security_test.js b/test/acceptance/run/security_test.js index 206f6f1f..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', }, ], @@ -122,6 +123,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', @@ -149,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; }); }); diff --git a/test/integration/run/services/cdn_test.js b/test/integration/run/services/cdn_test.js index 0be4dcf9..57872c88 100644 --- a/test/integration/run/services/cdn_test.js +++ b/test/integration/run/services/cdn_test.js @@ -322,7 +322,7 @@ describe('Integration | CDN', function () { // then postCustomStaticRules.done(); - expect(result).to.deep.equal(['1234']); + expect(result).to.deep.equal([{ namespaceKey: 'namespace-key1', ruleId: '1234' }]); }); it('should throw an error with statusCode and message', async function () {