diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index 3c04e2b0da9c..63a38ad7d71c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -1161,8 +1161,8 @@ describe('get_filter', () => { expect(request).toEqual({ method: 'POST', path: `/testindex1,testindex2/_eql/search?allow_no_indices=true`, - event_category_field: 'event.other_category', body: { + event_category_field: 'event.other_category', size: 100, query: 'process where true', filter: { diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index 70fe2b6187aa..e562d186bc42 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -65,7 +65,6 @@ interface EqlSearchRequest { method: string; path: string; body: object; - event_category_field?: string; } export const buildEqlSearchRequest = ( @@ -109,7 +108,7 @@ export const buildEqlSearchRequest = ( }, }); } - const baseRequest = { + return { method: 'POST', path: `/${indexString}/_eql/search?allow_no_indices=true`, body: { @@ -120,14 +119,7 @@ export const buildEqlSearchRequest = ( filter: requestFilter, }, }, + event_category_field: eventCategoryOverride, }, }; - if (eventCategoryOverride) { - return { - ...baseRequest, - event_category_field: eventCategoryOverride, - }; - } else { - return baseRequest; - } }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts index e8beef3e58a4..18f985872672 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts @@ -8,9 +8,20 @@ /* eslint-disable @typescript-eslint/naming-convention */ import expect from '@kbn/expect'; -import { CreateRulesSchema } from '../../../../plugins/security_solution/common/detection_engine/schemas/request'; +import { + CreateRulesSchema, + EqlCreateSchema, + QueryCreateSchema, + ThreatMatchCreateSchema, + ThresholdCreateSchema, +} from '../../../../plugins/security_solution/common/detection_engine/schemas/request'; import { getCreateExceptionListItemMinimalSchemaMock } from '../../../../plugins/lists/common/schemas/request/create_exception_list_item_schema.mock'; -import { deleteAllExceptions } from '../../../lists_api_integration/utils'; +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../lists_api_integration/utils'; import { RulesSchema } from '../../../../plugins/security_solution/common/detection_engine/schemas/response'; import { getCreateExceptionListMinimalSchemaMock } from '../../../../plugins/lists/common/schemas/request/create_exception_list_schema.mock'; import { CreateExceptionListItemSchema } from '../../../../plugins/lists/common'; @@ -39,6 +50,9 @@ import { getSignalsByIds, findImmutableRuleById, getPrePackagedRulesStatus, + getRuleForSignalTesting, + getOpenSignals, + createRuleWithExceptionEntries, } from '../../utils'; import { ROLES } from '../../../../plugins/security_solution/common/test'; import { createUserAndRole, deleteUserAndRole } from '../roles_users_utils'; @@ -576,25 +590,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('should be able to execute against an exception list that does include valid entries and get back 0 signals', async () => { - const { id, list_id, namespace_type, type } = await createExceptionList( - supertest, - getCreateExceptionListMinimalSchemaMock() - ); - - const exceptionListItem: CreateExceptionListItemSchema = { - ...getCreateExceptionListItemMinimalSchemaMock(), - entries: [ - { - field: 'host.name', // This matches the query below which will exclude everything - operator: 'included', - type: 'match', - value: 'suricata-sensor-amsterdam', - }, - ], - }; - await createExceptionListItem(supertest, exceptionListItem); - - const ruleWithException: CreateRulesSchema = { + const rule: QueryCreateSchema = { name: 'Simple Rule Query', description: 'Simple Rule Query', enabled: true, @@ -605,20 +601,200 @@ export default ({ getService }: FtrProviderContext) => { type: 'query', from: '1900-01-01T00:00:00.000Z', query: 'host.name: "suricata-sensor-amsterdam"', - exceptions_list: [ + }; + const createdRule = await createRuleWithExceptionEntries(supertest, rule, [ + [ { - id, - list_id, - namespace_type, - type, + field: 'host.name', // This matches the query above which will exclude everything + operator: 'included', + type: 'match', + value: 'suricata-sensor-amsterdam', }, ], - }; - const rule = await createRule(supertest, ruleWithException); - await waitForRuleSuccessOrStatus(supertest, rule.id); - const signalsOpen = await getSignalsByIds(supertest, [rule.id]); + ]); + const signalsOpen = await getOpenSignals(supertest, es, createdRule); expect(signalsOpen.hits.hits.length).equal(0); }); + + it('generates no signals when an exception is added for an EQL rule', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'configuration where agent.id=="a1d7b39c-f898-4dbe-a761-efb61939302d"', + }; + const createdRule = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'host.id', + operator: 'included', + type: 'match', + value: '8cc95778cce5407c809480e8e32ad76b', + }, + ], + ]); + const signalsOpen = await getOpenSignals(supertest, es, createdRule); + expect(signalsOpen.hits.hits.length).equal(0); + }); + + it('generates no signals when an exception is added for a threshold rule', async () => { + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: 'threshold-rule', + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'host.id', + value: 700, + }, + }; + const createdRule = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'host.id', + operator: 'included', + type: 'match', + value: '8cc95778cce5407c809480e8e32ad76b', + }, + ], + ]); + const signalsOpen = await getOpenSignals(supertest, es, createdRule); + expect(signalsOpen.hits.hits.length).equal(0); + }); + + it('generates no signals when an exception is added for a threat match rule', async () => { + const rule: ThreatMatchCreateSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', + threat_query: 'source.ip: "188.166.120.93"', // narrow things down with a query to a specific source ip + threat_index: ['auditbeat-*'], // We use auditbeat as both the matching index and the threat list for simplicity + threat_mapping: [ + // We match host.name against host.name + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const createdRule = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'source.ip', + operator: 'included', + type: 'match', + value: '188.166.120.93', + }, + ], + ]); + const signalsOpen = await getOpenSignals(supertest, es, createdRule); + expect(signalsOpen.hits.hits.length).equal(0); + }); + describe('rules with value list exceptions', () => { + beforeEach(async () => { + await createListsIndex(supertest); + }); + + afterEach(async () => { + await deleteListsIndex(supertest); + }); + + it('generates no signals when a value list exception is added for a query rule', async () => { + const valueListId = 'value-list-id'; + await importFile(supertest, 'keyword', ['suricata-sensor-amsterdam'], valueListId); + const rule: QueryCreateSchema = { + name: 'Simple Rule Query', + description: 'Simple Rule Query', + enabled: true, + risk_score: 1, + rule_id: 'rule-1', + severity: 'high', + index: ['auditbeat-*'], + type: 'query', + from: '1900-01-01T00:00:00.000Z', + query: 'host.name: "suricata-sensor-amsterdam"', + }; + const createdRule = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'host.name', + operator: 'included', + type: 'list', + list: { + id: valueListId, + type: 'keyword', + }, + }, + ], + ]); + const signalsOpen = await getOpenSignals(supertest, es, createdRule); + expect(signalsOpen.hits.hits.length).equal(0); + }); + + it('generates no signals when a value list exception is added for a threat match rule', async () => { + const valueListId = 'value-list-id'; + await importFile(supertest, 'keyword', ['zeek-sensor-amsterdam'], valueListId); + const rule: ThreatMatchCreateSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', + threat_query: 'source.ip: "188.166.120.93"', // narrow things down with a query to a specific source ip + threat_index: ['auditbeat-*'], // We use auditbeat as both the matching index and the threat list for simplicity + threat_mapping: [ + // We match host.name against host.name + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const createdRule = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'host.name', + operator: 'included', + type: 'list', + list: { + id: valueListId, + type: 'keyword', + }, + }, + ], + ]); + const signalsOpen = await getOpenSignals(supertest, es, createdRule); + expect(signalsOpen.hits.hits.length).equal(0); + }); + }); }); }); }); 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 08fb9222e178..6f437f7bcc8e 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 @@ -6,11 +6,12 @@ */ import expect from '@kbn/expect'; -import { orderBy } from 'lodash'; +import { orderBy, get } from 'lodash'; import { EqlCreateSchema, QueryCreateSchema, + ThresholdCreateSchema, } from '../../../../plugins/security_solution/common/detection_engine/schemas/request'; import { DEFAULT_SIGNALS_INDEX } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -216,55 +217,263 @@ export default ({ getService }: FtrProviderContext) => { }); describe('EQL Rules', () => { - it('generates signals from EQL sequences in the expected form', async () => { + it('generates a correctly formatted signal from EQL non-sequence queries', async () => { const rule: EqlCreateSchema = { ...getRuleForSignalTesting(['auditbeat-*']), rule_id: 'eql-rule', type: 'eql', language: 'eql', - query: 'sequence by host.name [any where true] [any where true]', + query: 'configuration where agent.id=="a1d7b39c-f898-4dbe-a761-efb61939302d"', }; const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 1, [id]); const signals = await getSignalsByRuleIds(supertest, ['eql-rule']); - const signal = signals.hits.hits[0]._source.signal; + expect(signals.hits.hits.length).eql(1); + const fullSignal = signals.hits.hits[0]._source; - expect(signal).eql({ - rule: signal.rule, - group: signal.group, - original_time: signal.original_time, - status: 'open', - depth: 1, - ancestors: [ - { - depth: 0, - id: 'gCF0B2kBR346wHgnb7m0', - index: 'auditbeat-8.0.0-2019.02.19-000001', - type: 'event', + expect(fullSignal).eql({ + '@timestamp': fullSignal['@timestamp'], + agent: { + ephemeral_id: '0010d67a-14f7-41da-be30-489fea735967', + hostname: 'suricata-zeek-sensor-toronto', + id: 'a1d7b39c-f898-4dbe-a761-efb61939302d', + type: 'auditbeat', + version: '8.0.0', + }, + auditd: { + data: { + audit_enabled: '1', + old: '1', }, - ], - original_event: { - action: 'error', - category: 'user-login', + message_type: 'config_change', + result: 'success', + sequence: 1496, + session: 'unset', + summary: { + actor: { + primary: 'unset', + }, + object: { + primary: '1', + type: 'audit-config', + }, + }, + }, + cloud: { + instance: { + id: '133555295', + }, + provider: 'digitalocean', + region: 'tor1', + }, + ecs: { + version: '1.0.0-beta2', + }, + event: { + action: 'changed-audit-configuration', + category: 'configuration', module: 'auditd', + kind: 'signal', }, - parent: { - depth: 0, - id: 'gCF0B2kBR346wHgnb7m0', - index: 'auditbeat-8.0.0-2019.02.19-000001', - type: 'event', + host: { + architecture: 'x86_64', + containerized: false, + hostname: 'suricata-zeek-sensor-toronto', + id: '8cc95778cce5407c809480e8e32ad76b', + name: 'suricata-zeek-sensor-toronto', + os: { + codename: 'bionic', + family: 'debian', + kernel: '4.15.0-45-generic', + name: 'Ubuntu', + platform: 'ubuntu', + version: '18.04.2 LTS (Bionic Beaver)', + }, }, - parents: [ - { + service: { + type: 'auditd', + }, + user: { + audit: { + id: 'unset', + }, + }, + signal: { + rule: fullSignal.signal.rule, + original_time: fullSignal.signal.original_time, + status: 'open', + depth: 1, + ancestors: [ + { + depth: 0, + id: '9xbRBmkBR346wHgngz2D', + index: 'auditbeat-8.0.0-2019.02.19-000001', + type: 'event', + }, + ], + original_event: { + action: 'changed-audit-configuration', + category: 'configuration', + module: 'auditd', + }, + parent: { depth: 0, - id: 'gCF0B2kBR346wHgnb7m0', + id: '9xbRBmkBR346wHgngz2D', index: 'auditbeat-8.0.0-2019.02.19-000001', type: 'event', }, - ], - _meta: { - version: SIGNALS_TEMPLATE_VERSION, + parents: [ + { + depth: 0, + id: '9xbRBmkBR346wHgngz2D', + index: 'auditbeat-8.0.0-2019.02.19-000001', + type: 'event', + }, + ], + _meta: { + version: SIGNALS_TEMPLATE_VERSION, + }, + }, + }); + }); + + it('generates up to max_signals for non-sequence EQL queries', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'any where true', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 100, [id]); + const signals = await getSignalsByIds(supertest, [id], 1000); + const filteredSignals = signals.hits.hits.filter( + (signal) => signal._source.signal.depth === 1 + ); + expect(filteredSignals.length).eql(100); + }); + + it('uses the provided event_category_override', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'config_change where agent.id=="a1d7b39c-f898-4dbe-a761-efb61939302d"', + event_category_override: 'auditd.message_type', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByRuleIds(supertest, ['eql-rule']); + expect(signals.hits.hits.length).eql(1); + const fullSignal = signals.hits.hits[0]._source; + + expect(fullSignal).eql({ + '@timestamp': fullSignal['@timestamp'], + agent: { + ephemeral_id: '0010d67a-14f7-41da-be30-489fea735967', + hostname: 'suricata-zeek-sensor-toronto', + id: 'a1d7b39c-f898-4dbe-a761-efb61939302d', + type: 'auditbeat', + version: '8.0.0', + }, + auditd: { + data: { + audit_enabled: '1', + old: '1', + }, + message_type: 'config_change', + result: 'success', + sequence: 1496, + session: 'unset', + summary: { + actor: { + primary: 'unset', + }, + object: { + primary: '1', + type: 'audit-config', + }, + }, + }, + cloud: { + instance: { + id: '133555295', + }, + provider: 'digitalocean', + region: 'tor1', + }, + ecs: { + version: '1.0.0-beta2', + }, + event: { + action: 'changed-audit-configuration', + category: 'configuration', + module: 'auditd', + kind: 'signal', + }, + host: { + architecture: 'x86_64', + containerized: false, + hostname: 'suricata-zeek-sensor-toronto', + id: '8cc95778cce5407c809480e8e32ad76b', + name: 'suricata-zeek-sensor-toronto', + os: { + codename: 'bionic', + family: 'debian', + kernel: '4.15.0-45-generic', + name: 'Ubuntu', + platform: 'ubuntu', + version: '18.04.2 LTS (Bionic Beaver)', + }, + }, + service: { + type: 'auditd', + }, + user: { + audit: { + id: 'unset', + }, + }, + signal: { + rule: fullSignal.signal.rule, + original_time: fullSignal.signal.original_time, + status: 'open', + depth: 1, + ancestors: [ + { + depth: 0, + id: '9xbRBmkBR346wHgngz2D', + index: 'auditbeat-8.0.0-2019.02.19-000001', + type: 'event', + }, + ], + original_event: { + action: 'changed-audit-configuration', + category: 'configuration', + module: 'auditd', + }, + parent: { + depth: 0, + id: '9xbRBmkBR346wHgngz2D', + index: 'auditbeat-8.0.0-2019.02.19-000001', + type: 'event', + }, + parents: [ + { + depth: 0, + id: '9xbRBmkBR346wHgngz2D', + index: 'auditbeat-8.0.0-2019.02.19-000001', + type: 'event', + }, + ], + _meta: { + version: SIGNALS_TEMPLATE_VERSION, + }, }, }); }); @@ -275,18 +484,76 @@ export default ({ getService }: FtrProviderContext) => { rule_id: 'eql-rule', type: 'eql', language: 'eql', - query: 'sequence by host.name [any where true] [any where true]', + query: 'sequence by host.name [anomoly where true] [any where true]', }; const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 10, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signals = await getSignalsByRuleIds(supertest, ['eql-rule']); + const buildingBlock = signals.hits.hits.find( + (signal) => + signal._source.signal.depth === 1 && + get(signal._source, 'signal.original_event.category') === 'anomoly' + ); + expect(buildingBlock).not.eql(undefined); + const signal = buildingBlock!._source.signal; + + expect(signal).eql({ + rule: signal.rule, + group: signal.group, + original_time: signal.original_time, + status: 'open', + depth: 1, + ancestors: [ + { + depth: 0, + id: 'VhXOBmkBR346wHgnLP8T', + index: 'auditbeat-8.0.0-2019.02.19-000001', + type: 'event', + }, + ], + original_event: { + action: 'changed-promiscuous-mode-on-device', + category: 'anomoly', + module: 'auditd', + }, + parent: { + depth: 0, + id: 'VhXOBmkBR346wHgnLP8T', + index: 'auditbeat-8.0.0-2019.02.19-000001', + type: 'event', + }, + parents: [ + { + depth: 0, + id: 'VhXOBmkBR346wHgnLP8T', + index: 'auditbeat-8.0.0-2019.02.19-000001', + type: 'event', + }, + ], + _meta: { + version: SIGNALS_TEMPLATE_VERSION, + }, + }); + }); + + it('generates shell signals from EQL sequences in the expected form', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'sequence by host.name [anomoly where true] [any where true]', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsByRuleIds(supertest, ['eql-rule']); const sequenceSignal = signalsOpen.hits.hits.find( (signal) => signal._source.signal.depth === 2 ); const signal = sequenceSignal!._source.signal; const eventIds = signal.parents.map((event) => event.id); - expect(signal).eql({ status: 'open', depth: 2, @@ -295,7 +562,7 @@ export default ({ getService }: FtrProviderContext) => { ancestors: [ { depth: 0, - id: 'gCF0B2kBR346wHgnb7m0', + id: 'VhXOBmkBR346wHgnLP8T', index: 'auditbeat-8.0.0-2019.02.19-000001', type: 'event', }, @@ -308,7 +575,7 @@ export default ({ getService }: FtrProviderContext) => { }, { depth: 0, - id: 'CCF0B2kBR346wHgngLtX', + id: '4hbXBmkBR346wHgn6fdp', index: 'auditbeat-8.0.0-2019.02.19-000001', type: 'event', }, @@ -341,6 +608,254 @@ export default ({ getService }: FtrProviderContext) => { }, }); }); + + it('generates up to max_signals with an EQL rule', async () => { + const rule: EqlCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: 'eql-rule', + type: 'eql', + language: 'eql', + query: 'sequence by host.name [any where true] [any where true]', + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + // For EQL rules, max_signals is the maximum number of detected sequences: each sequence has a building block + // alert for each event in the sequence, so max_signals=100 results in 200 building blocks in addition to + // 100 regular alerts + await waitForSignalsToBePresent(supertest, 300, [id]); + const signalsOpen = await getSignalsByIds(supertest, [id], 1000); + expect(signalsOpen.hits.hits.length).eql(300); + const shellSignals = signalsOpen.hits.hits.filter( + (signal) => signal._source.signal.depth === 2 + ); + const buildingBlocks = signalsOpen.hits.hits.filter( + (signal) => signal._source.signal.depth === 1 + ); + expect(shellSignals.length).eql(100); + expect(buildingBlocks.length).eql(200); + }); + }); + + describe('Threshold Rules', () => { + it('generates 1 signal from Threshold rules when threshold is met', async () => { + const ruleId = 'threshold-rule'; + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: ruleId, + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'host.id', + value: 700, + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByRuleIds(supertest, [ruleId]); + expect(signalsOpen.hits.hits.length).eql(1); + const signal = signalsOpen.hits.hits[0]; + expect(signal._source.signal.threshold_result).eql({ + terms: [ + { + field: 'host.id', + value: '8cc95778cce5407c809480e8e32ad76b', + }, + ], + count: 788, + from: '1900-01-01T00:00:00.000Z', + }); + }); + + it('generates 2 signals from Threshold rules when threshold is met', async () => { + const ruleId = 'threshold-rule'; + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: ruleId, + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'host.id', + value: 100, + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsByRuleIds(supertest, [ruleId]); + expect(signalsOpen.hits.hits.length).eql(2); + }); + + it('applies the provided query before bucketing ', async () => { + const ruleId = 'threshold-rule'; + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: ruleId, + type: 'threshold', + language: 'kuery', + query: 'host.id:"2ab45fc1c41e4c84bbd02202a7e5761f"', + threshold: { + field: 'process.name', + value: 21, + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsByRuleIds(supertest, [ruleId]); + expect(signalsOpen.hits.hits.length).eql(1); + }); + + it('generates no signals from Threshold rules when threshold is met and cardinality is not met', async () => { + const ruleId = 'threshold-rule'; + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: ruleId, + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'host.id', + value: 100, + cardinality: [ + { + field: 'destination.ip', + value: 100, + }, + ], + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + const signalsOpen = await getSignalsByRuleIds(supertest, [ruleId]); + expect(signalsOpen.hits.hits.length).eql(0); + }); + + it('generates no signals from Threshold rules when cardinality is met and threshold is not met', async () => { + const ruleId = 'threshold-rule'; + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: ruleId, + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'host.id', + value: 1000, + cardinality: [ + { + field: 'destination.ip', + value: 5, + }, + ], + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + const signalsOpen = await getSignalsByRuleIds(supertest, [ruleId]); + expect(signalsOpen.hits.hits.length).eql(0); + }); + + it('generates signals from Threshold rules when threshold and cardinality are both met', async () => { + const ruleId = 'threshold-rule'; + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: ruleId, + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: 'host.id', + value: 100, + cardinality: [ + { + field: 'destination.ip', + value: 5, + }, + ], + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + const signalsOpen = await getSignalsByRuleIds(supertest, [ruleId]); + expect(signalsOpen.hits.hits.length).eql(1); + const signal = signalsOpen.hits.hits[0]; + expect(signal._source.signal.threshold_result).eql({ + terms: [ + { + field: 'host.id', + value: '8cc95778cce5407c809480e8e32ad76b', + }, + ], + cardinality: [ + { + field: 'destination.ip', + value: 7, + }, + ], + count: 788, + from: '1900-01-01T00:00:00.000Z', + }); + }); + + it('should not generate signals if only one field meets the threshold requirement', async () => { + const ruleId = 'threshold-rule'; + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: ruleId, + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: ['host.id', 'process.name'], + value: 22, + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + const signalsOpen = await getSignalsByRuleIds(supertest, [ruleId]); + expect(signalsOpen.hits.hits.length).eql(0); + }); + + it('generates signals from Threshold rules when bucketing by multiple fields', async () => { + const ruleId = 'threshold-rule'; + const rule: ThresholdCreateSchema = { + ...getRuleForSignalTesting(['auditbeat-*']), + rule_id: ruleId, + type: 'threshold', + language: 'kuery', + query: '*:*', + threshold: { + field: ['host.id', 'process.name', 'event.module'], + value: 21, + }, + }; + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + const signalsOpen = await getSignalsByRuleIds(supertest, [ruleId]); + expect(signalsOpen.hits.hits.length).eql(1); + const signal = signalsOpen.hits.hits[0]; + expect(signal._source.signal.threshold_result).eql({ + terms: [ + { + field: 'event.module', + value: 'system', + }, + { + field: 'host.id', + value: '2ab45fc1c41e4c84bbd02202a7e5761f', + }, + { + field: 'process.name', + value: 'sshd', + }, + ], + count: 21, + from: '1900-01-01T00:00:00.000Z', + }); + }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index d821b57faf22..55011ec05519 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -778,6 +778,17 @@ export const countDownES = async ( ); }; +/** + * Refresh an index, making changes available to search. + * Useful for tests where we want to ensure that a rule does NOT create alerts, e.g. testing exceptions. + * @param es The ElasticSearch handle + */ +export const refreshIndex = async (es: KibanaClient, index?: string) => { + await es.indices.refresh({ + index, + }); +}; + /** * Does a plain countdown and checks against a boolean to determine if to wait and try again. * This is useful for over the wire things that can cause issues such as conflict or timeouts @@ -1107,7 +1118,7 @@ export const installPrePackagedRules = async ( */ export const createRuleWithExceptionEntries = async ( supertest: SuperTest, - rule: QueryCreateSchema, + rule: CreateRulesSchema, entries: NonEmptyEntriesArray[] ): Promise => { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -1141,7 +1152,7 @@ export const createRuleWithExceptionEntries = async ( // the rule to sometimes not filter correctly the first time with an exception list // or other timing issues. Then afterwards wait for the rule to have succeeded before // returning. - const ruleWithException: QueryCreateSchema = { + const ruleWithException: CreateRulesSchema = { ...rule, enabled: false, exceptions_list: [ @@ -1202,3 +1213,16 @@ export const deleteMigrations = async ({ ) ); }; + +export const getOpenSignals = async ( + supertest: SuperTest, + es: KibanaClient, + rule: FullResponseSchema +) => { + await waitForRuleSuccessOrStatus(supertest, rule.id); + // Critically important that we wait for rule success AND refresh the write index in that order before we + // assert that no signals were created. Otherwise, signals could be written but not available to query yet + // when we search, causing tests that check that signals are NOT created to pass when they should fail. + await refreshIndex(es, rule.output_index); + return getSignalsByIds(supertest, [rule.id]); +};