Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Alerts] Alert suppression time window #148868

Merged
merged 31 commits into from
Jan 30, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d48454d
WIP
marshallmain Dec 15, 2022
51776bc
Merge branch 'main' of github.com:elastic/kibana into alert-suppressi…
marshallmain Jan 5, 2023
382f0c3
Add radio button to rule create, implement alert update logic
marshallmain Jan 11, 2023
bf6ec62
Add explicit index to bulk updates
marshallmain Jan 12, 2023
b210b3b
Fix deduplication bug with null values, add tests
marshallmain Jan 13, 2023
5d52a88
Cleanup
marshallmain Jan 13, 2023
6743a5d
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Jan 13, 2023
899d160
Fix types
marshallmain Jan 13, 2023
3a6d854
Merge branch 'main' into alert-suppression-time-window
marshallmain Jan 13, 2023
f39021f
Fix more types
marshallmain Jan 13, 2023
7d7b504
Fix unit tests
marshallmain Jan 13, 2023
60b8490
Fix test data
marshallmain Jan 13, 2023
bea5a14
Merge branch 'main' into alert-suppression-time-window
marshallmain Jan 13, 2023
14037fd
Add real rule execution test for suppression
marshallmain Jan 13, 2023
57cb8cf
Merge branch 'main' into alert-suppression-time-window
marshallmain Jan 13, 2023
b2d2df2
Merge branch 'main' into alert-suppression-time-window
marshallmain Jan 17, 2023
feb371f
Move suppression types to rule registry
marshallmain Jan 18, 2023
79e81a9
Apply Garrett's fix
marshallmain Jan 24, 2023
d2c586b
Add timestamp override and max_signals tests, move schema
marshallmain Jan 24, 2023
d654699
Merge branch 'main' of github.com:elastic/kibana into alert-suppressi…
marshallmain Jan 25, 2023
0c0b157
Fix import
marshallmain Jan 25, 2023
ce3a624
Fix technical field names post merge
marshallmain Jan 25, 2023
424d74b
Merge branch 'main' into alert-suppression-time-window
marshallmain Jan 25, 2023
6e6c572
Helper funcs in persistence wrapper, fix license check in UI
marshallmain Jan 25, 2023
969253f
Exclude closed alerts from suppression update logic
marshallmain Jan 26, 2023
9b98237
PR comments
marshallmain Jan 27, 2023
6052cc9
Merge branch 'main' of github.com:elastic/kibana into alert-suppressi…
marshallmain Jan 27, 2023
3f1ecb2
Update x-pack/plugins/rule_registry/server/utils/create_persistence_r…
marshallmain Jan 27, 2023
487f56b
Modify tests to account for alert start and enable rule timestamp bug
marshallmain Jan 27, 2023
5650d6f
Fix tests
marshallmain Jan 30, 2023
1ca4f87
Merge branch 'main' into alert-suppression-time-window
marshallmain Jan 30, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/kbn-rule-data-utils/src/technical_field_names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const ALERT_EVALUATION_THRESHOLD = `${ALERT_NAMESPACE}.evaluation.threshold` as
const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const;
const ALERT_FLAPPING = `${ALERT_NAMESPACE}.flapping` as const;
const ALERT_INSTANCE_ID = `${ALERT_NAMESPACE}.instance.id` as const;
const ALERT_LAST_DETECTED = `${ALERT_NAMESPACE}.last_detected_at` as const;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this field to the default_alerts_as_data.ts file to make it available for all alerts and have it renamed to last_detected for consistency with start and end?

const ALERT_REASON = `${ALERT_NAMESPACE}.reason` as const;
const ALERT_RISK_SCORE = `${ALERT_NAMESPACE}.risk_score` as const;
const ALERT_SEVERITY = `${ALERT_NAMESPACE}.severity` as const;
Expand All @@ -50,6 +51,7 @@ const ALERT_SUPPRESSION_VALUE = `${ALERT_SUPPRESSION_TERMS}.value` as const;
const ALERT_SUPPRESSION_START = `${ALERT_SUPPRESSION_META}.start` as const;
const ALERT_SUPPRESSION_END = `${ALERT_SUPPRESSION_META}.end` as const;
const ALERT_SUPPRESSION_DOCS_COUNT = `${ALERT_SUPPRESSION_META}.docs_count` as const;
const ALERT_SUPPRESSION_GROUP_ID = `${ALERT_SUPPRESSION_META}.group_id` as const;

