[Security Solution][Detections] Adds sequence callout in the exceptions modals for eql rule types (#79007)

This commit is contained in:
Davis Plumlee 2020-10-05 14:39:40 -06:00 committed by GitHub
parent b9613af6a8
commit 6a173ba19d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 233 additions and 2 deletions

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { hasLargeValueList, hasNestedEntry, isThreatMatchRule } from './utils';
import { hasEqlSequenceQuery, hasLargeValueList, hasNestedEntry, isThreatMatchRule } from './utils';
import { EntriesArray } from '../shared_imports';
describe('#hasLargeValueList', () => {
@ -113,3 +113,40 @@ describe('#hasNestedEntry', () => {
});
});
});
describe('#hasEqlSequenceQuery', () => {
describe('when a non-sequence query is passed', () => {
const query = 'process where process.name == "regsvr32.exe"';
it('should return false', () => {
expect(hasEqlSequenceQuery(query)).toEqual(false);
});
});
describe('when a sequence query is passed', () => {
const query = 'sequence [process where process.name = "test.exe"]';
it('should return true', () => {
expect(hasEqlSequenceQuery(query)).toEqual(true);
});
});
describe('when a sequence query is passed with extra white space and escape characters', () => {
const query = '\tsequence \n [process where process.name = "test.exe"]';
it('should return true', () => {
expect(hasEqlSequenceQuery(query)).toEqual(true);
});
});
describe('when a non-sequence query is passed using the word sequence', () => {
const query = 'sequence where true';
it('should return false', () => {
expect(hasEqlSequenceQuery(query)).toEqual(false);
});
});
describe('when a non-sequence query is passed using the word sequence with extra white space and escape characters', () => {
const query = ' sequence\nwhere\ttrue';
it('should return false', () => {
expect(hasEqlSequenceQuery(query)).toEqual(false);
});
});
});

View file

@ -17,6 +17,14 @@ export const hasNestedEntry = (entries: EntriesArray): boolean => {
return found.length > 0;
};
export const hasEqlSequenceQuery = (ruleQuery: string | undefined): boolean => {
if (ruleQuery != null) {
const parsedQuery = ruleQuery.trim().split(/[ \t\r\n]+/);
return parsedQuery[0] === 'sequence' && parsedQuery[1] !== 'where';
}
return false;
};
export const isEqlRule = (ruleType: Type | undefined): boolean => ruleType === 'eql';
export const isThresholdRule = (ruleType: Type | undefined): boolean => ruleType === 'threshold';
export const isQueryRule = (ruleType: Type | undefined): boolean =>

View file

