[Security Solution][Alerts] Detection engine wildcard exceptions (#136147)

* Implement wildcard exceptions for detection rules

* Fix index pattern retrieval on edit exceptions flyout

* Fix API integration test logic

* Fix entry_renderer linting

* Remove bad fix idea

* Add 'does not match' operator to UI

* Fix test

* Add unit tests

* Add wildcard exceptions to list of DE exception operators

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marshall Main 2022-07-19 15:03:47 -07:00 committed by GitHub
parent 17b7cd75c7
commit aaa3107dbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 512 additions and 10 deletions

View file

@ -132,6 +132,9 @@ describe('operator', () => {
{
label: 'matches',
},
{
label: 'does not match',
},
]);
});

View file

@ -94,6 +94,15 @@ export const matchesOperator: OperatorOption = {
value: 'matches',
};
export const doesNotMatchOperator: OperatorOption = {
message: i18n.translate('lists.exceptions.doesNotMatchOperatorLabel', {
defaultMessage: 'does not match',
}),
operator: OperatorEnum.EXCLUDED,
type: OperatorTypeEnum.WILDCARD,
value: 'does_not_match',
};
export const EVENT_FILTERS_OPERATORS: OperatorOption[] = [
isOperator,
isNotOperator,
@ -115,6 +124,8 @@ export const DETECTION_ENGINE_EXCEPTION_OPERATORS: OperatorOption[] = [
doesNotExistOperator,
isInListOperator,
isNotInListOperator,
matchesOperator,
doesNotMatchOperator,
];
export const ALL_OPERATORS: OperatorOption[] = [
@ -127,6 +138,7 @@ export const ALL_OPERATORS: OperatorOption[] = [
isInListOperator,
isNotInListOperator,
matchesOperator,
doesNotMatchOperator,
];
export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [
@ -136,6 +148,8 @@ export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [
isNotOneOfOperator,
existsOperator,
doesNotExistOperator,
matchesOperator,
doesNotMatchOperator,
];
export const EXCEPTION_OPERATORS_ONLY_LISTS: OperatorOption[] = [

View file

@ -20,13 +20,15 @@ import {
entriesMatchAny,
entriesNested,
OsTypeArray,
entriesMatchWildcard,
EntryMatchWildcard,
} from '@kbn/securitysolution-io-ts-list-types';
import { Filter } from '@kbn/es-query';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { hasLargeValueList } from '../has_large_value_list';
type NonListEntry = EntryMatch | EntryMatchAny | EntryNested | EntryExists;
type NonListEntry = EntryMatch | EntryMatchAny | EntryNested | EntryExists | EntryMatchWildcard;
interface ExceptionListItemNonLargeList extends ExceptionListItemSchema {
entries: NonListEntry[];
}
@ -290,6 +292,25 @@ export const buildMatchAnyClause = (entry: EntryMatchAny): BooleanFilter => {
}
};
export const buildMatchWildcardClause = (entry: EntryMatchWildcard): BooleanFilter => {
const { field, operator, value } = entry;
const wildcardClause = {
bool: {
filter: {
wildcard: {
[field]: value,
},
},
},
};
if (operator === 'excluded') {
return buildExclusionClause(wildcardClause);
} else {
return wildcardClause;
}
};
export const buildExistsClause = (entry: EntryExists): BooleanFilter => {
const { field, operator } = entry;
const existsClause = {
@ -352,15 +373,15 @@ export const createInnerAndClauses = (
entry: NonListEntry,
parent?: string
): BooleanFilter | NestedFilter => {
const field = parent != null ? `${parent}.${entry.field}` : entry.field;
if (entriesExists.is(entry)) {
const field = parent != null ? `${parent}.${entry.field}` : entry.field;
return buildExistsClause({ ...entry, field });
} else if (entriesMatch.is(entry)) {
const field = parent != null ? `${parent}.${entry.field}` : entry.field;
return buildMatchClause({ ...entry, field });
} else if (entriesMatchAny.is(entry)) {
const field = parent != null ? `${parent}.${entry.field}` : entry.field;
return buildMatchAnyClause({ ...entry, field });
} else if (entriesMatchWildcard.is(entry)) {
return buildMatchWildcardClause({ ...entry, field });
} else if (entriesNested.is(entry)) {
return buildNestedClause(entry);
} else {

View file

@ -16,6 +16,7 @@ import {
buildExistsClause,
buildMatchAnyClause,
buildMatchClause,
buildMatchWildcardClause,
buildNestedClause,
createOrClauses,
} from '@kbn/securitysolution-list-utils';
@ -32,6 +33,10 @@ import {
getEntryNestedMock,
} from '../schemas/types/entry_nested.mock';
import { getExceptionListItemSchemaMock } from '../schemas/response/exception_list_item_schema.mock';
import {
getEntryMatchWildcardExcludeMock,
getEntryMatchWildcardMock,
} from '../schemas/types/entry_match_wildcard.mock';
// TODO: Port the test over to packages/kbn-securitysolution-list-utils/src/build_exception_filter/index.test.ts once the mocks are ported to kbn
@ -1040,4 +1045,38 @@ describe('build_exceptions_filter', () => {
});
});
});
describe('buildWildcardClause', () => {
test('it should build wildcard filter when operator is "included"', () => {
const booleanFilter = buildMatchWildcardClause(getEntryMatchWildcardMock());
expect(booleanFilter).toEqual({
bool: {
filter: {
wildcard: {
'host.name': 'some host name',
},
},
},
});
});
test('it should build boolean filter when operator is "excluded"', () => {
const booleanFilter = buildMatchWildcardClause(getEntryMatchWildcardExcludeMock());
expect(booleanFilter).toEqual({
bool: {
must_not: {
bool: {
filter: {
wildcard: {
'host.name': 'some host name',
},
},
},
},
},
});
});
});
});

View file

@ -10,6 +10,7 @@ import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { coreMock } from '@kbn/core/public/mocks';
import {
doesNotExistOperator,
doesNotMatchOperator,
existsOperator,
isInListOperator,
isNotInListOperator,
@ -383,6 +384,80 @@ describe('BuilderEntryItem', () => {
).toBeTruthy();
});
test('it renders field values correctly when operator is "matchesOperator"', () => {
wrapper = mount(
<BuilderEntryItem
autocompleteService={autocompleteStartMock}
entry={{
correspondingKeywordField: undefined,
entryIndex: 0,
field: getField('ip'),
id: '123',
nested: undefined,
operator: matchesOperator,
parent: undefined,
value: '1234*',
}}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
}}
listType="detection"
onChange={jest.fn()}
setErrorsExist={jest.fn()}
setWarningsExist={jest.fn()}
showLabel={false}
/>
);
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryField"]').text()).toEqual('ip');
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"]').text()).toEqual(
'matches'
);
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldWildcard"]').text()).toEqual(
'1234*'
);
});
test('it renders field values correctly when operator is "doesNotMatchOperator"', () => {
wrapper = mount(
<BuilderEntryItem
autocompleteService={autocompleteStartMock}
entry={{
correspondingKeywordField: undefined,
entryIndex: 0,
field: getField('ip'),
id: '123',
nested: undefined,
operator: doesNotMatchOperator,
parent: undefined,
value: '1234*',
}}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
}}
listType="detection"
onChange={jest.fn()}
setErrorsExist={jest.fn()}
setWarningsExist={jest.fn()}
showLabel={false}
/>
);
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryField"]').text()).toEqual('ip');
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"]').text()).toEqual(
'does not match'
);
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldWildcard"]').text()).toEqual(
'1234*'
);
});
test('it uses "correspondingKeywordField" if it exists', () => {
const correspondingKeywordField: FieldSpec = {
aggregatable: true,
@ -656,6 +731,47 @@ describe('BuilderEntryItem', () => {
);
});
test('it invokes "onChange" when new value field is entered for wildcard operator', () => {
const mockOnChange = jest.fn();
wrapper = mount(
<BuilderEntryItem
autocompleteService={autocompleteStartMock}
entry={{
correspondingKeywordField: undefined,
entryIndex: 0,
field: getField('ip'),
id: '123',
nested: undefined,
operator: matchesOperator,
parent: undefined,
value: '1234*',
}}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
}}
listType="detection"
onChange={mockOnChange}
setErrorsExist={jest.fn()}
setWarningsExist={jest.fn()}
showLabel={false}
/>
);
(
wrapper.find(EuiComboBox).at(2).props() as unknown as {
onCreateOption: (a: string) => void;
}
).onCreateOption('5678*');
expect(mockOnChange).toHaveBeenCalledWith(
{ field: 'ip', id: '123', operator: 'included', type: 'wildcard', value: '5678*' },
0
);
});
test('it invokes "setErrorsExist" when user touches value input and leaves empty', async () => {
const mockSetErrorExists = jest.fn();
wrapper = mount(

View file

@ -292,6 +292,7 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
);
};
// eslint-disable-next-line complexity
const getFieldValueComboBox = (type: OperatorTypeEnum, isFirst: boolean): JSX.Element => {
switch (type) {
case OperatorTypeEnum.MATCH:
@ -338,13 +339,16 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
);
case OperatorTypeEnum.WILDCARD:
const wildcardValue = typeof entry.value === 'string' ? entry.value : undefined;
let os: OperatingSystem = OperatingSystem.WINDOWS;
if (osTypes) {
[os] = osTypes as OperatingSystem[];
let actualWarning: React.ReactNode | string | undefined;
if (listType !== 'detection') {
let os: OperatingSystem = OperatingSystem.WINDOWS;
if (osTypes) {
[os] = osTypes as OperatingSystem[];
}
const warning = validateFilePathInput({ os, value: wildcardValue });
actualWarning =
warning === FILENAME_WILDCARD_WARNING ? getWildcardWarning(warning) : warning;
}
const warning = validateFilePathInput({ os, value: wildcardValue });
const actualWarning =
warning === FILENAME_WILDCARD_WARNING ? getWildcardWarning(warning) : warning;
return (
<AutocompleteFieldWildcardComponent

View file

@ -35,6 +35,7 @@ const OPERATOR_TYPE_LABELS_INCLUDED = Object.freeze({
const OPERATOR_TYPE_LABELS_EXCLUDED = Object.freeze({
[ListOperatorTypeEnum.MATCH_ANY]: i18n.CONDITION_OPERATOR_TYPE_NOT_MATCH_ANY,
[ListOperatorTypeEnum.MATCH]: i18n.CONDITION_OPERATOR_TYPE_NOT_MATCH,
[ListOperatorTypeEnum.WILDCARD]: i18n.CONDITION_OPERATOR_TYPE_WILDCARD_DOES_NOT_MATCH,
});
const EuiFlexGroupNested = styled(EuiFlexGroup)`

View file

@ -69,6 +69,13 @@ export const CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES = i18n.translate(
}
);
export const CONDITION_OPERATOR_TYPE_WILDCARD_DOES_NOT_MATCH = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator',
{
defaultMessage: 'DOES NOT MATCH',
}
);
export const CONDITION_OPERATOR_TYPE_NESTED = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator',
{

View file

@ -593,5 +593,173 @@ export default ({ getService }: FtrProviderContext) => {
expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']);
});
});
describe('"matches wildcard" operator', () => {
it('should return 0 alerts if wildcard matches all words', async () => {
const rule = getRuleForSignalTesting(['keyword']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'included',
type: 'wildcard',
// Filter out all 4 words
value: 'word *',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql([]);
});
it('should return 1 alert if wildcard exceptions match one, two, and three', async () => {
const rule = getRuleForSignalTesting(['keyword']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'included',
type: 'wildcard',
value: 'word one',
},
],
[
{
field: 'keyword',
operator: 'included',
type: 'wildcard',
// Filter out both "word two" and "word three"
value: 'word t*',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 1, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql(['word four']);
});
it('should return 3 alerts if one is set as an exception', async () => {
const rule = getRuleForSignalTesting(['keyword']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'included',
type: 'wildcard',
// Without * or ? in the value, it should work the same as the "is" operator
value: 'word one',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 3, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql(['word four', 'word three', 'word two']);
});
it('should return 4 alerts if the wildcard matches nothing', async () => {
const rule = getRuleForSignalTesting(['keyword']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'included',
type: 'wildcard',
value: 'word a*',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 4, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']);
});
});
describe('"does not match wildcard" operator', () => {
it('should return 4 results if excluded wildcard matches all 4 words', async () => {
const rule = getRuleForSignalTesting(['keyword']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'excluded',
type: 'wildcard',
// Filter out everything except things matching 'word *'
value: 'word *',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 4, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']);
});
it('should filter in 2 keywords if using a wildcard exception', async () => {
const rule = getRuleForSignalTesting(['keyword']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'excluded',
type: 'wildcard',
// Filter out everything except things matching 'word t*'
value: 'word t*',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 2, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql(['word three', 'word two']);
});
it('should return 1 alert if "word one" is excluded', async () => {
const rule = getRuleForSignalTesting(['keyword']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'excluded',
type: 'wildcard',
// Without * or ? in the value, it should work the same as the "is not" operator
value: 'word one',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 1, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql(['word one']);
});
it('should return 0 alerts if it cannot find what it is excluding', async () => {
const rule = getRuleForSignalTesting(['keyword']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'excluded',
type: 'wildcard',
value: 'word a*',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql([]);
});
});
});
};

View file

@ -633,5 +633,134 @@ export default ({ getService }: FtrProviderContext) => {
]);
});
});
describe('"matches wildcard" operator', () => {
it('should filter 1 single keyword if it is set as an exception', async () => {
const rule = getRuleForSignalTesting(['keyword_as_array']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'included',
type: 'wildcard',
value: 'word o*',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 3, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql([
[],
['word eight', 'word nine', 'word ten'],
['word five', null, 'word six', 'word seven'],
]);
});
it('should filter 2 keyword if both are set as exceptions', async () => {
const rule = getRuleForSignalTesting(['keyword_as_array']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'included',
type: 'wildcard',
value: 'word t*',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 2, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql([[], ['word five', null, 'word six', 'word seven']]);
});
it('should filter 3 keyword if all 3 are set as exceptions', async () => {
const rule = getRuleForSignalTesting(['keyword_as_array']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'included',
type: 'wildcard',
value: 'word *',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 1, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]);
});
});
describe('"does not match wildcard" operator', () => {
it('should filter 1 single keyword if it is set as an exception', async () => {
const rule = getRuleForSignalTesting(['keyword_as_array']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'excluded',
type: 'wildcard',
value: 'word o*',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 1, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql([['word one', 'word two', 'word three', 'word four']]);
});
it('should filter 2 keyword if both are set as exceptions', async () => {
const rule = getRuleForSignalTesting(['keyword_as_array']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'excluded',
type: 'wildcard',
value: 'word t*',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 2, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql([
['word eight', 'word nine', 'word ten'],
['word one', 'word two', 'word three', 'word four'],
]);
});
it('should filter 3 keyword if all 3 are set as exceptions', async () => {
const rule = getRuleForSignalTesting(['keyword_as_array']);
const { id } = await createRuleWithExceptionEntries(supertest, log, rule, [
[
{
field: 'keyword',
operator: 'excluded',
type: 'wildcard',
value: 'word *',
},
],
]);
await waitForRuleSuccessOrStatus(supertest, log, id);
await waitForSignalsToBePresent(supertest, log, 3, [id]);
const signalsOpen = await getSignalsById(supertest, log, id);
const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort();
expect(hits).to.eql([
['word eight', 'word nine', 'word ten'],
['word five', null, 'word six', 'word seven'],
['word one', 'word two', 'word three', 'word four'],
]);
});
});
});
};