// Fields pertaining to the cases associated with the alert
const ALERT_CASE_IDS = `${ALERT_NAMESPACE}.case_ids` as const;
Expand Down Expand Up @@ -128,6 +130,7 @@ const fields = {
ALERT_EVALUATION_VALUE,
ALERT_FLAPPING,
ALERT_INSTANCE_ID,
ALERT_LAST_DETECTED,
ALERT_RULE_CONSUMER,
ALERT_RULE_PRODUCER,
ALERT_REASON,
Expand Down Expand Up @@ -184,6 +187,7 @@ const fields = {
ALERT_SUPPRESSION_START,
ALERT_SUPPRESSION_END,
ALERT_SUPPRESSION_DOCS_COUNT,
ALERT_SUPPRESSION_GROUP_ID,
SPACE_IDS,
VERSION,
};
Expand All @@ -197,6 +201,7 @@ export {
ALERT_EVALUATION_VALUE,
ALERT_FLAPPING,
ALERT_INSTANCE_ID,
ALERT_LAST_DETECTED,
ALERT_NAMESPACE,
ALERT_RULE_NAMESPACE,
ALERT_RULE_CONSUMER,
Expand Down Expand Up @@ -260,6 +265,7 @@ export {
ALERT_SUPPRESSION_START,
ALERT_SUPPRESSION_END,
ALERT_SUPPRESSION_DOCS_COUNT,
ALERT_SUPPRESSION_GROUP_ID,
TAGS,
TIMESTAMP,
SPACE_IDS,
Expand Down
32 changes: 32 additions & 0 deletions x-pack/plugins/rule_registry/common/schemas/8.7.0/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import {
ALERT_SUPPRESSION_TERMS,
ALERT_SUPPRESSION_START,
ALERT_SUPPRESSION_END,
ALERT_SUPPRESSION_DOCS_COUNT,
ALERT_INSTANCE_ID,
} from '@kbn/rule-data-utils';

/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.0.0.
Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.0.0.

If you are adding new fields for a new release of Kibana, create a new sibling folder to this one
for the version to be released and add the field(s) to the schema in that folder.

Then, update `../index.ts` to import from the new folder that has the latest schemas, add the
new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas.
*/

export interface SuppressionFields {
[ALERT_SUPPRESSION_TERMS]: Array<{ field: string; value: string | number | null }>;
[ALERT_SUPPRESSION_START]: Date;
[ALERT_SUPPRESSION_END]: Date;
[ALERT_SUPPRESSION_DOCS_COUNT]: number;
[ALERT_INSTANCE_ID]: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,18 @@ export class RuleDataClient implements IRuleDataClient {
};

return {
search: async <TSearchRequest extends ESSearchRequest>(
search: async <
TSearchRequest extends ESSearchRequest,
TAlertDoc = Partial<ParsedTechnicalFields & ParsedExperimentalFields>
>(
request: TSearchRequest
): Promise<
ESSearchResponse<Partial<ParsedTechnicalFields & ParsedExperimentalFields>, TSearchRequest>
> => {
): Promise<ESSearchResponse<TAlertDoc, TSearchRequest>> => {
try {
const clusterClient = await waitUntilReady();
return (await clusterClient.search({
...request,
index: indexPattern,
})) as unknown as ESSearchResponse<
Partial<ParsedTechnicalFields & ParsedExperimentalFields>,
TSearchRequest
>;
})) as unknown as ESSearchResponse<TAlertDoc, TSearchRequest>;
} catch (err) {
this.options.logger.error(`Error performing search in RuleDataClient - ${err.message}`);
throw err;
Expand Down
9 changes: 5 additions & 4 deletions x-pack/plugins/rule_registry/server/rule_data_client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ export interface IRuleDataClient {
}

export interface IRuleDataReader {
search<TSearchRequest extends ESSearchRequest>(
search<
TSearchRequest extends ESSearchRequest,
TAlertDoc = Partial<ParsedTechnicalFields & ParsedExperimentalFields>
>(
request: TSearchRequest
): Promise<
ESSearchResponse<Partial<ParsedTechnicalFields & ParsedExperimentalFields>, TSearchRequest>
>;
): Promise<ESSearchResponse<TAlertDoc, TSearchRequest>>;

getDynamicIndexPattern(target?: string): Promise<{
title: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PersistenceServices } from './persistence_types';
export const createPersistenceServicesMock = (): jest.Mocked<PersistenceServices> => {
return {
alertWithPersistence: jest.fn(),
alertWithSuppression: jest.fn(),
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@
* 2.0.
*/

import dateMath from '@elastic/datemath';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { chunk } from 'lodash';
import { ALERT_UUID, VERSION } from '@kbn/rule-data-utils';
import { chunk, partition } from 'lodash';
import {
ALERT_INSTANCE_ID,
ALERT_LAST_DETECTED,
ALERT_SUPPRESSION_DOCS_COUNT,
ALERT_SUPPRESSION_END,
ALERT_UUID,
TIMESTAMP,
VERSION,
} from '@kbn/rule-data-utils';
import { getCommonAlertFields } from './get_common_alert_fields';
import { CreatePersistenceRuleTypeWrapper } from './persistence_types';
import { errorAggregator } from './utils';
import { createGetSummarizedAlertsFn } from './create_get_summarized_alerts_fn';
import { SuppressionFields } from '../../common/schemas/8.7.0';

export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper =
({ logger, ruleDataClient }) =>
Expand Down Expand Up @@ -94,7 +104,7 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
spaceId: options.spaceId,
});
} catch (e) {
logger.debug('Enrichemnts failed');
logger.debug('Enrichments failed');
}
}

Expand Down Expand Up @@ -146,6 +156,171 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
return { createdAlerts: [], errors: {}, alertsWereTruncated: false };
}
},
alertWithSuppression: async (
mikecote marked this conversation as resolved.
Show resolved Hide resolved
mikecote marked this conversation as resolved.
Show resolved Hide resolved
alerts,
suppressionWindow,
enrichAlerts,
currentTimeOverride
) => {
const ruleDataClientWriter = await ruleDataClient.getWriter({
namespace: options.spaceId,
});

// Only write alerts if:
// - writing is enabled
// AND
// - rule execution has not been cancelled due to timeout
// OR
// - if execution has been cancelled due to timeout, if feature flags are configured to write alerts anyway
const writeAlerts =
ruleDataClient.isWriteEnabled() && options.services.shouldWriteAlerts();

if (writeAlerts && alerts.length > 0) {
const commonRuleFields = getCommonAlertFields(options);

const suppressionWindowStart = dateMath.parse(suppressionWindow, {
forceNow: currentTimeOverride,
});
if (!suppressionWindowStart) {
throw new Error('Failed to parse suppression window');
}

const suppressionAlertSearchRequest = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this query work with ALERT_START instead of TIMESTAMP? It will make it easier for the framework to enable suppression for O11y and stack alerts too (given timestamp behaves differently in O11y / stack vs security solution) we need to look at the first detected time.

We will need to set ALERT_START for newly detected alerts within augmentAlerts to make this work.

body: {
size: alerts.length,
query: {
bool: {
filter: [
{
range: {
[TIMESTAMP]: {
gte: suppressionWindowStart.toISOString(),
},
},
},
{
terms: {
[ALERT_INSTANCE_ID]: alerts.map(
(alert) => alert._source['kibana.alert.instance.id']
),
},
},
],
},
},
collapse: {
field: ALERT_INSTANCE_ID,
},
sort: [
{
'@timestamp': {
marshallmain marked this conversation as resolved.
Show resolved Hide resolved
order: 'desc' as const,
},
},
],
},
};

const response = await ruleDataClient
.getReader({ namespace: options.spaceId })
.search<typeof suppressionAlertSearchRequest, SuppressionFields>(
suppressionAlertSearchRequest
);

const existingAlertsByInstanceId = response.hits.hits.reduce<
Record<string, estypes.SearchHit<SuppressionFields>>
>((acc, hit) => {
acc[hit._source['kibana.alert.instance.id']] = hit;
return acc;
}, {});

const [duplicateAlerts, newAlerts] = partition(
alerts,
(alert) =>
existingAlertsByInstanceId[alert._source['kibana.alert.instance.id']] != null
);

const duplicateAlertUpdates = duplicateAlerts.flatMap((alert) => {
const existingAlert =
existingAlertsByInstanceId[alert._source['kibana.alert.instance.id']];
const existingDocsCount =
existingAlert._source?.[ALERT_SUPPRESSION_DOCS_COUNT] ?? 0;
return [
{
update: {
_id: existingAlert._id,
_index: existingAlert._index,
require_alias: false,
},
},
{
doc: {
[ALERT_LAST_DETECTED]: currentTimeOverride ?? new Date(),
[ALERT_SUPPRESSION_END]: alert._source[ALERT_SUPPRESSION_END],
[ALERT_SUPPRESSION_DOCS_COUNT]:
existingDocsCount + alert._source[ALERT_SUPPRESSION_DOCS_COUNT] + 1,
},
spong marked this conversation as resolved.
Show resolved Hide resolved
},
];
});

let enrichedAlerts = newAlerts;

if (enrichAlerts) {
try {
enrichedAlerts = await enrichAlerts(enrichedAlerts, {
spaceId: options.spaceId,
});
} catch (e) {
logger.debug('Enrichments failed');
}
}

const augmentedAlerts = enrichedAlerts.map((alert) => {
return {
...alert,
_source: {
[VERSION]: ruleDataClient.kibanaVersion,
[ALERT_LAST_DETECTED]: currentTimeOverride ?? new Date(),
...commonRuleFields,
...alert._source,
},
};
});

const newAlertCreates = augmentedAlerts.flatMap((alert) => [
{ create: { _id: alert._id } },
alert._source,
]);

const bulkResponse = await ruleDataClientWriter.bulk({
body: [...duplicateAlertUpdates, ...newAlertCreates],
refresh: true,
mikecote marked this conversation as resolved.
Show resolved Hide resolved
});

if (bulkResponse == null) {
return { createdAlerts: [], errors: {} };
}

return {
createdAlerts: augmentedAlerts
.map((alert, idx) => {
const responseItem =
bulkResponse.body.items[idx + duplicateAlertUpdates.length].create;
return {
_id: responseItem?._id ?? '',
_index: responseItem?._index ?? '',
...alert._source,
};
})
.filter((_, idx) => bulkResponse.body.items[idx].create?.status === 201),
errors: errorAggregator(bulkResponse.body, [409]),
};
} else {
logger.debug('Writing is disabled.');
return { createdAlerts: [], errors: {} };
}
},
},
});

