[Security Solution][Exceptions] Exception modal bulk close alerts that match exception attributes (#71321)

* progress on bulk close

* works but could be slow

* clean up, add tests

* fix reduce types

* address 'event.' fields

* remove duplicate import

* don't replace nested fields

* my best friend typescript
This commit is contained in:
Pedro Jaramillo 2020-07-14 05:39:58 +02:00 committed by GitHub
parent c86ad7bbec
commit f4091df289
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1145 additions and 582 deletions

View file

@ -17,6 +17,7 @@ import {
entriesMatch,
entriesNested,
ExceptionListItemSchema,
CreateExceptionListItemSchema,
} from '../shared_imports';
import { Language, Query } from './schemas/common/schemas';
@ -45,32 +46,35 @@ export const getLanguageBooleanOperator = ({
export const operatorBuilder = ({
operator,
language,
exclude,
}: {
operator: Operator;
language: Language;
exclude: boolean;
}): string => {
const not = getLanguageBooleanOperator({
language,
value: 'not',
});
switch (operator) {
case 'included':
return `${not} `;
default:
return '';
if ((exclude && operator === 'included') || (!exclude && operator === 'excluded')) {
return `${not} `;
} else {
return '';
}
};
export const buildExists = ({
item,
language,
exclude,
}: {
item: EntryExists;
language: Language;
exclude: boolean;
}): string => {
const { operator, field } = item;
const exceptionOperator = operatorBuilder({ operator, language });
const exceptionOperator = operatorBuilder({ operator, language, exclude });
switch (language) {
case 'kuery':
@ -85,12 +89,14 @@ export const buildExists = ({
export const buildMatch = ({
item,
language,
exclude,
}: {
item: EntryMatch;
language: Language;
exclude: boolean;
}): string => {
const { value, operator, field } = item;
const exceptionOperator = operatorBuilder({ operator, language });
const exceptionOperator = operatorBuilder({ operator, language, exclude });
return `${exceptionOperator}${field}:${value}`;
};
@ -98,9 +104,11 @@ export const buildMatch = ({
export const buildMatchAny = ({
item,
language,
exclude,
}: {
item: EntryMatchAny;
language: Language;
exclude: boolean;
}): string => {
const { value, operator, field } = item;
@ -109,7 +117,7 @@ export const buildMatchAny = ({
return '';
default:
const or = getLanguageBooleanOperator({ language, value: 'or' });
const exceptionOperator = operatorBuilder({ operator, language });
const exceptionOperator = operatorBuilder({ operator, language, exclude });
const matchAnyValues = value.map((v) => v);
return `${exceptionOperator}${field}:(${matchAnyValues.join(` ${or} `)})`;
@ -133,16 +141,18 @@ export const buildNested = ({
export const evaluateValues = ({
item,
language,
exclude,
}: {
item: Entry | EntryNested;
language: Language;
exclude: boolean;
}): string => {
if (entriesExists.is(item)) {
return buildExists({ item, language });
return buildExists({ item, language, exclude });
} else if (entriesMatch.is(item)) {
return buildMatch({ item, language });
return buildMatch({ item, language, exclude });
} else if (entriesMatchAny.is(item)) {
return buildMatchAny({ item, language });
return buildMatchAny({ item, language, exclude });
} else if (entriesNested.is(item)) {
return buildNested({ item, language });
} else {
@ -163,7 +173,11 @@ export const formatQuery = ({
const or = getLanguageBooleanOperator({ language, value: 'or' });
const and = getLanguageBooleanOperator({ language, value: 'and' });
const formattedExceptions = exceptions.map((exception) => {
return `(${query} ${and} ${exception})`;
if (query === '') {
return `(${exception})`;
} else {
return `(${query} ${and} ${exception})`;
}
});
return formattedExceptions.join(` ${or} `);
@ -175,15 +189,17 @@ export const formatQuery = ({
export const buildExceptionItemEntries = ({
lists,
language,
exclude,
}: {
lists: EntriesArray;
language: Language;
exclude: boolean;
}): string => {
const and = getLanguageBooleanOperator({ language, value: 'and' });
const exceptionItem = lists
.filter(({ type }) => type !== 'list')
.reduce<string[]>((accum, listItem) => {
const exceptionSegment = evaluateValues({ item: listItem, language });
const exceptionSegment = evaluateValues({ item: listItem, language, exclude });
return [...accum, exceptionSegment];
}, []);
@ -194,15 +210,22 @@ export const buildQueryExceptions = ({
query,
language,
lists,
exclude = true,
}: {
query: Query;
language: Language;
lists: ExceptionListItemSchema[] | undefined;
lists: Array<ExceptionListItemSchema | CreateExceptionListItemSchema> | undefined;
exclude?: boolean;
}): DataQuery[] => {
if (lists != null) {
const exceptions = lists.map((exceptionItem) =>
buildExceptionItemEntries({ lists: exceptionItem.entries, language })
);
const exceptions = lists.reduce<string[]>((acc, exceptionItem) => {
return [
...acc,
...(exceptionItem.entries !== undefined
? [buildExceptionItemEntries({ lists: exceptionItem.entries, language, exclude })]
: []),
];
}, []);
const formattedQuery = formatQuery({ exceptions, language, query });
return [
{

View file

@ -456,6 +456,96 @@ describe('get_filter', () => {
});
});
describe('when "excludeExceptions" is false', () => {
test('it should work with a list', () => {
const esQuery = getQueryFilter(
'host.name: linux',
'kuery',
[],
['auditbeat-*'],
[getExceptionListItemSchemaMock()],
false
);
expect(esQuery).toEqual({
bool: {
filter: [
{
bool: {
filter: [
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'host.name': 'linux',
},
},
],
},
},
{
bool: {
filter: [
{
nested: {
path: 'some.parentField',
query: {
bool: {
minimum_should_match: 1,
should: [
{
match: {
'some.parentField.nested.field': 'some value',
},
},
],
},
},
score_mode: 'none',
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'some.not.nested.field': 'some value',
},
},
],
},
},
],
},
},
],
},
},
],
must: [],
must_not: [],
should: [],
},
});
});
test('it should work with an empty list', () => {
const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], [], false);
expect(esQuery).toEqual({
bool: {
filter: [
{ bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } },
],
must: [],
must_not: [],
should: [],
},
});
});
});
test('it should work with a nested object queries', () => {
const esQuery = getQueryFilter(
'category:{ name:Frank and trusted:true }',

View file

@ -11,7 +11,10 @@ import {
buildEsQuery,
Query as DataQuery,
} from '../../../../../src/plugins/data/common';
import { ExceptionListItemSchema } from '../../../lists/common/schemas';
import {
ExceptionListItemSchema,
CreateExceptionListItemSchema,
} from '../../../lists/common/schemas';
import { buildQueryExceptions } from './build_exceptions_query';
import { Query, Language, Index } from './schemas/common/schemas';
@ -20,14 +23,20 @@ export const getQueryFilter = (
language: Language,
filters: Array<Partial<Filter>>,
index: Index,
lists: ExceptionListItemSchema[]
lists: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
excludeExceptions: boolean = true
) => {
const indexPattern: IIndexPattern = {
fields: [],
title: index.join(),
};
const queries: DataQuery[] = buildQueryExceptions({ query, language, lists });
const queries: DataQuery[] = buildQueryExceptions({
query,
language,
lists,
exclude: excludeExceptions,
});
const config = {
allowLeadingWildcards: true,

View file

@ -251,13 +251,19 @@ export const AddExceptionModal = memo(function AddExceptionModal({
const onAddExceptionConfirm = useCallback(() => {
if (addOrUpdateExceptionItems !== null) {
if (shouldCloseAlert && alertData) {
addOrUpdateExceptionItems(enrichExceptionItems(), alertData.ecsData._id);
} else {
addOrUpdateExceptionItems(enrichExceptionItems());
}
const alertIdToClose = shouldCloseAlert && alertData ? alertData.ecsData._id : undefined;
const bulkCloseIndex =
shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined;
addOrUpdateExceptionItems(enrichExceptionItems(), alertIdToClose, bulkCloseIndex);
}
}, [addOrUpdateExceptionItems, enrichExceptionItems, shouldCloseAlert, alertData]);
}, [
addOrUpdateExceptionItems,
enrichExceptionItems,
shouldCloseAlert,
shouldBulkCloseAlert,
alertData,
signalIndexName,
]);
const isSubmitButtonDisabled = useCallback(
() => fetchOrCreateListError || exceptionItemsToAdd.length === 0,
@ -330,7 +336,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
<EuiHorizontalRule />
<ModalBodySection>
{alertData !== undefined && (
<EuiFormRow>
<EuiFormRow fullWidth>
<EuiCheckbox
id="close-alert-on-add-add-exception-checkbox"
label="Close this alert"
@ -339,10 +345,14 @@ export const AddExceptionModal = memo(function AddExceptionModal({
/>
</EuiFormRow>
)}
<EuiFormRow>
<EuiFormRow fullWidth>
<EuiCheckbox
id="bulk-close-alert-on-add-add-exception-checkbox"
label={i18n.BULK_CLOSE_LABEL}
label={
shouldDisableBulkClose
? i18n.BULK_CLOSE_LABEL_DISABLED
: i18n.BULK_CLOSE_LABEL
}
checked={shouldBulkCloseAlert}
onChange={onBulkCloseAlertCheckboxChange}
disabled={shouldDisableBulkClose}

View file

@ -60,6 +60,14 @@ export const BULK_CLOSE_LABEL = i18n.translate(
}
);
export const BULK_CLOSE_LABEL_DISABLED = i18n.translate(
'xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled',
{
defaultMessage:
'Close all alerts that match attributes in this exception (Lists and non-ECS fields are not supported)',
}
);
export const EXCEPTION_BUILDER_INFO = i18n.translate(
'xpack.securitySolution.exceptions.addException.infoLabel',
{

View file

@ -181,9 +181,11 @@ export const EditExceptionModal = memo(function EditExceptionModal({
const onEditExceptionConfirm = useCallback(() => {
if (addOrUpdateExceptionItems !== null) {
addOrUpdateExceptionItems(enrichExceptionItems());
const bulkCloseIndex =
shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined;
addOrUpdateExceptionItems(enrichExceptionItems(), undefined, bulkCloseIndex);
}
}, [addOrUpdateExceptionItems, enrichExceptionItems]);
}, [addOrUpdateExceptionItems, enrichExceptionItems, shouldBulkCloseAlert, signalIndexName]);
const indexPatternConfig = useCallback(() => {
if (exceptionListType === 'endpoint') {
@ -239,10 +241,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({
</ModalBodySection>
<EuiHorizontalRule />
<ModalBodySection>
<EuiFormRow>
<EuiFormRow fullWidth>
<EuiCheckbox
id="close-alert-on-add-add-exception-checkbox"
label={i18n.BULK_CLOSE_LABEL}
label={
shouldDisableBulkClose ? i18n.BULK_CLOSE_LABEL_DISABLED : i18n.BULK_CLOSE_LABEL
}
checked={shouldBulkCloseAlert}
onChange={onBulkCloseAlertCheckboxChange}
disabled={shouldDisableBulkClose}

View file

@ -38,6 +38,14 @@ export const BULK_CLOSE_LABEL = i18n.translate(
}
);
export const BULK_CLOSE_LABEL_DISABLED = i18n.translate(
'xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled',
{
defaultMessage:
'Close all alerts that match attributes in this exception (Lists and non-ECS fields are not supported)',
}
);
export const ENDPOINT_QUARANTINE_TEXT = i18n.translate(
'xpack.securitySolution.exceptions.editException.endpointQuarantineText',
{

View file

@ -25,6 +25,7 @@ import {
enrichExceptionItemsWithOS,
entryHasListType,
entryHasNonEcsType,
prepareExceptionItemsForBulkClose,
} from './helpers';
import { FormattedEntry, DescriptionListItem, EmptyEntry } from './types';
import {
@ -683,4 +684,65 @@ describe('Exception helpers', () => {
expect(result).toEqual(true);
});
});
describe('#prepareExceptionItemsForBulkClose', () => {
test('it should return no exceptionw when passed in an empty array', () => {
const payload: ExceptionListItemSchema[] = [];
const result = prepareExceptionItemsForBulkClose(payload);
expect(result).toEqual([]);
});
test("should not make any updates when the exception entries don't contain 'event.'", () => {
const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()];
const result = prepareExceptionItemsForBulkClose(payload);
expect(result).toEqual(payload);
});
test("should update entry fields when they start with 'event.'", () => {
const payload = [
{
...getExceptionListItemSchemaMock(),
entries: [
{
...getEntryMatchMock(),
field: 'event.kind',
},
getEntryMatchMock(),
],
},
{
...getExceptionListItemSchemaMock(),
entries: [
{
...getEntryMatchMock(),
field: 'event.module',
},
],
},
];
const expected = [
{
...getExceptionListItemSchemaMock(),
entries: [
{
...getEntryMatchMock(),
field: 'signal.original_event.kind',
},
getEntryMatchMock(),
],
},
{
...getExceptionListItemSchemaMock(),
entries: [
{
...getEntryMatchMock(),
field: 'signal.original_event.module',
},
],
},
];
const result = prepareExceptionItemsForBulkClose(payload);
expect(result).toEqual(expected);
});
});
});

View file

@ -36,6 +36,7 @@ import {
exceptionListItemSchema,
UpdateExceptionListItemSchema,
ExceptionListType,
EntryNested,
} from '../../../lists_plugin_deps';
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { TimelineNonEcsData } from '../../../graphql/types';
@ -380,6 +381,35 @@ export const formatExceptionItemForUpdate = (
};
};
/**
* Maps "event." fields to "signal.original_event.". This is because when a rule is created
* the "event" field is copied over to "original_event". When the user creates an exception,
* they expect it to match against the original_event's fields, not the signal event's.
* @param exceptionItems new or existing ExceptionItem[]
*/
export const prepareExceptionItemsForBulkClose = (
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => {
if (item.entries !== undefined) {
const newEntries = item.entries.map((itemEntry: Entry | EntryNested) => {
return {
...itemEntry,
field: itemEntry.field.startsWith('event.')
? itemEntry.field.replace(/^event./, 'signal.original_event.')
: itemEntry.field,
};
});
return {
...item,
entries: newEntries,
};
} else {
return item;
}
});
};
/**
* Adds new and existing comments to all new exceptionItems if not present already
* @param exceptionItems new or existing ExceptionItem[]

View file

@ -9,6 +9,8 @@ import { KibanaServices } from '../../../common/lib/kibana';
import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api';
import * as listsApi from '../../../../../lists/public/exceptions/api';
import * as getQueryFilterHelper from '../../../../common/detection_engine/get_query_filter';
import * as buildAlertStatusFilterHelper from '../../../detections/components/alerts_table/default_config';
import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { getCreateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/create_exception_list_item_schema.mock';
import { getUpdateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/update_exception_list_item_schema.mock';
@ -38,11 +40,16 @@ describe('useAddOrUpdateException', () => {
let updateExceptionListItem: jest.SpyInstance<ReturnType<
typeof listsApi.updateExceptionListItem
>>;
let getQueryFilter: jest.SpyInstance<ReturnType<typeof getQueryFilterHelper.getQueryFilter>>;
let buildAlertStatusFilter: jest.SpyInstance<ReturnType<
typeof buildAlertStatusFilterHelper.buildAlertStatusFilter
>>;
let addOrUpdateItemsArgs: Parameters<AddOrUpdateExceptionItemsFunc>;
let render: () => RenderHookResult<UseAddOrUpdateExceptionProps, ReturnUseAddOrUpdateException>;
const onError = jest.fn();
const onSuccess = jest.fn();
const alertIdToClose = 'idToClose';
const bulkCloseIndex = ['.signals'];
const itemsToAdd: CreateExceptionListItemSchema[] = [
{
...getCreateExceptionListItemSchemaMock(),
@ -113,6 +120,10 @@ describe('useAddOrUpdateException', () => {
.spyOn(listsApi, 'updateExceptionListItem')
.mockResolvedValue(getExceptionListItemSchemaMock());
getQueryFilter = jest.spyOn(getQueryFilterHelper, 'getQueryFilter');
buildAlertStatusFilter = jest.spyOn(buildAlertStatusFilterHelper, 'buildAlertStatusFilter');
addOrUpdateItemsArgs = [itemsToAddOrUpdate];
render = () =>
renderHook<UseAddOrUpdateExceptionProps, ReturnUseAddOrUpdateException>(() =>
@ -244,4 +255,92 @@ describe('useAddOrUpdateException', () => {
});
});
});
describe('when bulkCloseIndex is passed in', () => {
beforeEach(() => {
addOrUpdateItemsArgs = [itemsToAddOrUpdate, undefined, bulkCloseIndex];
});
it('should update the status of only alerts that are open', async () => {
await act(async () => {
const { rerender, result, waitForNextUpdate } = render();
const addOrUpdateItems = await waitForAddOrUpdateFunc({
rerender,
result,
waitForNextUpdate,
});
if (addOrUpdateItems) {
addOrUpdateItems(...addOrUpdateItemsArgs);
}
await waitForNextUpdate();
expect(buildAlertStatusFilter).toHaveBeenCalledTimes(1);
expect(buildAlertStatusFilter.mock.calls[0][0]).toEqual('open');
});
});
it('should generate the query filter using exceptions', async () => {
await act(async () => {
const { rerender, result, waitForNextUpdate } = render();
const addOrUpdateItems = await waitForAddOrUpdateFunc({
rerender,
result,
waitForNextUpdate,
});
if (addOrUpdateItems) {
addOrUpdateItems(...addOrUpdateItemsArgs);
}
await waitForNextUpdate();
expect(getQueryFilter).toHaveBeenCalledTimes(1);
expect(getQueryFilter.mock.calls[0][4]).toEqual(itemsToAddOrUpdate);
expect(getQueryFilter.mock.calls[0][5]).toEqual(false);
});
});
it('should update the alert status', async () => {
await act(async () => {
const { rerender, result, waitForNextUpdate } = render();
const addOrUpdateItems = await waitForAddOrUpdateFunc({
rerender,
result,
waitForNextUpdate,
});
if (addOrUpdateItems) {
addOrUpdateItems(...addOrUpdateItemsArgs);
}
await waitForNextUpdate();
expect(updateAlertStatus).toHaveBeenCalledTimes(1);
});
});
it('creates new items', async () => {
await act(async () => {
const { rerender, result, waitForNextUpdate } = render();
const addOrUpdateItems = await waitForAddOrUpdateFunc({
rerender,
result,
waitForNextUpdate,
});
if (addOrUpdateItems) {
addOrUpdateItems(...addOrUpdateItemsArgs);
}
await waitForNextUpdate();
expect(addExceptionListItem).toHaveBeenCalledTimes(2);
expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]);
});
});
it('updates existing items', async () => {
await act(async () => {
const { rerender, result, waitForNextUpdate } = render();
const addOrUpdateItems = await waitForAddOrUpdateFunc({
rerender,
result,
waitForNextUpdate,
});
if (addOrUpdateItems) {
addOrUpdateItems(...addOrUpdateItemsArgs);
}
await waitForNextUpdate();
expect(updateExceptionListItem).toHaveBeenCalledTimes(2);
expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual(
itemsToUpdateFormatted[1]
);
});
});
});
});

View file

@ -16,18 +16,23 @@ import {
} from '../../../lists_plugin_deps';
import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api';
import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions';
import { formatExceptionItemForUpdate } from './helpers';
import { buildAlertStatusFilter } from '../../../detections/components/alerts_table/default_config';
import { getQueryFilter } from '../../../../common/detection_engine/get_query_filter';
import { Index } from '../../../../common/detection_engine/schemas/common/schemas';
import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from './helpers';
/**
* Adds exception items to the list. Also optionally closes alerts.
*
* @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update
* @param alertIdToClose - optional string representing alert to close
* @param bulkCloseIndex - optional index used to create bulk close query
*
*/
export type AddOrUpdateExceptionItemsFunc = (
exceptionItemsToAddOrUpdate: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>,
alertIdToClose?: string
alertIdToClose?: string,
bulkCloseIndex?: Index
) => Promise<void>;
export type ReturnUseAddOrUpdateException = [
@ -100,7 +105,8 @@ export const useAddOrUpdateException = ({
const addOrUpdateExceptionItems: AddOrUpdateExceptionItemsFunc = async (
exceptionItemsToAddOrUpdate,
alertIdToClose
alertIdToClose,
bulkCloseIndex
) => {
try {
setIsLoading(true);
@ -111,6 +117,23 @@ export const useAddOrUpdateException = ({
});
}
if (bulkCloseIndex != null) {
const filter = getQueryFilter(
'',
'kuery',
buildAlertStatusFilter('open'),
bulkCloseIndex,
prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate),
false
);
await updateAlertStatus({
query: {
query: filter,
},
status: 'closed',
});
}
await addOrUpdateItems(exceptionItemsToAddOrUpdate);
if (isSubscribed) {