Skip to content

Commit

Permalink
[Security Solution][Detections] Handle dupes when processing threshol…
Browse files Browse the repository at this point in the history
…d rules (#83062)

* Fix threshold rule synthetic signal generation

* Use top_hits aggregation

* Find signals and aggregate over search terms

* Exclude dupes

* Fixes to algorithm

* Sync timestamps with events/signals

* Add timestampOverride

* Revert changes in signal creation

* Simplify query, return 10k buckets

* Account for when threshold.field is not supplied

* Ensure we're getting the last event when threshold.field is not provided

* Add missing import

* Handle case where threshold field not supplied

* Fix type errors

* Handle non-ECS fields

* Regorganize

* Address comments

* Fix type error

* Add unit test for buildBulkBody on threshold results

* Add threshold_count back to mapping (and deprecate)

* Timestamp fixes

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
madirey and kibanamachine committed Nov 30, 2020
1 parent d21e4f5 commit bdf7b88
Show file tree
Hide file tree
Showing 12 changed files with 296 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import signalsMapping from './signals_mapping.json';
import ecsMapping from './ecs_mapping.json';

export const SIGNALS_TEMPLATE_VERSION = 2;
export const SIGNALS_TEMPLATE_VERSION = 3;
export const MIN_EQL_RULE_INDEX_VERSION = 2;

export const getSignalsTemplate = (index: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,16 @@
"threshold_count": {
"type": "float"
},
"threshold_result": {
"properties": {
"count": {
"type": "long"
},
"value": {
"type": "keyword"
}
}
},
"depth": {
"type": "integer"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,115 @@ describe('buildBulkBody', () => {
expect(fakeSignalSourceHit).toEqual(expected);
});

test('bulk body builds well-defined body with threshold results', () => {
const sampleParams = sampleRuleAlertParams();
const baseDoc = sampleDocNoSortId();
const doc: SignalSourceHit = {
...baseDoc,
_source: {
...baseDoc._source,
threshold_result: {
count: 5,
value: 'abcd',
},
},
};
delete doc._source.source;
const fakeSignalSourceHit = buildBulkBody({
doc,
ruleParams: sampleParams,
id: sampleRuleGuid,
name: 'rule-name',
actions: [],
createdAt: '2020-01-28T15:58:34.810Z',
updatedAt: '2020-01-28T15:59:14.004Z',
createdBy: 'elastic',
updatedBy: 'elastic',
interval: '5m',
enabled: true,
tags: ['some fake tag 1', 'some fake tag 2'],
throttle: 'no_actions',
});
// Timestamp will potentially always be different so remove it for the test
// @ts-expect-error
delete fakeSignalSourceHit['@timestamp'];
const expected: Omit<SignalHit, '@timestamp'> & { someKey: 'someValue' } = {
someKey: 'someValue',
event: {
kind: 'signal',
},
signal: {
parent: {
id: sampleIdGuid,
type: 'event',
index: 'myFakeSignalIndex',
depth: 0,
},
parents: [
{
id: sampleIdGuid,
type: 'event',
index: 'myFakeSignalIndex',
depth: 0,
},
],
ancestors: [
{
id: sampleIdGuid,
type: 'event',
index: 'myFakeSignalIndex',
depth: 0,
},
],
original_time: '2020-04-20T21:27:45+0000',
status: 'open',
rule: {
actions: [],
author: ['Elastic'],
building_block_type: 'default',
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
rule_id: 'rule-1',
false_positives: [],
max_signals: 10000,
risk_score: 50,
risk_score_mapping: [],
output_index: '.siem-signals',
description: 'Detecting root and admin users',
from: 'now-6m',
immutable: false,
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
interval: '5m',
language: 'kuery',
license: 'Elastic License',
name: 'rule-name',
query: 'user.name: root or user.name: admin',
references: ['http://google.com'],
severity: 'high',
severity_mapping: [],
tags: ['some fake tag 1', 'some fake tag 2'],
threat: [],
throttle: 'no_actions',
type: 'query',
to: 'now',
note: '',
enabled: true,
created_by: 'elastic',
updated_by: 'elastic',
version: 1,
created_at: fakeSignalSourceHit.signal.rule?.created_at,
updated_at: fakeSignalSourceHit.signal.rule?.updated_at,
exceptions_list: getListArrayMock(),
},
threshold_result: {
count: 5,
value: 'abcd',
},
depth: 1,
},
};
expect(fakeSignalSourceHit).toEqual(expected);
});

test('bulk body builds original_event if it exists on the event to begin with', () => {
const sampleParams = sampleRuleAlertParams();
const doc = sampleDocNoSortId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const buildBulkBody = ({
...buildSignal([doc], rule),
...additionalSignalFields(doc),
};
delete doc._source.threshold_result;
const event = buildEventTypeSignal(doc);
const signalHit: SignalHit = {
...doc._source,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema): Signal =>
export const additionalSignalFields = (doc: BaseSignalHit) => {
return {
parent: buildParent(removeClashes(doc)),
original_time: doc._source['@timestamp'],
original_time: doc._source['@timestamp'], // This field has already been replaced with timestampOverride, if provided.
original_event: doc._source.event ?? undefined,
threshold_count: doc._source.threshold_count ?? undefined,
threshold_result: doc._source.threshold_result,
original_signal:
doc._source.signal != null && !isEventTypeSignal(doc) ? doc._source.signal : undefined,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ const getTransformedHits = (

const source = {
'@timestamp': get(timestampOverride ?? '@timestamp', hit._source),
threshold_count: totalResults,
threshold_result: {
count: totalResults,
value: ruleId,
},
...getThresholdSignalQueryFields(hit, filter),
};

Expand All @@ -176,7 +179,10 @@ const getTransformedHits = (

const source = {
'@timestamp': get(timestampOverride ?? '@timestamp', hit._source),
threshold_count: docCount,
threshold_result: {
count: docCount,
value: get(threshold.field, hit._source),
},
...getThresholdSignalQueryFields(hit, filter),
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* 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 { TimestampOverrideOrUndefined } from '../../../../common/detection_engine/schemas/common/schemas';
import { singleSearchAfter } from './single_search_after';

import { AlertServices } from '../../../../../alerts/server';
import { Logger } from '../../../../../../../src/core/server';
import { SignalSearchResponse } from './types';
import { BuildRuleMessage } from './rule_messages';

interface FindPreviousThresholdSignalsParams {
from: string;
to: string;
indexPattern: string[];
services: AlertServices;
logger: Logger;
ruleId: string;
bucketByField: string;
timestampOverride: TimestampOverrideOrUndefined;
buildRuleMessage: BuildRuleMessage;
}

export const findPreviousThresholdSignals = async ({
from,
to,
indexPattern,
services,
logger,
ruleId,
bucketByField,
timestampOverride,
buildRuleMessage,
}: FindPreviousThresholdSignalsParams): Promise<{
searchResult: SignalSearchResponse;
searchDuration: string;
searchErrors: string[];
}> => {
const aggregations = {
threshold: {
terms: {
field: 'signal.threshold_result.value',
},
aggs: {
lastSignalTimestamp: {
max: {
field: 'signal.original_time', // timestamp of last event captured by bucket
},
},
},
},
};

const filter = {
bool: {
must: [
{
term: {
'signal.rule.rule_id': ruleId,
},
},
{
term: {
'signal.rule.threshold.field': bucketByField,
},
},
],
},
};

return singleSearchAfter({
aggregations,
searchAfterSortId: undefined,
timestampOverride,
index: indexPattern,
from,
to,
services,
logger,
filter,
pageSize: 0,
buildRuleMessage,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const findThresholdSignals = async ({
terms: {
field: threshold.field,
min_doc_count: threshold.value,
size: 10000, // max 10k buckets
},
aggs: {
// Get the most recent hit per bucket
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { Logger, KibanaRequest } from 'src/core/server';

import { Filter } from 'src/plugins/data/common';
import {
SIGNALS_ID,
DEFAULT_SEARCH_AFTER_PAGE_SIZE,
Expand All @@ -29,6 +30,7 @@ import {
RuleAlertAttributes,
EqlSignalSearchResponse,
BaseSignalHit,
ThresholdQueryBucket,
} from './types';
import {
getGapBetweenRuns,
Expand All @@ -46,6 +48,7 @@ import { signalParamsSchema } from './signal_params_schema';
import { siemRuleActionGroups } from './siem_rule_action_groups';
import { findMlSignals } from './find_ml_signals';
import { findThresholdSignals } from './find_threshold_signals';
import { findPreviousThresholdSignals } from './find_previous_threshold_signals';
import { bulkCreateMlSignals } from './bulk_create_ml_signals';
import { bulkCreateThresholdSignals } from './bulk_create_threshold_signals';
import {
Expand Down Expand Up @@ -300,6 +303,46 @@ export const signalRulesAlertType = ({
lists: exceptionItems ?? [],
});

const {
searchResult: previousSignals,
searchErrors: previousSearchErrors,
} = await findPreviousThresholdSignals({
indexPattern: [outputIndex],
from,
to,
services,
logger,
ruleId,
bucketByField: threshold.field,
timestampOverride,
buildRuleMessage,
});

previousSignals.aggregations.threshold.buckets.forEach((bucket: ThresholdQueryBucket) => {
esFilter.bool.filter.push(({
bool: {
must_not: {
bool: {
must: [
{
term: {
[threshold.field ?? 'signal.rule.rule_id']: bucket.key,
},
},
{
range: {
[timestampOverride ?? '@timestamp']: {
lte: bucket.lastSignalTimestamp.value_as_string,
},
},
},
],
},
},
},
} as unknown) as Filter);
});

const { searchResult: thresholdResults, searchErrors } = await findThresholdSignals({
inputIndexPattern: inputIndex,
from,
Expand Down Expand Up @@ -349,7 +392,7 @@ export const signalRulesAlertType = ({
}),
createSearchAfterReturnType({
success,
errors: [...errors, ...searchErrors],
errors: [...errors, ...previousSearchErrors, ...searchErrors],
createdSignalsCount: createdItemsCount,
bulkCreateTimes: bulkCreateDuration ? [bulkCreateDuration] : [],
}),
Expand Down
Loading

0 comments on commit bdf7b88

Please sign in to comment.