Expand Down
15 changes: 15 additions & 0 deletions x-pack/plugins/rule_registry/server/utils/persistence_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { WithoutReservedActionGroups } from '@kbn/alerting-plugin/common';
import { IRuleDataClient } from '../rule_data_client';
import { BulkResponseErrorAggregation } from './utils';
import { AlertWithCommonFieldsLatest } from '../../common/schemas';
import { SuppressionFields } from '../../common/schemas/8.7.0';

export type PersistenceAlertService = <T>(
alerts: Array<{
Expand All @@ -40,6 +41,19 @@ export type PersistenceAlertService = <T>(
>
) => Promise<PersistenceAlertServiceResult<T>>;

export type SuppressedAlertService = <T extends SuppressionFields>(
alerts: Array<{
_id: string;
_source: T;
}>,
suppressionWindow: string,
enrichAlerts?: (
alerts: Array<{ _id: string; _source: T }>,
params: { spaceId: string }
) => Promise<Array<{ _id: string; _source: T }>>,
currentTimeOverride?: Date
) => Promise<Omit<PersistenceAlertServiceResult<T>, 'alertsWereTruncated'>>;

export interface PersistenceAlertServiceResult<T> {
createdAlerts: Array<AlertWithCommonFieldsLatest<T> & { _id: string; _index: string }>;
errors: BulkResponseErrorAggregation;
Expand All @@ -48,6 +62,7 @@ export interface PersistenceAlertServiceResult<T> {

export interface PersistenceServices {
alertWithPersistence: PersistenceAlertService;
alertWithSuppression: SuppressedAlertService;
}

export type PersistenceAlertType<
Expand Down
Loading