From 7ef04d41d6cbea1a9d49443f6c3f1beb1cda8d08 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Thu, 17 Oct 2019 20:16:05 -0600 Subject: [PATCH] [SIEM] [Detection Engine] Adds stable alerting ids, more scripting for product testing, and more unit tests (#48471) (#48580) * Adds stable alerting id's by using the alert params. * Currently does a manual walk through of the alert params to find the stable id * Updated all of the endpoints to take either of the two id's. * Added several scripts to support performance testing ad-hoc such as `post_x_signals.sh` * Added scripts to support converting from saved searches to alerts. * Consolidated and fixed a lot of the backend types * Added unit tests against the router endpoints * https://github.com/elastic/kibana/issues/47013 Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. ~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~ ~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~ ~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~ - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios ~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~ - [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) - [ ] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) --- x-pack/legacy/plugins/siem/index.ts | 10 +- .../convert_saved_search_to_signals.js | 114 +++++++++++ .../plugins/siem/server/kibana.index.ts | 2 +- .../alerts/build_events_query.ts | 4 +- .../alerts/build_events_reindex.ts | 6 +- .../detection_engine/alerts/create_signal.ts | 92 --------- .../detection_engine/alerts/create_signals.ts | 144 +++++++++++++ .../detection_engine/alerts/delete_signals.ts | 15 +- .../detection_engine/alerts/find_signals.ts | 12 +- .../detection_engine/alerts/read_signals.ts | 64 +++++- .../alerts/signals_alert_type.ts | 9 +- .../lib/detection_engine/alerts/types.ts | 117 +++++++++++ .../detection_engine/alerts/update_signals.ts | 30 +-- .../routes/__mocks__/_mock_server.ts | 100 +++++++++ .../routes/__mocks__/request_responses.ts | 163 +++++++++++++++ .../routes/create_signals.route.test.ts | 167 +++++++++++++++ .../routes/create_signals_route.ts | 166 +++++++-------- .../routes/delete_signals_route.test.ts | 76 +++++++ .../routes/delete_signals_route.ts | 54 ++--- .../routes/find_signals_route.test.ts | 92 +++++++++ .../routes/find_signals_route.ts | 91 ++++----- .../routes/read_signals_route.test.ts | 75 +++++++ .../routes/read_signals_route.ts | 48 +++-- .../routes/update_signals_route.test.ts | 191 ++++++++++++++++++ .../routes/update_signals_route.ts | 96 +++++++++ .../routes/updated_signals_route.ts | 114 ----------- .../convert_saved_search_to_signals.sh | 12 ++ .../scripts/{read_signal.sh => get_signal.sh} | 0 .../detection_engine/scripts/post_signal.sh | 24 ++- .../scripts/post_x_signals.sh | 40 ++++ .../scripts/signals/root_or_admin_1.json | 2 +- .../scripts/signals/root_or_admin_2.json | 2 +- .../scripts/signals/root_or_admin_3.json | 3 +- .../signals/root_or_admin_filter_9999.json | 2 +- ...ate_1.json => root_or_admin_update_1.json} | 2 +- .../scripts/signals/watch_longmont_3.json | 2 +- .../detection_engine/scripts/update_signal.sh | 5 +- 37 files changed, 1668 insertions(+), 478 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/scripts/convert_saved_search_to_signals.js delete mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signal.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signals.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/types.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals.route.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/update_signals_route.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/update_signals_route.ts delete mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/updated_signals_route.ts create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/convert_saved_search_to_signals.sh rename x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/{read_signal.sh => get_signal.sh} (100%) create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_x_signals.sh rename x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/{temp_update_1.json => root_or_admin_update_1.json} (85%) diff --git a/x-pack/legacy/plugins/siem/index.ts b/x-pack/legacy/plugins/siem/index.ts index 1dafcbfbfc65c..f78f55ddc445b 100644 --- a/x-pack/legacy/plugins/siem/index.ts +++ b/x-pack/legacy/plugins/siem/index.ts @@ -26,6 +26,7 @@ import { } from './common/constants'; import { signalsAlertType } from './server/lib/detection_engine/alerts/signals_alert_type'; import { defaultIndexPattern } from './default_index_pattern'; +import { isAlertExecutor } from './server/lib/detection_engine/alerts/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function siem(kibana: any) { @@ -126,9 +127,12 @@ export function siem(kibana: any) { init(server: Server) { const newPlatform = ((server as unknown) as KbnServer).newPlatform; if (server.plugins.alerting != null) { - server.plugins.alerting.setup.registerType( - signalsAlertType({ logger: newPlatform.coreContext.logger.get('plugins', APP_ID) }) - ); + const type = signalsAlertType({ + logger: newPlatform.coreContext.logger.get('plugins', APP_ID), + }); + if (isAlertExecutor(type)) { + server.plugins.alerting.setup.registerType(type); + } } server.injectUiAppVars('siem', async () => server.getInjectedUiAppVars('kibana')); initServerWithKibana(server); diff --git a/x-pack/legacy/plugins/siem/scripts/convert_saved_search_to_signals.js b/x-pack/legacy/plugins/siem/scripts/convert_saved_search_to_signals.js new file mode 100644 index 0000000000000..feadc0b667916 --- /dev/null +++ b/x-pack/legacy/plugins/siem/scripts/convert_saved_search_to_signals.js @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +require('../../../../../src/setup_node_env'); + +const fs = require('fs'); +const path = require('path'); + +/* + * This script is used to parse a set of saved searches on a file system + * and output signal data compatible json files. + * Example: + * node saved_query_to_signals.js ${HOME}/saved_searches ${HOME}/saved_signals + * + * After editing any changes in the files of ${HOME}/saved_signals/*.json + * you can then post the signals with a CURL post script such as: + * + * ./post_signal.sh ${HOME}/saved_signals/*.json + * + * Note: This script is recursive and but does not preserve folder structure + * when it outputs the saved signals. + */ + +// Defaults of the outputted signals since the saved KQL searches do not have +// this type of information. You usually will want to make any hand edits after +// doing a search to KQL conversion before posting it as a signal or checking it +// into another repository. +const INTERVAL = '24h'; +const SEVERITY = 1; +const TYPE = 'kql'; +const FROM = 'now-24h'; +const TO = 'now'; +const INDEX = ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*']; + +const walk = dir => { + const list = fs.readdirSync(dir); + return list.reduce((accum, file) => { + const fileWithDir = dir + '/' + file; + const stat = fs.statSync(fileWithDir); + if (stat && stat.isDirectory()) { + return [...accum, ...walk(fileWithDir)]; + } else { + return [...accum, fileWithDir]; + } + }, []); +}; + +//clean up the file system characters +const cleanupFileName = file => { + return path + .basename(file, path.extname(file)) + .replace(/\s+/g, '_') + .replace(/,/g, '') + .replace(/\+s/g, '') + .replace(/-/g, '') + .replace(/__/g, '_') + .toLowerCase(); +}; + +async function main() { + if (process.argv.length !== 4) { + throw new Error( + 'usage: saved_query_to_signals [input directory with saved searches] [output directory]' + ); + } + + const files = process.argv[2]; + const outputDir = process.argv[3]; + + const savedSearchesJson = walk(files).filter(file => file.endsWith('.ndjson')); + + const savedSearchesParsed = savedSearchesJson.reduce((accum, json) => { + const jsonFile = fs.readFileSync(json, 'utf8'); + try { + const parsedFile = JSON.parse(jsonFile); + parsedFile._file = json; + parsedFile.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.parse( + parsedFile.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + return [...accum, parsedFile]; + } catch (err) { + return accum; + } + }, []); + + savedSearchesParsed.forEach(savedSearch => { + const fileToWrite = cleanupFileName(savedSearch._file); + + const query = savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON.query.query; + if (query != null && query.trim() !== '') { + const outputMessage = { + id: fileToWrite, + description: savedSearch.attributes.description || savedSearch.attributes.title, + index: INDEX, + interval: INTERVAL, + name: savedSearch.attributes.title, + severity: SEVERITY, + type: TYPE, + from: FROM, + to: TO, + kql: savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON.query.query, + }; + + fs.writeFileSync(`${outputDir}/${fileToWrite}.json`, JSON.stringify(outputMessage, null, 2)); + } + }); +} + +if (require.main === module) { + main(); +} diff --git a/x-pack/legacy/plugins/siem/server/kibana.index.ts b/x-pack/legacy/plugins/siem/server/kibana.index.ts index 0e69cfda7af1a..9d26051c1baf2 100644 --- a/x-pack/legacy/plugins/siem/server/kibana.index.ts +++ b/x-pack/legacy/plugins/siem/server/kibana.index.ts @@ -20,7 +20,7 @@ import { createSignalsRoute } from './lib/detection_engine/routes/create_signals import { readSignalsRoute } from './lib/detection_engine/routes/read_signals_route'; import { findSignalsRoute } from './lib/detection_engine/routes/find_signals_route'; import { deleteSignalsRoute } from './lib/detection_engine/routes/delete_signals_route'; -import { updateSignalsRoute } from './lib/detection_engine/routes/updated_signals_route'; +import { updateSignalsRoute } from './lib/detection_engine/routes/update_signals_route'; const APP_ID = 'siem'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_query.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_query.ts index 18659a0232d1b..91efdc41ca798 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_query.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_query.ts @@ -8,8 +8,8 @@ import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; interface BuildEventsScrollQuery { index: string[]; - from: number; - to: number; + from: string; + to: string; kql: string | undefined; filter: Record | undefined; size: number; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_reindex.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_reindex.ts index c48be695756af..b84657b52769c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_reindex.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/build_events_reindex.ts @@ -14,10 +14,10 @@ import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; interface BuildEventsReIndexParams { description: string; index: string[]; - from: number; - to: number; + from: string; + to: string; signalsIndex: string; - maxDocs: number; + maxDocs: string; filter: Record | undefined; kql: string | undefined; severity: number; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signal.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signal.ts deleted file mode 100644 index 8685b4f082fa5..0000000000000 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signal.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SIGNALS_ID } from '../../../../common/constants'; -import { AlertsClient } from '../../../../../alerting/server/alerts_client'; -import { ActionsClient } from '../../../../../actions/server/actions_client'; - -export interface SignalParams { - alertsClient: AlertsClient; - actionsClient: ActionsClient; - description: string; - from: string; - id: string; - index: string[]; - interval: string; - enabled: boolean; - filter: Record | undefined; - kql: string | undefined; - maxSignals: string; - name: string; - severity: number; - type: string; // TODO: Replace this type with a static enum type - to: string; - references: string[]; -} - -export const createSignal = async ({ - alertsClient, - actionsClient, - description, - enabled, - filter, - from, - id, - index, - interval, - kql, - name, - severity, - to, - type, - references, -}: SignalParams) => { - // TODO: Right now we are using the .server-log as the default action as each alert has to have - // at least one action or it will not be able to do in-memory persistence. When adding in actions - // such as email, slack, etc... this should be the default action if not action is specified to - // create signals - - const actionResults = await actionsClient.create({ - action: { - actionTypeId: '.server-log', - description: 'SIEM Alerts Log', - config: {}, - secrets: {}, - }, - }); - - return alertsClient.create({ - data: { - alertTypeId: SIGNALS_ID, - alertTypeParams: { - description, - id, - index, - from, - filter, - kql, - name, - severity, - to, - type, - references, - }, - interval, - enabled, - actions: [ - { - group: 'default', - id: actionResults.id, - params: { - message: 'SIEM Alert Fired', - level: 'info', - }, - }, - ], - throttle: null, - }, - }); -}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signals.ts new file mode 100644 index 0000000000000..70b6723fc51ee --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/create_signals.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SIGNALS_ID } from '../../../../common/constants'; +import { updateSignal } from './update_signals'; +import { SignalParams } from './types'; + +// TODO: This updateIfIdExists should be temporary and we will remove it once we can POST id's directly to +// the alerting framework. +export const updateIfIdExists = async ({ + alertsClient, + actionsClient, + description, + enabled, + filter, + from, + id, + index, + interval, + kql, + maxSignals, + name, + severity, + to, + type, + references, +}: SignalParams) => { + try { + const signal = await updateSignal({ + alertsClient, + actionsClient, + description, + enabled, + filter, + from, + id, + index, + interval, + kql, + maxSignals, + name, + severity, + to, + type, + references, + }); + return signal; + } catch (err) { + // This happens when we cannot get a saved object back from reading a signal. + // So we continue normally as we have nothing we can upsert. + } + return null; +}; + +export const createSignals = async ({ + alertsClient, + actionsClient, + description, + enabled, + filter, + from, + id, + index, + interval, + kql, + maxSignals, + name, + severity, + to, + type, + references, +}: SignalParams) => { + // TODO: Once we can post directly to _id we will not have to do this part anymore. + const signalUpdating = await updateIfIdExists({ + alertsClient, + actionsClient, + description, + enabled, + filter, + from, + id, + index, + interval, + kql, + maxSignals, + name, + severity, + to, + type, + references, + }); + if (signalUpdating == null) { + // TODO: Right now we are using the .server-log as the default action as each alert has to have + // at least one action or it will not be able to do in-memory persistence. When adding in actions + // such as email, slack, etc... this should be the default action if no action is specified to + // create signals + const actionResults = await actionsClient.create({ + action: { + actionTypeId: '.server-log', + description: 'SIEM Alerts Log', + config: {}, + secrets: {}, + }, + }); + + return alertsClient.create({ + data: { + alertTypeId: SIGNALS_ID, + alertTypeParams: { + description, + id, + index, + from, + filter, + kql, + maxSignals, + name, + severity, + to, + type, + references, + }, + interval, + enabled, + actions: [ + { + group: 'default', + id: actionResults.id, + params: { + message: 'SIEM Alert Fired', + level: 'info', + }, + }, + ], + throttle: null, + }, + }); + } else { + return signalUpdating; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/delete_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/delete_signals.ts index dad9147d9eb68..007d8b9325a86 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/delete_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/delete_signals.ts @@ -6,13 +6,8 @@ import { AlertAction } from '../../../../../alerting/server/types'; import { ActionsClient } from '../../../../../actions/server/actions_client'; -import { AlertsClient } from '../../../../../alerting/server/alerts_client'; - -export interface DeleteSignalParams { - alertsClient: AlertsClient; - actionsClient: ActionsClient; - id: string; -} +import { readSignals } from './read_signals'; +import { DeleteSignalParams } from './types'; export const deleteAllSignalActions = async ( actionsClient: ActionsClient, @@ -27,14 +22,14 @@ export const deleteAllSignalActions = async ( }; export const deleteSignals = async ({ alertsClient, actionsClient, id }: DeleteSignalParams) => { - const alert = await alertsClient.get({ id }); + const signal = await readSignals({ alertsClient, id }); // TODO: Remove this as cast as soon as signal.actions TypeScript bug is fixed // where it is trying to return AlertAction[] or RawAlertAction[] - const actions = (alert.actions as (AlertAction[] | undefined)) || []; + const actions = (signal.actions as (AlertAction[] | undefined)) || []; const actionsErrors = await deleteAllSignalActions(actionsClient, actions); - const deletedAlert = await alertsClient.delete({ id }); + const deletedAlert = await alertsClient.delete({ id: signal.id }); if (actionsErrors != null) { throw actionsErrors; } else { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts index 0dd784d83f31f..ebddf6ac5b3c7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/find_signals.ts @@ -5,19 +5,11 @@ */ import { SIGNALS_ID } from '../../../../common/constants'; -import { AlertsClient } from '../../../../../alerting/server/alerts_client'; - -export interface GetSignalParams { - alertsClient: AlertsClient; - perPage?: number; - page?: number; - sortField?: string; - fields?: string[]; -} +import { FindSignalParams } from './types'; // TODO: Change this from a search to a filter once this ticket is solved: // https://github.com/elastic/kibana/projects/26#card-27462236 -export const findSignals = async ({ alertsClient, perPage, page, fields }: GetSignalParams) => { +export const findSignals = async ({ alertsClient, perPage, page, fields }: FindSignalParams) => { return alertsClient.find({ options: { fields, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/read_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/read_signals.ts index 15b4cd57e3aa2..0ef13f39e793b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/read_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/read_signals.ts @@ -4,15 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertsClient } from '../../../../../alerting/server/alerts_client'; +import { findSignals } from './find_signals'; +import { SignalAlertType, isAlertTypeArray, ReadSignalParams } from './types'; -export interface ReadSignalParams { - alertsClient: AlertsClient; - id: string; -} +export const findSignalInArrayById = (objects: object[], id: string): SignalAlertType | null => { + if (isAlertTypeArray(objects)) { + const signals: SignalAlertType[] = objects; + const signal: SignalAlertType[] = signals.filter(datum => { + return datum.alertTypeParams.id === id; + }); + if (signal.length !== 0) { + return signal[0]; + } else { + return null; + } + } else { + return null; + } +}; + +// This an extremely slow and inefficient way of getting a signal by its id. +// I have to manually query every single record since the Signal Params are +// not indexed and I cannot push in my own _id when I create an alert at the moment. +// TODO: Once we can directly push in the _id, then we should no longer need this way. +// TODO: This is meant to be _very_ temporary. +export const readSignalById = async ({ + alertsClient, + id, +}: ReadSignalParams): Promise => { + const firstSignals = await findSignals({ alertsClient, page: 1 }); + const firstSignal = findSignalInArrayById(firstSignals.data, id); + if (firstSignal != null) { + return firstSignal; + } else { + const totalPages = Math.ceil(firstSignals.total / firstSignals.perPage); + return Array(totalPages) + .fill({}) + .map((_, page) => { + // page index never starts at zero. It always has to be 1 or greater + return findSignals({ alertsClient, page: page + 1 }); + }) + .reduce>(async (accum, findSignal) => { + const signals = await findSignal; + const signal = findSignalInArrayById(signals.data, id); + if (signal != null) { + return signal; + } else { + return accum; + } + }, Promise.resolve(null)); + } +}; -// TODO: Change this from a search to a filter once this ticket is solved: -// https://github.com/elastic/kibana/projects/26#card-27462236 export const readSignals = async ({ alertsClient, id }: ReadSignalParams) => { - return alertsClient.get({ id }); + const signalById = await readSignalById({ alertsClient, id }); + if (signalById != null) { + return signalById; + } else { + return alertsClient.get({ id }); + } }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts index 54e7799da982c..3ce3161a442cd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/signals_alert_type.ts @@ -7,7 +7,6 @@ import { schema } from '@kbn/config-schema'; import { SIGNALS_ID } from '../../../../common/constants'; import { Logger } from '../../../../../../../../src/core/server'; -import { AlertType, AlertExecutorOptions } from '../../../../../alerting'; // TODO: Remove this for the build_events_query call eventually import { buildEventsReIndex } from './build_events_reindex'; @@ -18,8 +17,9 @@ import { buildEventsScrollQuery } from './build_events_query'; // bulk scroll class import { scrollAndBulkIndex } from './utils'; +import { SignalAlertTypeDefinition } from './types'; -export const signalsAlertType = ({ logger }: { logger: Logger }): AlertType => { +export const signalsAlertType = ({ logger }: { logger: Logger }): SignalAlertTypeDefinition => { return { id: SIGNALS_ID, name: 'SIEM Signals', @@ -29,7 +29,7 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): AlertType => { description: schema.string(), from: schema.string(), filter: schema.nullable(schema.object({}, { allowUnknowns: true })), - id: schema.number(), + id: schema.string(), index: schema.arrayOf(schema.string()), kql: schema.nullable(schema.string()), maxSignals: schema.number({ defaultValue: 100 }), @@ -42,8 +42,7 @@ export const signalsAlertType = ({ logger }: { logger: Logger }): AlertType => { scrollLock: schema.maybe(schema.string()), }), }, - // TODO: Type the params as it is all filled with any - async executor({ services, params, state }: AlertExecutorOptions) { + async executor({ services, params }) { const instance = services.alertInstanceFactory('siem-signals'); const { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/types.ts new file mode 100644 index 0000000000000..c2307e366a886 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/types.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash/fp'; + +import Hapi from 'hapi'; +import { SIGNALS_ID } from '../../../../common/constants'; +import { + Alert, + AlertType, + State, + AlertExecutorOptions, +} from '../../../../../alerting/server/types'; +import { AlertsClient } from '../../../../../alerting/server/alerts_client'; +import { ActionsClient } from '../../../../../actions/server/actions_client'; + +export interface SignalAlertParams { + description: string; + from: string; + id: string; + index: string[]; + interval: string; + enabled: boolean; + filter: Record | undefined; + kql: string | undefined; + maxSignals: string; + name: string; + severity: number; + type: 'filter' | 'kql'; + to: string; + references: string[]; +} + +export interface Clients { + alertsClient: AlertsClient; + actionsClient: ActionsClient; +} + +export type SignalParams = SignalAlertParams & Clients; + +export type DeleteSignalParams = Clients & { id: string }; + +export interface FindSignalsRequest extends Omit { + query: { + per_page: number; + page: number; + search?: string; + sort_field?: string; + fields?: string[]; + }; +} + +export interface FindSignalParams { + alertsClient: AlertsClient; + perPage?: number; + page?: number; + sortField?: string; + fields?: string[]; +} + +export interface ReadSignalParams { + alertsClient: AlertsClient; + id: string; +} + +export type SignalAlertType = Alert & { + id: string; + alertTypeParams: SignalAlertParams; +}; + +export interface SignalsRequest extends Hapi.Request { + payload: Omit & { + max_signals: string; + }; +} + +export type SignalExecutorOptions = Omit & { + params: { + description: string; + from: string; + id: string; + index: string[]; + interval: string; + enabled: boolean; + filter: Record | undefined; + kql: string | undefined; + maxSignals: string; + name: string; + severity: number; + type: 'filter' | 'kql'; + to: string; + references: string[]; + scrollSize: number; + scrollLock: string; + }; +}; + +// This returns true because by default a SignalAlertTypeDefinition is an AlertType +// since we are only increasing the strictness of params. +export const isAlertExecutor = (obj: SignalAlertTypeDefinition): obj is AlertType => { + return true; +}; + +export type SignalAlertTypeDefinition = Omit & { + executor: ({ services, params, state }: SignalExecutorOptions) => Promise; +}; + +export const isAlertType = (obj: unknown): obj is SignalAlertType => { + return get('alertTypeId', obj) === SIGNALS_ID; +}; + +export const isAlertTypeArray = (objArray: unknown[]): objArray is SignalAlertType[] => { + return objArray.length === 0 || isAlertType(objArray[0]); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.ts index 7c307695dd2a5..de2f0d511711c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/alerts/update_signals.ts @@ -6,28 +6,8 @@ import { defaults } from 'lodash/fp'; import { AlertAction } from '../../../../../alerting/server/types'; -import { AlertsClient } from '../../../../../alerting/server/alerts_client'; -import { ActionsClient } from '../../../../../actions/server/actions_client'; import { readSignals } from './read_signals'; - -export interface SignalParams { - alertsClient: AlertsClient; - actionsClient: ActionsClient; - description?: string; - from?: string; - id: string; - index?: string[]; - interval?: string; - enabled?: boolean; - filter?: Record | undefined; - kql?: string | undefined; - maxSignals?: string; - name?: string; - severity?: number; - type?: string; // TODO: Replace this type with a static enum type - to?: string; - references?: string[]; -} +import { SignalParams } from './types'; export const calculateInterval = ( interval: string | undefined, @@ -66,6 +46,7 @@ export const updateSignal = async ({ index, interval, kql, + maxSignals, name, severity, to, @@ -94,6 +75,7 @@ export const updateSignal = async ({ from, index, kql: nextKql, + maxSignals, name, severity, to, @@ -103,13 +85,13 @@ export const updateSignal = async ({ ); if (signal.enabled && !enabled) { - await alertsClient.disable({ id }); + await alertsClient.disable({ id: signal.id }); } else if (!signal.enabled && enabled) { - await alertsClient.enable({ id }); + await alertsClient.enable({ id: signal.id }); } return alertsClient.update({ - id, + id: signal.id, data: { interval: calculateInterval(interval, signal.interval), actions, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts new file mode 100644 index 0000000000000..c02af2c841a30 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Hapi from 'hapi'; +import { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { alertsClientMock } from '../../../../../../alerting/server/alerts_client.mock'; +import { actionsClientMock } from '../../../../../../actions/server/actions_client.mock'; + +const defaultConfig = { + 'kibana.index': '.kibana', +}; + +const isKibanaConfig = (config: unknown): config is KibanaConfig => + Object.getOwnPropertyDescriptor(config, 'get') != null && + Object.getOwnPropertyDescriptor(config, 'has') != null; + +const assertNever = (): never => { + throw new Error('Unexpected object'); +}; + +const createMockKibanaConfig = (config: Record): KibanaConfig => { + const returnConfig = { + get(key: string) { + return config[key]; + }, + has(key: string) { + return config[key] != null; + }, + }; + if (isKibanaConfig(returnConfig)) { + return returnConfig; + } else { + return assertNever(); + } +}; + +export const createMockServer = (config: Record = defaultConfig) => { + const server = new Hapi.Server({ + port: 0, + }); + + server.config = () => createMockKibanaConfig(config); + + const actionsClient = actionsClientMock.create(); + const alertsClient = alertsClientMock.create(); + server.decorate('request', 'getAlertsClient', () => alertsClient); + server.decorate('request', 'getBasePath', () => '/s/default'); + server.decorate('request', 'getActionsClient', () => actionsClient); + + return { server, alertsClient, actionsClient }; +}; + +export const createMockServerWithoutAlertClientDecoration = ( + config: Record = defaultConfig +) => { + const serverWithoutAlertClient = new Hapi.Server({ + port: 0, + }); + + serverWithoutAlertClient.config = () => createMockKibanaConfig(config); + + const actionsClient = actionsClientMock.create(); + serverWithoutAlertClient.decorate('request', 'getBasePath', () => '/s/default'); + serverWithoutAlertClient.decorate('request', 'getActionsClient', () => actionsClient); + + return { serverWithoutAlertClient, actionsClient }; +}; + +export const createMockServerWithoutActionClientDecoration = ( + config: Record = defaultConfig +) => { + const serverWithoutActionClient = new Hapi.Server({ + port: 0, + }); + + serverWithoutActionClient.config = () => createMockKibanaConfig(config); + + const alertsClient = alertsClientMock.create(); + serverWithoutActionClient.decorate('request', 'getBasePath', () => '/s/default'); + serverWithoutActionClient.decorate('request', 'getAlertsClient', () => alertsClient); + + return { serverWithoutActionClient, alertsClient }; +}; + +export const createMockServerWithoutActionOrAlertClientDecoration = ( + config: Record = defaultConfig +) => { + const serverWithoutActionOrAlertClient = new Hapi.Server({ + port: 0, + }); + + serverWithoutActionOrAlertClient.config = () => createMockKibanaConfig(config); + + return { + serverWithoutActionOrAlertClient, + }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts new file mode 100644 index 0000000000000..e4b3ccccbdbaf --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ServerInjectOptions } from 'hapi'; +import { ActionResult } from '../../../../../../actions/server/types'; + +export const getUpdateRequest = (): ServerInjectOptions => ({ + method: 'PUT', + url: '/api/siem/signals', + payload: { + id: 'rule-1', + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'kql', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, +}); + +export const getReadRequest = (): ServerInjectOptions => ({ + method: 'GET', + url: '/api/siem/signals/rule-1', +}); + +export const getFindRequest = (): ServerInjectOptions => ({ + method: 'GET', + url: '/api/siem/signals/_find', +}); + +export const getFindResult = () => ({ + page: 1, + perPage: 1, + total: 0, + data: [], +}); + +export const getDeleteRequest = (): ServerInjectOptions => ({ + method: 'DELETE', + url: '/api/siem/signals/rule-1', +}); + +export const getCreateRequest = (): ServerInjectOptions => ({ + method: 'POST', + url: '/api/siem/signals', + payload: { + id: 'rule-1', + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'kql', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, +}); + +export const createActionResult = (): ActionResult => ({ + id: 'result-1', + actionTypeId: 'action-id-1', + description: '', + config: {}, +}); + +export const createAlertResult = () => ({ + id: 'rule-1', + alertTypeId: 'siem.signals', + alertTypeParams: { + description: 'Detecting root and admin users', + id: 'rule-1', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + from: 'now-6m', + filter: null, + kql: 'user.name: root or user.name: admin', + maxSignals: 100, + name: 'Detect Root/Admin Users', + severity: 1, + to: 'now', + type: 'kql', + references: [], + }, + interval: '5m', + enabled: true, + actions: [ + { + group: 'default', + params: { + message: 'SIEM Alert Fired', + level: 'info', + }, + id: '9c3846a3-dbf9-40ce-ba7e-ef635499afa6', + }, + ], + throttle: null, + createdBy: 'elastic', + updatedBy: 'elastic', + apiKeyOwner: 'elastic', + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '78d036d0-f042-11e9-a9ae-51b9a11630ec', +}); + +export const getResult = () => ({ + id: 'result-1', + enabled: false, + alertTypeId: '', + interval: undefined, + actions: undefined, + alertTypeParams: undefined, +}); + +export const updateActionResult = (): ActionResult => ({ + id: 'result-1', + actionTypeId: 'action-id-1', + description: '', + config: {}, +}); + +export const updateAlertResult = () => ({ + id: 'rule-1', + alertTypeId: 'siem.signals', + alertTypeParams: { + description: 'Detecting root and admin users', + id: 'rule-1', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + from: 'now-6m', + filter: null, + kql: 'user.name: root or user.name: admin', + maxSignals: 100, + name: 'Detect Root/Admin Users', + severity: 1, + to: 'now', + type: 'kql', + references: [], + }, + interval: '5m', + enabled: true, + actions: [ + { + group: 'default', + params: { + message: 'SIEM Alert Fired', + level: 'info', + }, + id: '9c3846a3-dbf9-40ce-ba7e-ef635499afa6', + }, + ], + throttle: null, + createdBy: 'elastic', + updatedBy: 'elastic', + apiKeyOwner: 'elastic', + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '78d036d0-f042-11e9-a9ae-51b9a11630ec', +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals.route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals.route.test.ts new file mode 100644 index 0000000000000..1575ab5b16a3c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals.route.test.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockServer, + createMockServerWithoutActionClientDecoration, + createMockServerWithoutAlertClientDecoration, + createMockServerWithoutActionOrAlertClientDecoration, +} from './__mocks__/_mock_server'; +import { createSignalsRoute } from './create_signals_route'; +import { ServerInjectOptions } from 'hapi'; +import { + getFindResult, + getResult, + createActionResult, + createAlertResult, + getCreateRequest, +} from './__mocks__/request_responses'; + +describe('create_signals', () => { + let { server, alertsClient, actionsClient } = createMockServer(); + + beforeEach(() => { + jest.resetAllMocks(); + ({ server, alertsClient, actionsClient } = createMockServer()); + createSignalsRoute(server); + }); + + describe('status codes with actionClient and alertClient', () => { + it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.create.mockResolvedValue(createActionResult()); + alertsClient.create.mockResolvedValue(createAlertResult()); + const { statusCode } = await server.inject(getCreateRequest()); + expect(statusCode).toBe(200); + }); + + it('returns 404 if actionClient is not available on the route', async () => { + const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); + createSignalsRoute(serverWithoutActionClient); + const { statusCode } = await serverWithoutActionClient.inject(getCreateRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient is not available on the route', async () => { + const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); + createSignalsRoute(serverWithoutAlertClient); + const { statusCode } = await serverWithoutAlertClient.inject(getCreateRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient and actionClient are both not available on the route', async () => { + const { + serverWithoutActionOrAlertClient, + } = createMockServerWithoutActionOrAlertClientDecoration(); + createSignalsRoute(serverWithoutActionOrAlertClient); + const { statusCode } = await serverWithoutActionOrAlertClient.inject(getCreateRequest()); + expect(statusCode).toBe(404); + }); + }); + + describe('validation', () => { + it('returns 400 if id is not given', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.create.mockResolvedValue(createActionResult()); + alertsClient.create.mockResolvedValue(createAlertResult()); + const request: ServerInjectOptions = { + method: 'POST', + url: '/api/siem/signals', + payload: { + // missing id should throw a 400 + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'kql', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + + it('returns 200 if type is kql', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.create.mockResolvedValue(createActionResult()); + alertsClient.create.mockResolvedValue(createAlertResult()); + const request: ServerInjectOptions = { + method: 'POST', + url: '/api/siem/signals', + payload: { + id: 'rule-1', + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'kql', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + }); + + it('returns 200 if type is filter', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.create.mockResolvedValue(createActionResult()); + alertsClient.create.mockResolvedValue(createAlertResult()); + const request: ServerInjectOptions = { + method: 'POST', + url: '/api/siem/signals', + payload: { + id: 'rule-1', + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'filter', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + }); + + it('returns 400 if type is not filter or kql', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.create.mockResolvedValue(createActionResult()); + alertsClient.create.mockResolvedValue(createAlertResult()); + const request: ServerInjectOptions = { + method: 'POST', + url: '/api/siem/signals', + payload: { + id: 'rule-1', + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'something-made-up', // This is a made up type that causes the 400 + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals_route.ts index 5ba8e9e363209..597f064b2c796 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/create_signals_route.ts @@ -7,102 +7,86 @@ import Hapi from 'hapi'; import Joi from 'joi'; import { isFunction } from 'lodash/fp'; -import { createSignal } from '../alerts/create_signal'; +import { createSignals } from '../alerts/create_signals'; +import { SignalsRequest } from '../alerts/types'; -interface SignalsRequest extends Hapi.Request { - payload: { - description: string; - enabled: boolean; - filter: Record | undefined; - from: string; - id: string; - index: string[]; - interval: string; - kql: string | undefined; - max_signals: string; - name: string; - severity: number; - type: string; - to: string; - references: string[]; - }; -} - -export const createSignalsRoute = (server: Hapi.Server) => { - server.route({ - method: 'POST', - path: '/api/siem/signals', - options: { - tags: ['access:signals-all'], - validate: { - options: { - abortEarly: false, - }, - payload: Joi.object({ - description: Joi.string().required(), - enabled: Joi.boolean().default(true), - filter: Joi.object(), - from: Joi.string().required(), - id: Joi.string().required(), - index: Joi.array().required(), - interval: Joi.string().default('5m'), - kql: Joi.string(), - max_signals: Joi.number().default(100), - name: Joi.string().required(), - severity: Joi.number().required(), - to: Joi.string().required(), - type: Joi.string().required(), // TODO: Restrict this to only be kql or filter for the moment - references: Joi.array().default([]), - }).xor('filter', 'kql'), +export const createCreateSignalsRoute: Hapi.ServerRoute = { + method: 'POST', + path: '/api/siem/signals', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, }, + payload: Joi.object({ + description: Joi.string().required(), + enabled: Joi.boolean().default(true), + filter: Joi.object(), + from: Joi.string().required(), + id: Joi.string().required(), + index: Joi.array().required(), + interval: Joi.string().default('5m'), + kql: Joi.string(), + max_signals: Joi.number().default(100), + name: Joi.string().required(), + severity: Joi.number().required(), + to: Joi.string().required(), + type: Joi.string() + .valid('filter', 'kql') + .required(), + references: Joi.array().default([]), + }).xor('filter', 'kql'), }, - async handler(request: SignalsRequest, headers) { - const { - description, - enabled, - filter, - kql, - from, - id, - index, - interval, - // eslint-disable-next-line @typescript-eslint/camelcase - max_signals: maxSignals, - name, - severity, - to, - type, - references, - } = request.payload; + }, + async handler(request: SignalsRequest, headers) { + const { + description, + enabled, + filter, + kql, + from, + id, + index, + interval, + // eslint-disable-next-line @typescript-eslint/camelcase + max_signals: maxSignals, + name, + severity, + to, + type, + references, + } = request.payload; - const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; - const actionsClient = isFunction(request.getActionsClient) - ? request.getActionsClient() - : null; + const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - if (!alertsClient || !actionsClient) { - return headers.response().code(404); - } + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } - return createSignal({ - alertsClient, - actionsClient, - description, - enabled, - filter, - from, - id, - index, - interval, - kql, - maxSignals, - name, - severity, - to, - type, - references, - }); - }, - }); + return createSignals({ + alertsClient, + actionsClient, + description, + enabled, + filter, + from, + id, + index, + interval, + kql, + maxSignals, + name, + severity, + to, + type, + references, + }); + }, +}; + +export const createSignalsRoute = (server: Hapi.Server) => { + server.route(createCreateSignalsRoute); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.test.ts new file mode 100644 index 0000000000000..70a072f466527 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockServer, + createMockServerWithoutActionClientDecoration, + createMockServerWithoutAlertClientDecoration, + createMockServerWithoutActionOrAlertClientDecoration, +} from './__mocks__/_mock_server'; + +import { deleteSignalsRoute } from './delete_signals_route'; +import { ServerInjectOptions } from 'hapi'; +import { getFindResult, getResult, getDeleteRequest } from './__mocks__/request_responses'; + +describe('delete_signals', () => { + let { server, alertsClient } = createMockServer(); + + beforeEach(() => { + ({ server, alertsClient } = createMockServer()); + deleteSignalsRoute(server); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('status codes with actionClient and alertClient', () => { + it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.delete.mockResolvedValue({}); + const { statusCode } = await server.inject(getDeleteRequest()); + expect(statusCode).toBe(200); + }); + + it('returns 404 if actionClient is not available on the route', async () => { + const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); + deleteSignalsRoute(serverWithoutActionClient); + const { statusCode } = await serverWithoutActionClient.inject(getDeleteRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient is not available on the route', async () => { + const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); + deleteSignalsRoute(serverWithoutAlertClient); + const { statusCode } = await serverWithoutAlertClient.inject(getDeleteRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient and actionClient are both not available on the route', async () => { + const { + serverWithoutActionOrAlertClient, + } = createMockServerWithoutActionOrAlertClientDecoration(); + deleteSignalsRoute(serverWithoutActionOrAlertClient); + const { statusCode } = await serverWithoutActionOrAlertClient.inject(getDeleteRequest()); + expect(statusCode).toBe(404); + }); + }); + + describe('validation', () => { + it('returns 404 if given a non-existent id', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.delete.mockResolvedValue({}); + const request: ServerInjectOptions = { + method: 'DELETE', + url: '/api/siem/signals', + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(404); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.ts index 559077c862c1e..d89d996eb06a2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/delete_signals_route.ts @@ -9,32 +9,34 @@ import { isFunction } from 'lodash/fp'; import { deleteSignals } from '../alerts/delete_signals'; -export const deleteSignalsRoute = (server: Hapi.Server) => { - server.route({ - method: 'DELETE', - path: '/api/siem/signals/{id}', - options: { - tags: ['access:signals-all'], - validate: { - options: { - abortEarly: false, - }, +export const createDeleteSignalsRoute: Hapi.ServerRoute = { + method: 'DELETE', + path: '/api/siem/signals/{id}', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, }, }, - async handler(request: Hapi.Request, headers) { - const { id } = request.params; - const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; - const actionsClient = isFunction(request.getActionsClient) - ? request.getActionsClient() - : null; - if (alertsClient == null || actionsClient == null) { - return headers.response().code(404); - } - return deleteSignals({ - actionsClient, - alertsClient, - id, - }); - }, - }); + }, + async handler(request: Hapi.Request, headers) { + const { id } = request.params; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; + + if (alertsClient == null || actionsClient == null) { + return headers.response().code(404); + } + + return deleteSignals({ + actionsClient, + alertsClient, + id, + }); + }, +}; + +export const deleteSignalsRoute = (server: Hapi.Server): void => { + server.route(createDeleteSignalsRoute); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.test.ts new file mode 100644 index 0000000000000..c054f35f93c28 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockServer, + createMockServerWithoutActionClientDecoration, + createMockServerWithoutAlertClientDecoration, + createMockServerWithoutActionOrAlertClientDecoration, +} from './__mocks__/_mock_server'; + +import { findSignalsRoute } from './find_signals_route'; +import { ServerInjectOptions } from 'hapi'; +import { getFindResult, getResult, getFindRequest } from './__mocks__/request_responses'; + +describe('find_signals', () => { + let { server, alertsClient, actionsClient } = createMockServer(); + + beforeEach(() => { + ({ server, alertsClient, actionsClient } = createMockServer()); + findSignalsRoute(server); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('status codes with actionClient and alertClient', () => { + it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + actionsClient.find.mockResolvedValue({ + page: 1, + perPage: 1, + total: 0, + data: [], + }); + alertsClient.get.mockResolvedValue(getResult()); + const { statusCode } = await server.inject(getFindRequest()); + expect(statusCode).toBe(200); + }); + + it('returns 404 if actionClient is not available on the route', async () => { + const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); + findSignalsRoute(serverWithoutActionClient); + const { statusCode } = await serverWithoutActionClient.inject(getFindRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient is not available on the route', async () => { + const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); + findSignalsRoute(serverWithoutAlertClient); + const { statusCode } = await serverWithoutAlertClient.inject(getFindRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient and actionClient are both not available on the route', async () => { + const { + serverWithoutActionOrAlertClient, + } = createMockServerWithoutActionOrAlertClientDecoration(); + findSignalsRoute(serverWithoutActionOrAlertClient); + const { statusCode } = await serverWithoutActionOrAlertClient.inject(getFindRequest()); + expect(statusCode).toBe(404); + }); + }); + + describe('validation', () => { + it('returns 400 if a bad query parameter is given', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + const request: ServerInjectOptions = { + method: 'GET', + url: '/api/siem/signals/_find?invalid_value=500', + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + + it('returns 200 if the set of optional query parameters are given', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + const request: ServerInjectOptions = { + method: 'GET', + url: + '/api/siem/signals/_find?page=2&per_page=20&sort_field=timestamp&fields=["field-1","field-2","field-3]', + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts index f774f8d76797d..a16932d256e5e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/find_signals_route.ts @@ -8,56 +8,51 @@ import Hapi from 'hapi'; import Joi from 'joi'; import { isFunction } from 'lodash/fp'; import { findSignals } from '../alerts/find_signals'; +import { FindSignalsRequest } from '../alerts/types'; -interface FindSignalsRequest extends Omit { - query: { - per_page: number; - page: number; - search?: string; - sort_field?: string; - fields?: string[]; - }; -} - -export const findSignalsRoute = (server: Hapi.Server) => { - server.route({ - method: 'GET', - path: '/api/siem/signals/_find', - options: { - tags: ['access:signals-all'], - validate: { - options: { - abortEarly: false, - }, - query: Joi.object() - .keys({ - per_page: Joi.number() - .min(0) - .default(20), - page: Joi.number() - .min(1) - .default(1), - sort_field: Joi.string(), - fields: Joi.array() - .items(Joi.string()) - .single(), - }) - .default(), +export const createFindSignalRoute: Hapi.ServerRoute = { + method: 'GET', + path: '/api/siem/signals/_find', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, }, + query: Joi.object() + .keys({ + per_page: Joi.number() + .min(0) + .default(20), + page: Joi.number() + .min(1) + .default(1), + sort_field: Joi.string(), + fields: Joi.array() + .items(Joi.string()) + .single(), + }) + .default(), }, - async handler(request: FindSignalsRequest, headers) { - const { query } = request; - const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; - if (alertsClient == null) { - return headers.response().code(404); - } + }, + async handler(request: FindSignalsRequest, headers) { + const { query } = request; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - return findSignals({ - alertsClient, - perPage: query.per_page, - page: query.page, - sortField: query.sort_field, - }); - }, - }); + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } + + return findSignals({ + alertsClient, + perPage: query.per_page, + page: query.page, + sortField: query.sort_field, + }); + }, +}; + +export const findSignalsRoute = (server: Hapi.Server) => { + server.route(createFindSignalRoute); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.test.ts new file mode 100644 index 0000000000000..1010ae7e0c7bd --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockServer, + createMockServerWithoutActionClientDecoration, + createMockServerWithoutAlertClientDecoration, + createMockServerWithoutActionOrAlertClientDecoration, +} from './__mocks__/_mock_server'; + +import { readSignalsRoute } from './read_signals_route'; +import { ServerInjectOptions } from 'hapi'; +import { getFindResult, getResult, getReadRequest } from './__mocks__/request_responses'; + +describe('read_signals', () => { + let { server, alertsClient } = createMockServer(); + + beforeEach(() => { + ({ server, alertsClient } = createMockServer()); + readSignalsRoute(server); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('status codes with actionClient and alertClient', () => { + it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + const { statusCode } = await server.inject(getReadRequest()); + expect(statusCode).toBe(200); + }); + + it('returns 404 if actionClient is not available on the route', async () => { + const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); + readSignalsRoute(serverWithoutActionClient); + const { statusCode } = await serverWithoutActionClient.inject(getReadRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient is not available on the route', async () => { + const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); + readSignalsRoute(serverWithoutAlertClient); + const { statusCode } = await serverWithoutAlertClient.inject(getReadRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient and actionClient are both not available on the route', async () => { + const { + serverWithoutActionOrAlertClient, + } = createMockServerWithoutActionOrAlertClientDecoration(); + readSignalsRoute(serverWithoutActionOrAlertClient); + const { statusCode } = await serverWithoutActionOrAlertClient.inject(getReadRequest()); + expect(statusCode).toBe(404); + }); + }); + + describe('validation', () => { + it('returns 404 if given a non-existent id', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.delete.mockResolvedValue({}); + const request: ServerInjectOptions = { + method: 'GET', + url: '/api/siem/signals/', + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(404); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.ts index 0d4f9ac0ef1fc..b26c8c17f32dc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/read_signals_route.ts @@ -9,28 +9,32 @@ import { isFunction } from 'lodash/fp'; import { readSignals } from '../alerts/read_signals'; -export const readSignalsRoute = (server: Hapi.Server) => { - server.route({ - method: 'GET', - path: '/api/siem/signals/{id}', - options: { - tags: ['access:signals-all'], - validate: { - options: { - abortEarly: false, - }, +export const createReadSignalsRoute: Hapi.ServerRoute = { + method: 'GET', + path: '/api/siem/signals/{id}', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, }, }, - async handler(request: Hapi.Request, headers) { - const { id } = request.params; - const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; - if (alertsClient == null) { - return headers.response().code(404); - } - return readSignals({ - alertsClient, - id, - }); - }, - }); + }, + async handler(request: Hapi.Request, headers) { + const { id } = request.params; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; + + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } + return readSignals({ + alertsClient, + id, + }); + }, +}; + +export const readSignalsRoute = (server: Hapi.Server) => { + server.route(createReadSignalsRoute); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/update_signals_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/update_signals_route.test.ts new file mode 100644 index 0000000000000..eff6923b3fbda --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/update_signals_route.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockServer, + createMockServerWithoutActionClientDecoration, + createMockServerWithoutAlertClientDecoration, + createMockServerWithoutActionOrAlertClientDecoration, +} from './__mocks__/_mock_server'; + +import { updateSignalsRoute } from './update_signals_route'; +import { ServerInjectOptions } from 'hapi'; +import { + getFindResult, + getResult, + updateActionResult, + updateAlertResult, + getUpdateRequest, +} from './__mocks__/request_responses'; + +describe('update_signals', () => { + let { server, alertsClient, actionsClient } = createMockServer(); + + beforeEach(() => { + jest.resetAllMocks(); + ({ server, alertsClient, actionsClient } = createMockServer()); + updateSignalsRoute(server); + }); + + describe('status codes with actionClient and alertClient', () => { + it('returns 200 when deleting a single signal with a valid actionClient and alertClient', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(updateAlertResult()); + const { statusCode } = await server.inject(getUpdateRequest()); + expect(statusCode).toBe(200); + }); + + it('returns 404 if actionClient is not available on the route', async () => { + const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); + updateSignalsRoute(serverWithoutActionClient); + const { statusCode } = await serverWithoutActionClient.inject(getUpdateRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient is not available on the route', async () => { + const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); + updateSignalsRoute(serverWithoutAlertClient); + const { statusCode } = await serverWithoutAlertClient.inject(getUpdateRequest()); + expect(statusCode).toBe(404); + }); + + it('returns 404 if alertClient and actionClient are both not available on the route', async () => { + const { + serverWithoutActionOrAlertClient, + } = createMockServerWithoutActionOrAlertClientDecoration(); + updateSignalsRoute(serverWithoutActionOrAlertClient); + const { statusCode } = await serverWithoutActionOrAlertClient.inject(getUpdateRequest()); + expect(statusCode).toBe(404); + }); + }); + + describe('validation', () => { + it('returns 400 if id is not given in either the body or the url', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + const request: ServerInjectOptions = { + method: 'PUT', + url: '/api/siem/signals', + payload: { + // missing id should throw a 400 + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'kql', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + + it('returns 200 if type is kql', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(updateAlertResult()); + const request: ServerInjectOptions = { + method: 'PUT', + url: '/api/siem/signals', + payload: { + id: 'rule-1', + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'kql', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + }); + + it('returns 200 if type is filter', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(updateAlertResult()); + const request: ServerInjectOptions = { + method: 'PUT', + url: '/api/siem/signals', + payload: { + id: 'rule-1', + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'filter', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + }); + + it('returns 400 if type is not filter or kql', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(updateAlertResult()); + const request: ServerInjectOptions = { + method: 'PUT', + url: '/api/siem/signals', + payload: { + id: 'rule-1', + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'something-made-up', // This is a made up type that causes the 400 + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(400); + }); + + it('returns 200 if id is given in the url but not the payload', async () => { + alertsClient.find.mockResolvedValue(getFindResult()); + alertsClient.get.mockResolvedValue(getResult()); + actionsClient.update.mockResolvedValue(updateActionResult()); + alertsClient.update.mockResolvedValue(updateAlertResult()); + const request: ServerInjectOptions = { + method: 'PUT', + url: '/api/siem/signals/rule-1', + payload: { + // missing id should throw a 400 + description: 'Detecting root and admin users', + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + name: 'Detect Root/Admin Users', + severity: 1, + type: 'kql', + from: 'now-6m', + to: 'now', + kql: 'user.name: root or user.name: admin', + }, + }; + const { statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/update_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/update_signals_route.ts new file mode 100644 index 0000000000000..7a437f04e7f2d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/update_signals_route.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Hapi from 'hapi'; +import Joi from 'joi'; +import { isFunction } from 'lodash/fp'; +import { updateSignal } from '../alerts/update_signals'; +import { SignalsRequest } from '../alerts/types'; + +export const createUpdateSignalsRoute: Hapi.ServerRoute = { + method: 'PUT', + path: '/api/siem/signals/{id?}', + options: { + tags: ['access:signals-all'], + validate: { + options: { + abortEarly: false, + }, + params: { + id: Joi.when(Joi.ref('$payload.id'), { + is: Joi.exist(), + then: Joi.string().optional(), + otherwise: Joi.string().required(), + }), + }, + payload: Joi.object({ + description: Joi.string(), + enabled: Joi.boolean(), + filter: Joi.object(), + from: Joi.string(), + id: Joi.string(), + index: Joi.array(), + interval: Joi.string(), + kql: Joi.string(), + max_signals: Joi.number().default(100), + name: Joi.string(), + severity: Joi.number(), + to: Joi.string(), + type: Joi.string().valid('filter', 'kql'), + references: Joi.array().default([]), + }).nand('filter', 'kql'), + }, + }, + async handler(request: SignalsRequest, headers) { + const { + description, + enabled, + filter, + kql, + from, + id, + index, + interval, + // eslint-disable-next-line @typescript-eslint/camelcase + max_signals: maxSignals, + name, + severity, + to, + type, + references, + } = request.payload; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + + const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; + + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } + + return updateSignal({ + alertsClient, + actionsClient, + description, + enabled, + filter, + from, + id: request.params.id ? request.params.id : id, + index, + interval, + kql, + maxSignals, + name, + severity, + to, + type, + references, + }); + }, +}; + +export const updateSignalsRoute = (server: Hapi.Server) => { + server.route(createUpdateSignalsRoute); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/updated_signals_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/updated_signals_route.ts deleted file mode 100644 index be567695f730d..0000000000000 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/updated_signals_route.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Hapi from 'hapi'; -import Joi from 'joi'; -import { isFunction } from 'lodash/fp'; -import { updateSignal } from '../alerts/update_signals'; - -interface SignalsRequest extends Hapi.Request { - payload: { - description: string; - enabled: boolean; - filter: Record | undefined; - from: string; - id: string; - index: string[]; - interval: string; - kql: string | undefined; - max_signals: string; - name: string; - severity: number; - type: string; - to: string; - references: string[]; - }; -} - -export const updateSignalsRoute = (server: Hapi.Server) => { - server.route({ - method: 'PUT', - path: '/api/siem/signals/{id?}', - options: { - tags: ['access:signals-all'], - validate: { - options: { - abortEarly: false, - }, - params: { - id: Joi.when(Joi.ref('$payload.id'), { - is: Joi.exist(), - then: Joi.string().optional(), - otherwise: Joi.string().required(), - }), - }, - payload: Joi.object({ - description: Joi.string(), - enabled: Joi.boolean(), - filter: Joi.object(), - from: Joi.string(), - id: Joi.string(), - index: Joi.array(), - interval: Joi.string(), - kql: Joi.string(), - max_signals: Joi.number().default(100), - name: Joi.string(), - severity: Joi.number(), - to: Joi.string(), - type: Joi.string(), // TODO: Restrict this to only be kql or filter for the moment - references: Joi.array().default([]), - }).nand('filter', 'kql'), - }, - }, - async handler(request: SignalsRequest, headers) { - const { - description, - enabled, - filter, - kql, - from, - id, - index, - interval, - // eslint-disable-next-line @typescript-eslint/camelcase - max_signals: maxSignals, - name, - severity, - to, - type, - references, - } = request.payload; - const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; - - const actionsClient = isFunction(request.getActionsClient) - ? request.getActionsClient() - : null; - - if (!alertsClient || !actionsClient) { - return headers.response().code(404); - } - - return updateSignal({ - alertsClient, - actionsClient, - description, - enabled, - filter, - from, - id: request.params.id ? request.params.id : id, - index, - interval, - kql, - maxSignals, - name, - severity, - to, - type, - references, - }); - }, - }); -}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/convert_saved_search_to_signals.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/convert_saved_search_to_signals.sh new file mode 100755 index 0000000000000..802273c67849d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/convert_saved_search_to_signals.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +node ../../../../scripts/convert_saved_search_to_signals.js $1 $2 diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/read_signal.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_signal.sh similarity index 100% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/read_signal.sh rename to x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/get_signal.sh diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_signal.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_signal.sh index 961953c71b2dc..6d79856ffd4fb 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_signal.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_signal.sh @@ -10,14 +10,22 @@ set -e ./check_env_variables.sh # Uses a default if no argument is specified -SIGNAL=${1:-./signals/root_or_admin_1.json} +SIGNALS=(${@:-./signals/root_or_admin_1.json}) # Example: ./post_signal.sh # Example: ./post_signal.sh ./signals/root_or_admin_1.json -curl -s -k \ - -H 'Content-Type: application/json' \ - -H 'kbn-xsrf: 123' \ - -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X POST ${KIBANA_URL}/api/siem/signals \ - -d @${SIGNAL} \ - | jq . +# Example glob: ./post_signal.sh ./signals/* +for SIGNAL in "${SIGNALS[@]}" +do { + [ -e "$SIGNAL" ] || continue + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}/api/siem/signals \ + -d @${SIGNAL} \ + | jq .; +} & +done + +wait diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_x_signals.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_x_signals.sh new file mode 100755 index 0000000000000..1aaecca37ba6f --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/post_x_signals.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Uses a default of 100 if no argument is specified +NUMBER=${1:-100} + +# Example: ./post_x_signals.sh +# Example: ./post_x_signals.sh 200 +for i in $(seq 1 $NUMBER); +do { + curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}/api/siem/signals \ + --data "{ + \"id\": \"${i}\", + \"description\": \"Detecting root and admin users\", + \"index\": [\"auditbeat-*\", \"filebeat-*\", \"packetbeat-*\", \"winlogbeat-*\"], + \"interval\": \"24h\", + \"name\": \"Detect Root/Admin Users\", + \"severity\": 1, + \"type\": \"kql\", + \"from\": \"now-6m\", + \"to\": \"now\", + \"kql\": \"user.name: root or user.name: admin\" + }" \ + | jq .; +} & +done + +wait \ No newline at end of file diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_1.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_1.json index 8b07d2d0ee86c..8bf96b6da47d0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_1.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_1.json @@ -1,5 +1,5 @@ { - "id": "1", + "id": "rule-1", "description": "Detecting root and admin users", "index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"], "interval": "5m", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_2.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_2.json index bc206cb8b846e..93686f527264e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_2.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_2.json @@ -1,5 +1,5 @@ { - "id": "2", + "id": "rule-2", "description": "Detecting root and admin users over a long period of time", "index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"], "interval": "24h", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_3.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_3.json index 4c4eaf71605b7..fc2f390ced790 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_3.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_3.json @@ -1,5 +1,5 @@ { - "id": "3", + "id": "rule-3", "description": "Detecting root and admin users", "index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"], "interval": "5m", @@ -10,4 +10,3 @@ "to": "now-15y", "kql": "user.name: root or user.name: admin" } - diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_filter_9999.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_filter_9999.json index 04d4315f8a868..f4d6b154c4ca7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_filter_9999.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_filter_9999.json @@ -1,5 +1,5 @@ { - "id": "9999", + "id": "rule-9999", "description": "Detecting root and admin users", "index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"], "interval": "5m", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/temp_update_1.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_update_1.json similarity index 85% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/temp_update_1.json rename to x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_update_1.json index 8ca571666ea4e..660c9585369bc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/temp_update_1.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/root_or_admin_update_1.json @@ -1,5 +1,5 @@ { - "id": "f47e5de7-0023-4898-b856-cf3874e883ea", + "id": "rule-1", "description": "Only watch winlogbeat users", "index": ["winlogbeat-*"], "interval": "9m", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/watch_longmont_3.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/watch_longmont_3.json index 21ca9f8ba73b8..94090b7158aee 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/watch_longmont_3.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/signals/watch_longmont_3.json @@ -1,5 +1,5 @@ { - "id": "3", + "id": "rule-3", "description": "Detect Longmont activity", "index": ["auditbeat-*", "filebeat-*", "packetbeat-*", "winlogbeat-*"], "interval": "24h", diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_signal.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_signal.sh index 0980999156f11..8cf69dc41e0be 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_signal.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/update_signal.sh @@ -9,11 +9,8 @@ set -e ./check_env_variables.sh -# TODO: Since we only have GUID's at the moment, you have to use ./find_signals.sh and then copy and paste that -# into the temp_update_1.json as your ID in order to run this script. - # Uses a default if no argument is specified -SIGNAL=${1:-./signals/temp_update_1.json} +SIGNAL=${1:-./signals/root_or_admin_update_1.json} # Example: ./update_signal.sh {id} ./signals/root_or_admin_1.json curl -s -k \