@ -25,6 +25,11 @@ import * as helpers from '../helpers';
import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { EntriesArray } from '../../../../../../lists/common/schemas/types';
import { ExceptionListItemSchema } from '../../../../../../lists/common';
import {
getRulesEqlSchemaMock,
getRulesSchemaMock,
} from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks';
import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async';
jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index');
jest.mock('../../../../common/lib/kibana');
@ -34,6 +39,7 @@ jest.mock('../use_add_exception');
jest.mock('../use_fetch_or_create_rule_exception_list');
jest.mock('../builder');
jest.mock('../../../../shared_imports');
jest.mock('../../../../detections/containers/detection_engine/rules/use_rule_async');
describe('When the add exception modal is opened', () => {
const ruleName = 'test rule';
@ -73,6 +79,9 @@ describe('When the add exception modal is opened', () => {
},
]);
(useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' });
(useRuleAsync as jest.Mock).mockImplementation(() => ({
rule: getRulesSchemaMock(),
}));
});
afterEach(() => {
@ -193,6 +202,9 @@ describe('When the add exception modal is opened', () => {
it('should contain the endpoint specific documentation text', () => {
expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy();
});
it('should not display the eql sequence callout', () => {
expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy();
});
});
describe('when there is alert data passed to a detection list exception', () => {
@ -241,6 +253,66 @@ describe('When the add exception modal is opened', () => {
.getDOMNode()
).toBeDisabled();
});
it('should not display the eql sequence callout', () => {
expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy();
});
});
describe('when there is an exception being created on a sequence eql rule type', () => {
let wrapper: ReactWrapper;
beforeEach(async () => {
const alertDataMock: Ecs = { _id: 'test-id', file: { path: ['test/path'] } };
(useRuleAsync as jest.Mock).mockImplementation(() => ({
rule: {
...getRulesEqlSchemaMock(),
query:
'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]',
},
}));
wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<AddExceptionModal
ruleId={'123'}
ruleIndices={['filebeat-*']}
ruleName={ruleName}
exceptionListType={'detection'}
onCancel={jest.fn()}
onConfirm={jest.fn()}
alertData={alertDataMock}
/>
</ThemeProvider>
);
const callProps = ExceptionBuilderComponent.mock.calls[0][0];
await waitFor(() =>
callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] })
);
});
it('has the add exception button enabled', () => {
expect(
wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode()
).not.toBeDisabled();
});
it('should render the exception builder', () => {
expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy();
});
it('should not prepopulate endpoint items', () => {
expect(defaultEndpointItems).not.toHaveBeenCalled();
});
it('should render the close on add exception checkbox', () => {
expect(
wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists()
).toBeTruthy();
});
it('should have the bulk close checkbox disabled', () => {
expect(
wrapper
.find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]')
.getDOMNode()
).toBeDisabled();
});
it('should display the eql sequence callout', () => {
expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy();
});
});
describe('when there is bulk-closeable alert data passed to an endpoint list exception', () => {

View file

@ -19,7 +19,9 @@ import {
EuiSpacer,
EuiFormRow,
EuiText,
EuiCallOut,
} from '@elastic/eui';
import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
import {
ExceptionListItemSchema,
@ -315,6 +317,13 @@ export const AddExceptionModal = memo(function AddExceptionModal({
const addExceptionMessage =
exceptionListType === 'endpoint' ? i18n.ADD_ENDPOINT_EXCEPTION : i18n.ADD_EXCEPTION;
const isRuleEQLSequenceStatement = useMemo((): boolean => {
if (maybeRule != null) {
return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query);
}
return false;
}, [maybeRule]);
return (
<EuiOverlayMask onClick={onCancel}>
<Modal onClose={onCancel} data-test-subj="add-exception-modal">
@ -353,6 +362,15 @@ export const AddExceptionModal = memo(function AddExceptionModal({
ruleExceptionList && (
<>
<ModalBodySection className="builder-section">
{isRuleEQLSequenceStatement && (
<>
<EuiCallOut
data-test-subj="eql-sequence-callout"
title={i18n.ADD_EXCEPTION_SEQUENCE_WARNING}
/>
<EuiSpacer />
</>
)}
<EuiText>{i18n.EXCEPTION_BUILDER_INFO}</EuiText>
<EuiSpacer />
<ExceptionBuilderComponent

View file

@ -81,3 +81,11 @@ export const EXCEPTION_BUILDER_INFO = i18n.translate(
defaultMessage: "Alerts are generated when the rule's conditions are met, except when:",
}
);
export const ADD_EXCEPTION_SEQUENCE_WARNING = i18n.translate(
'xpack.securitySolution.exceptions.addException.sequenceWarning',
{
defaultMessage:
"This rule's query contains an EQL sequence statement. The exception created will apply to all events in the sequence.",
}
);

View file

@ -22,6 +22,11 @@ import { useSignalIndex } from '../../../../detections/containers/detection_engi
import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { EntriesArray } from '../../../../../../lists/common/schemas/types';
import * as builder from '../builder';
import {
getRulesEqlSchemaMock,
getRulesSchemaMock,
} from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks';
import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../detections/containers/detection_engine/rules');
@ -30,6 +35,7 @@ jest.mock('../../../containers/source');
jest.mock('../use_fetch_or_create_rule_exception_list');
jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index');
jest.mock('../builder');
jest.mock('../../../../detections/containers/detection_engine/rules/use_rule_async');
describe('When the edit exception modal is opened', () => {
const ruleName = 'test rule';
@ -58,6 +64,9 @@ describe('When the edit exception modal is opened', () => {
},
]);
(useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' });
(useRuleAsync as jest.Mock).mockImplementation(() => ({
rule: getRulesSchemaMock(),
}));
});
afterEach(() => {
@ -190,7 +199,58 @@ describe('When the edit exception modal is opened', () => {
});
});
describe('when an detection exception with entries is passed', () => {
describe('when an exception assigned to a sequence eql rule type is passed', () => {
let wrapper: ReactWrapper;
beforeEach(async () => {
(useRuleAsync as jest.Mock).mockImplementation(() => ({
rule: {
...getRulesEqlSchemaMock(),
query:
'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]',
},
}));
wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<EditExceptionModal
ruleIndices={['filebeat-*']}
ruleId="123"
ruleName={ruleName}
exceptionListType={'detection'}
onCancel={jest.fn()}
onConfirm={jest.fn()}
exceptionItem={getExceptionListItemSchemaMock()}
/>
</ThemeProvider>
);
const callProps = ExceptionBuilderComponent.mock.calls[0][0];
await waitFor(() => {
callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] });
});
});
it('has the edit exception button enabled', () => {
expect(
wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode()
).not.toBeDisabled();
});
it('renders the exceptions builder', () => {
expect(wrapper.find('[data-test-subj="edit-exception-modal-builder"]').exists()).toBeTruthy();
});
it('should not contain the endpoint specific documentation text', () => {
expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy();
});
it('should have the bulk close checkbox disabled', () => {
expect(
wrapper
.find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]')
.getDOMNode()
).toBeDisabled();
});
it('should display the eql sequence callout', () => {
expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy();
});
});
describe('when a detection exception with entries is passed', () => {
let wrapper: ReactWrapper;
beforeEach(async () => {
wrapper = mount(
@ -229,6 +289,9 @@ describe('When the edit exception modal is opened', () => {
.getDOMNode()
).toBeDisabled();
});
it('should not display the eql sequence callout', () => {
expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy();
});
});
describe('when an exception with no entries is passed', () => {

View file

@ -22,6 +22,7 @@ import {
EuiCallOut,
} from '@elastic/eui';
import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils';
import { useFetchIndex } from '../../../containers/source';
import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index';
import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async';
@ -246,6 +247,13 @@ export const EditExceptionModal = memo(function EditExceptionModal({
signalIndexName,
]);
const isRuleEQLSequenceStatement = useMemo((): boolean => {
if (maybeRule != null) {
return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query);
}
return false;
}, [maybeRule]);
return (
<EuiOverlayMask onClick={onCancel}>
<Modal onClose={onCancel} data-test-subj="add-exception-modal">
@ -265,6 +273,15 @@ export const EditExceptionModal = memo(function EditExceptionModal({
{!isSignalIndexLoading && !addExceptionIsLoading && !isIndexPatternLoading && (
<>
<ModalBodySection className="builder-section">
{isRuleEQLSequenceStatement && (
<>
<EuiCallOut
data-test-subj="eql-sequence-callout"
title={i18n.EDIT_EXCEPTION_SEQUENCE_WARNING}
/>
<EuiSpacer />
</>
)}
<EuiText>{i18n.EXCEPTION_BUILDER_INFO}</EuiText>
<EuiSpacer />
<ExceptionBuilderComponent

View file

@ -89,3 +89,11 @@ export const VERSION_CONFLICT_ERROR_DESCRIPTION = i18n.translate(
"It appears this exception was updated since you first selected to edit it. Try clicking 'Cancel' and editing the exception again.",
}
);
export const EDIT_EXCEPTION_SEQUENCE_WARNING = i18n.translate(
'xpack.securitySolution.exceptions.editException.sequenceWarning',
{
defaultMessage:
"This rule's query contains an EQL sequence statement. The exception modified will apply to all events in the sequence.",
}
);