diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index a1f63a6d4e0c6..a4e812e8f111a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -121,10 +121,9 @@ export const buildSignalFromSequence = ( ): SignalHit => { const rule = buildRuleWithoutOverrides(ruleSO); const timestamp = new Date().toISOString(); - - const reason = buildReasonMessage({ rule }); - const signal: Signal = buildSignal(events, rule, reason); const mergedEvents = objectArrayIntersection(events.map((event) => event._source)); + const reason = buildReasonMessage({ rule, mergedDoc: mergedEvents as SignalSourceHit }); + const signal: Signal = buildSignal(events, rule, reason); return { ...mergedEvents, '@timestamp': timestamp, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts index 1a383b51eb8d4..5b55df2bee936 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { buildCommonReasonMessage } from './reason_formatters'; +import { buildReasonMessageUtil } from './reason_formatters'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; import { SignalSourceHit } from './types'; @@ -14,26 +14,48 @@ describe('reason_formatter', () => { let mergedDoc: SignalSourceHit; beforeAll(() => { rule = { - name: 'What is in a name', + name: 'my-rule', risk_score: 9000, severity: 'medium', } as RulesSchema; // Cast here as all fields aren't required mergedDoc = { - _index: 'some-index', - _id: 'some-id', + _index: 'index-1', + _id: 'id-1', fields: { - 'host.name': ['party host'], - 'user.name': ['ferris bueller'], + 'destination.address': ['9.99.99.9'], + 'destination.port': ['6789'], + 'event.category': ['test'], + 'file.name': ['sample'], + 'host.name': ['host'], + 'process.name': ['doingThings.exe'], + 'process.parent.name': ['didThings.exe'], + 'source.address': ['1.11.11.1'], + 'source.port': ['1234'], + 'user.name': ['test-user'], '@timestamp': '2021-08-11T02:28:59.101Z', }, }; }); - describe('buildCommonReasonMessage', () => { + describe('buildReasonMessageUtil', () => { describe('when rule and mergedDoc are provided', () => { it('should return the full reason message', () => { - expect(buildCommonReasonMessage({ rule, mergedDoc })).toEqual( - 'Alert What is in a name created with a medium severity and risk score of 9000 by ferris bueller on party host.' + expect(buildReasonMessageUtil({ rule, mergedDoc })).toMatchInlineSnapshot( + `"test event with process doingThings.exe, parent process didThings.exe, file sample, source 1.11.11.1:1234, destination 9.99.99.9:6789, by test-user on host created medium alert my-rule."` + ); + }); + }); + describe('when event category contains multiple items', () => { + it('should return the reason message with all categories showing', () => { + const updatedMergedDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'event.category': ['item one', 'item two'], + }, + }; + expect(buildReasonMessageUtil({ rule, mergedDoc: updatedMergedDoc })).toMatchInlineSnapshot( + `"item one, item two event with process doingThings.exe, parent process didThings.exe, file sample, source 1.11.11.1:1234, destination 9.99.99.9:6789, by test-user on host created medium alert my-rule."` ); }); }); @@ -46,8 +68,8 @@ describe('reason_formatter', () => { 'host.name': ['-'], }, }; - expect(buildCommonReasonMessage({ rule, mergedDoc: updatedMergedDoc })).toEqual( - 'Alert What is in a name created with a medium severity and risk score of 9000 by ferris bueller.' + expect(buildReasonMessageUtil({ rule, mergedDoc: updatedMergedDoc })).toMatchInlineSnapshot( + `"test event with process doingThings.exe, parent process didThings.exe, file sample, source 1.11.11.1:1234, destination 9.99.99.9:6789, by test-user created medium alert my-rule."` ); }); }); @@ -60,16 +82,102 @@ describe('reason_formatter', () => { 'user.name': ['-'], }, }; - expect(buildCommonReasonMessage({ rule, mergedDoc: updatedMergedDoc })).toEqual( - 'Alert What is in a name created with a medium severity and risk score of 9000 on party host.' + expect(buildReasonMessageUtil({ rule, mergedDoc: updatedMergedDoc })).toMatchInlineSnapshot( + `"test event with process doingThings.exe, parent process didThings.exe, file sample, source 1.11.11.1:1234, destination 9.99.99.9:6789, on host created medium alert my-rule."` + ); + }); + }); + describe('when rule and mergedDoc are provided, but destination details are missing', () => { + it('should return the reason message without the destination port', () => { + const noDestinationPortDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'destination.port': ['-'], + }, + }; + expect( + buildReasonMessageUtil({ rule, mergedDoc: noDestinationPortDoc }) + ).toMatchInlineSnapshot( + `"test event with process doingThings.exe, parent process didThings.exe, file sample, source 1.11.11.1:1234, destination 9.99.99.9 by test-user on host created medium alert my-rule."` + ); + }); + it('should return the reason message without destination details', () => { + const noDestinationPortDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'destination.address': ['-'], + 'destination.port': ['-'], + }, + }; + expect( + buildReasonMessageUtil({ rule, mergedDoc: noDestinationPortDoc }) + ).toMatchInlineSnapshot( + `"test event with process doingThings.exe, parent process didThings.exe, file sample, source 1.11.11.1:1234, by test-user on host created medium alert my-rule."` + ); + }); + }); + describe('when rule and mergedDoc are provided, but source details are missing', () => { + it('should return the reason message without the source port', () => { + const noSourcePortDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'source.port': ['-'], + }, + }; + expect(buildReasonMessageUtil({ rule, mergedDoc: noSourcePortDoc })).toMatchInlineSnapshot( + `"test event with process doingThings.exe, parent process didThings.exe, file sample, source 1.11.11.1 destination 9.99.99.9:6789, by test-user on host created medium alert my-rule."` + ); + }); + it('should return the reason message without source details', () => { + const noSourcePortDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'source.address': ['-'], + 'source.port': ['-'], + }, + }; + expect(buildReasonMessageUtil({ rule, mergedDoc: noSourcePortDoc })).toMatchInlineSnapshot( + `"test event with process doingThings.exe, parent process didThings.exe, file sample, destination 9.99.99.9:6789, by test-user on host created medium alert my-rule."` + ); + }); + }); + describe('when rule and mergedDoc are provided, but process details missing', () => { + it('should return the reason message without process details', () => { + const updatedMergedDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'process.name': ['-'], + 'process.parent.name': ['-'], + }, + }; + expect(buildReasonMessageUtil({ rule, mergedDoc: updatedMergedDoc })).toMatchInlineSnapshot( + `"test event with file sample, source 1.11.11.1:1234, destination 9.99.99.9:6789, by test-user on host created medium alert my-rule."` + ); + }); + }); + describe('when rule and mergedDoc are provided without any fields of interest', () => { + it('should return the full reason message', () => { + const updatedMergedDoc = { + ...mergedDoc, + fields: { + 'event.category': ['test'], + 'user.name': ['test-user'], + '@timestamp': '2021-08-11T02:28:59.101Z', + }, + }; + expect(buildReasonMessageUtil({ rule, mergedDoc: updatedMergedDoc })).toMatchInlineSnapshot( + `"test event by test-user created medium alert my-rule."` ); }); }); describe('when only rule is provided', () => { it('should return the reason message without host name or user name', () => { - expect(buildCommonReasonMessage({ rule })).toEqual( - 'Alert What is in a name created with a medium severity and risk score of 9000.' - ); + expect(buildReasonMessageUtil({ rule })).toMatchInlineSnapshot(`""`); }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts index 4917cdbd29170..e93a45bd13246 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { getOr } from 'lodash/fp'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; import { SignalSourceHit } from './types'; @@ -14,54 +15,118 @@ export interface BuildReasonMessageArgs { mergedDoc?: SignalSourceHit; } +export interface BuildReasonMessageUtilArgs extends BuildReasonMessageArgs { + type?: 'eql' | 'ml' | 'query' | 'threatMatch' | 'threshold'; +} + export type BuildReasonMessage = (args: BuildReasonMessageArgs) => string; +interface ReasonFields { + destinationAddress?: string | string[] | null; + destinationPort?: string | string[] | null; + eventCategory?: string | string[] | null; + fileName?: string | string[] | null; + hostName?: string | string[] | null; + processName?: string | string[] | null; + processParentName?: string | string[] | null; + sourceAddress?: string | string[] | null; + sourcePort?: string | string[] | null; + userName?: string | string[] | null; +} +const getFieldsFromDoc = (mergedDoc: SignalSourceHit) => { + const reasonFields: ReasonFields = {}; + const docToUse = mergedDoc?.fields || mergedDoc; + + reasonFields.destinationAddress = getOr(null, 'destination.address', docToUse); + reasonFields.destinationPort = getOr(null, 'destination.port', docToUse); + reasonFields.eventCategory = getOr(null, 'event.category', docToUse); + reasonFields.fileName = getOr(null, 'file.name', docToUse); + reasonFields.hostName = getOr(null, 'host.name', docToUse); + reasonFields.processName = getOr(null, 'process.name', docToUse); + reasonFields.processParentName = getOr(null, 'process.parent.name', docToUse); + reasonFields.sourceAddress = getOr(null, 'source.address', docToUse); + reasonFields.sourcePort = getOr(null, 'source.port', docToUse); + reasonFields.userName = getOr(null, 'user.name', docToUse); + + return reasonFields; +}; /** * Currently all security solution rule types share a common reason message string. This function composes that string * In the future there may be different configurations based on the different rule types, so the plumbing has been put in place * to more easily allow for this in the future. * @export buildCommonReasonMessage - is only exported for testing purposes, and only used internally here. */ -export const buildCommonReasonMessage = ({ rule, mergedDoc }: BuildReasonMessageArgs) => { - if (!rule) { +export const buildReasonMessageUtil = ({ rule, mergedDoc }: BuildReasonMessageUtilArgs) => { + if (!rule || !mergedDoc) { // This should never happen, but in case, better to not show a malformed string return ''; } - let hostName; - let userName; - if (mergedDoc?.fields) { - hostName = mergedDoc.fields['host.name'] != null ? mergedDoc.fields['host.name'] : hostName; - userName = mergedDoc.fields['user.name'] != null ? mergedDoc.fields['user.name'] : userName; - } + const { + destinationAddress, + destinationPort, + eventCategory, + fileName, + hostName, + processName, + processParentName, + sourceAddress, + sourcePort, + userName, + } = getFieldsFromDoc(mergedDoc); + + const fieldPresenceTracker = { hasFieldOfInterest: false }; - const isFieldEmpty = (field: string | string[] | undefined | null) => - !field || !field.length || (field.length === 1 && field[0] === '-'); + const getFieldTemplateValue = ( + field: string | string[] | undefined | null, + isFieldOfInterest?: boolean + ): string | null => { + if (!field || !field.length || (field.length === 1 && field[0] === '-')) return null; + if (isFieldOfInterest && !fieldPresenceTracker.hasFieldOfInterest) + fieldPresenceTracker.hasFieldOfInterest = true; + return Array.isArray(field) ? field.join(', ') : field; + }; return i18n.translate('xpack.securitySolution.detectionEngine.signals.alertReasonDescription', { - defaultMessage: - 'Alert {alertName} created with a {alertSeverity} severity and risk score of {alertRiskScore}{userName, select, null {} other {{whitespace}by {userName}} }{hostName, select, null {} other {{whitespace}on {hostName}} }.', + defaultMessage: `{eventCategory, select, null {} other {{eventCategory}{whitespace}}}event\ +{hasFieldOfInterest, select, false {} other {{whitespace}with}}\ +{processName, select, null {} other {{whitespace}process {processName},} }\ +{processParentName, select, null {} other {{whitespace}parent process {processParentName},} }\ +{fileName, select, null {} other {{whitespace}file {fileName},} }\ +{sourceAddress, select, null {} other {{whitespace}source {sourceAddress}}}{sourcePort, select, null {} other {:{sourcePort},}}\ +{destinationAddress, select, null {} other {{whitespace}destination {destinationAddress}}}{destinationPort, select, null {} other {:{destinationPort},}}\ +{userName, select, null {} other {{whitespace}by {userName}} }\ +{hostName, select, null {} other {{whitespace}on {hostName}} } \ +created {alertSeverity} alert {alertName}.`, values: { alertName: rule.name, alertSeverity: rule.severity, - alertRiskScore: rule.risk_score, - hostName: isFieldEmpty(hostName) ? 'null' : hostName, - userName: isFieldEmpty(userName) ? 'null' : userName, + destinationAddress: getFieldTemplateValue(destinationAddress, true), + destinationPort: getFieldTemplateValue(destinationPort, true), + eventCategory: getFieldTemplateValue(eventCategory), + fileName: getFieldTemplateValue(fileName, true), + hostName: getFieldTemplateValue(hostName), + processName: getFieldTemplateValue(processName, true), + processParentName: getFieldTemplateValue(processParentName, true), + sourceAddress: getFieldTemplateValue(sourceAddress, true), + sourcePort: getFieldTemplateValue(sourcePort, true), + userName: getFieldTemplateValue(userName), + hasFieldOfInterest: fieldPresenceTracker.hasFieldOfInterest, // Tracking if we have any fields to show the 'with' word whitespace: ' ', // there isn't support for the unicode /u0020 for whitespace, and leading spaces are deleted, so to prevent double-whitespace explicitly passing the space in. }, }); }; export const buildReasonMessageForEqlAlert = (args: BuildReasonMessageArgs) => - buildCommonReasonMessage({ ...args }); + buildReasonMessageUtil({ ...args, type: 'eql' }); export const buildReasonMessageForMlAlert = (args: BuildReasonMessageArgs) => - buildCommonReasonMessage({ ...args }); + buildReasonMessageUtil({ ...args, type: 'ml' }); export const buildReasonMessageForQueryAlert = (args: BuildReasonMessageArgs) => - buildCommonReasonMessage({ ...args }); + buildReasonMessageUtil({ ...args, type: 'query' }); export const buildReasonMessageForThreatMatchAlert = (args: BuildReasonMessageArgs) => - buildCommonReasonMessage({ ...args }); + buildReasonMessageUtil({ ...args, type: 'threatMatch' }); export const buildReasonMessageForThresholdAlert = (args: BuildReasonMessageArgs) => - buildCommonReasonMessage({ ...args }); + buildReasonMessageUtil({ ...args, type: 'threshold' }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts index 58df5bc3ff9e1..496781dbb985f 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts @@ -193,7 +193,7 @@ export default ({ getService }: FtrProviderContext) => { index: '.ml-anomalies-custom-linux_anomalous_network_activity_ecs', depth: 0, }, - reason: `Alert Test ML rule created with a critical severity and risk score of 50 by root on mothra.`, + reason: `event with process store, by root on mothra created critical alert Test ML rule.`, original_time: '2020-11-16T22:58:08.000Z', }, all_field_values: [ diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index f985cdfecc465..0aad3c699805a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -287,7 +287,8 @@ export default ({ getService }: FtrProviderContext) => { depth: 0, }, ], - reason: `Alert Query with a rule id created with a high severity and risk score of 55 by root on zeek-sensor-amsterdam.`, + reason: + 'user-login event by root on zeek-sensor-amsterdam created high alert Query with a rule id.', rule: fullSignal.signal.rule, status: 'open', }, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index b90ceb3dde9cc..c954d8aa5721d 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -362,7 +362,8 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { - reason: `Alert Signal Testing Query created with a high severity and risk score of 1 on suricata-zeek-sensor-toronto.`, + reason: + 'configuration event on suricata-zeek-sensor-toronto created high alert Signal Testing Query.', rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, status: 'open', @@ -497,7 +498,8 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { - reason: `Alert Signal Testing Query created with a high severity and risk score of 1 on suricata-zeek-sensor-toronto.`, + reason: + 'configuration event on suricata-zeek-sensor-toronto created high alert Signal Testing Query.', rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, status: 'open', @@ -662,7 +664,8 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { - reason: `Alert Signal Testing Query created with a high severity and risk score of 1 by root on zeek-sensor-amsterdam.`, + reason: + 'anomoly event with process bro, by root on zeek-sensor-amsterdam created high alert Signal Testing Query.', rule: fullSignal.signal.rule, group: fullSignal.signal.group, original_time: fullSignal.signal.original_time, @@ -753,7 +756,8 @@ export default ({ getService }: FtrProviderContext) => { status: 'open', depth: 2, group: source.signal.group, - reason: `Alert Signal Testing Query created with a high severity and risk score of 1.`, + reason: + 'event by root on zeek-sensor-amsterdam created high alert Signal Testing Query.', rule: source.signal.rule, ancestors: [ { @@ -872,7 +876,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', - reason: `Alert Signal Testing Query created with a high severity and risk score of 1.`, + reason: 'event created high alert Signal Testing Query.', rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1010,7 +1014,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', - reason: `Alert Signal Testing Query created with a high severity and risk score of 1.`, + reason: `event created high alert Signal Testing Query.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1094,7 +1098,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', - reason: `Alert Signal Testing Query created with a high severity and risk score of 1.`, + reason: `event created high alert Signal Testing Query.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1692,7 +1696,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', - reason: `Alert boot created with a high severity and risk score of 1 on zeek-sensor-amsterdam.`, + reason: `event on zeek-sensor-amsterdam created high alert boot.`, rule: { ...fullSignal.signal.rule, name: 'boot',