[Security Solution][Detection Engine] Adds threat matching to the rule creator (#78955) (#79230)

## Summary

This adds threat matching rule type to the rule creator.

Screen shot of creating a threat match

<img width="1023" alt="Screen Shot 2020-09-30 at 3 31 09 PM" src="https://user-images.githubusercontent.com/1151048/94742158-791b1c00-0332-11eb-9d79-78ab431322f0.png">

---

Screen shot of the description after creating one

<img width="1128" alt="Screen Shot 2020-09-30 at 3 29 32 PM" src="https://user-images.githubusercontent.com/1151048/94742203-8b955580-0332-11eb-837f-5b4383044a13.png">

---

Screen shot of first creating a threat match without values filled out

<img width="1017" alt="Screen Shot 2020-09-30 at 3 27 29 PM" src="https://user-images.githubusercontent.com/1151048/94742222-95b75400-0332-11eb-9872-e7670e917941.png">

Additions and bug fixes:
* Changes the threat index to be an array
* Adds a threat_language to the REST schema so that we can use KQL, Lucene, (others in the future)
* Adds plumbing for threat_list to work with the other REST endpoints such as PUT, PATCH, etc...
* Adds the AND, OR dialog and user interface

**Usage**
If you are a team member using the team servers you can skip this usage section of creating threat index. Otherwise if you want to know how to create a mock threat index, instructions are below.

Go to the folder:
```ts
/kibana/x-pack/plugins/security_solution/server/lib/detection_engine/scripts
```

And post a small ECS threat mapping to the index called `mock-threat-list`:
```ts
./create_threat_mapping.sh
```

Then to post a small number of threats that represent simple port numbers you can run:
```ts
./create_threat_data.sh
```

However, feel free to also manually create them directly in your dev tools like so:

```ts
# Posts a threat list item called some-name with an IP but change these out for valid data in your system
PUT mock-threat-list-1/_doc/9999
{
  "@timestamp": "2020-09-09T20:30:45.725Z",
  "host": {
    "name": "some-name",
    "ip": "127.0.0.1"
  }
}
```

```ts
# Posts a destination port number to watch
PUT mock-threat-list-1/_doc/10000
{
  "@timestamp": "2020-09-08T20:30:45.725Z",
  "destination": {
    "port": "443"
  }
}
```

```ts
# Posts a source port number to watch
PUT mock-threat-list-1/_doc/10001
{
  "@timestamp": "2020-09-08T20:30:45.725Z",
  "source": {
    "port": "443"
  }
}
```

### Checklist

- [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)
- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)
- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)
- [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Frank Hassanabad 2020-10-01 19:13:20 -06:00 committed by GitHub
parent 2ea3604243
commit a8938a34bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 3224 additions and 59 deletions

View file

@ -60,7 +60,7 @@ export const getAddPrepackagedThreatMatchRulesSchemaMock = (): AddPrepackagedRul
rule_id: 'rule-1',
version: 1,
threat_query: '*:*',
threat_index: 'list-index',
threat_index: ['list-index'],
threat_mapping: [
{
entries: [
@ -118,7 +118,7 @@ export const getAddPrepackagedThreatMatchRulesSchemaDecodedMock = (): AddPrepack
exceptions_list: [],
rule_id: 'rule-1',
threat_query: '*:*',
threat_index: 'list-index',
threat_index: ['list-index'],
threat_mapping: [
{
entries: [

View file

@ -51,6 +51,7 @@ import {
threat_query,
threat_filters,
threat_mapping,
threat_language,
} from '../types/threat_mapping';
import {
@ -128,6 +129,7 @@ export const addPrepackagedRulesSchema = t.intersection([
threat_mapping, // defaults to "undefined" if not set during decode
threat_query, // defaults to "undefined" if not set during decode
threat_index, // defaults to "undefined" if not set during decode
threat_language, // defaults "undefined" if not set during decode
})
),
]);

View file

@ -66,7 +66,7 @@ export const getCreateThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): CreateRu
language: 'kuery',
rule_id: ruleId,
threat_query: '*:*',
threat_index: 'list-index',
threat_index: ['list-index'],
threat_mapping: [
{
entries: [
@ -124,7 +124,7 @@ export const getCreateThreatMatchRulesSchemaDecodedMock = (): CreateRulesSchemaD
exceptions_list: [],
rule_id: 'rule-1',
threat_query: '*:*',
threat_index: 'list-index',
threat_index: ['list-index'],
threat_mapping: [
{
entries: [

View file

@ -52,6 +52,7 @@ import {
threat_query,
threat_filters,
threat_mapping,
threat_language,
} from '../types/threat_mapping';
import {
@ -124,6 +125,7 @@ export const createRulesSchema = t.intersection([
threat_query, // defaults to "undefined" if not set during decode
threat_filters, // defaults to "undefined" if not set during decode
threat_index, // defaults to "undefined" if not set during decode
threat_language, // defaults "undefined" if not set during decode
})
),
]);

View file

@ -86,7 +86,7 @@ export const getImportThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): ImportRu
risk_score: 55,
language: 'kuery',
rule_id: ruleId,
threat_index: 'index-123',
threat_index: ['index-123'],
threat_mapping: [{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }],
threat_query: '*:*',
threat_filters: [
@ -136,7 +136,7 @@ export const getImportThreatMatchRulesSchemaDecodedMock = (): ImportRulesSchemaD
rule_id: 'rule-1',
immutable: false,
threat_query: '*:*',
threat_index: 'index-123',
threat_index: ['index-123'],
threat_mapping: [
{
entries: [

View file

@ -58,6 +58,7 @@ import {
threat_query,
threat_filters,
threat_mapping,
threat_language,
} from '../types/threat_mapping';
import {
@ -147,6 +148,7 @@ export const importRulesSchema = t.intersection([
threat_mapping, // defaults to "undefined" if not set during decode
threat_query, // defaults to "undefined" if not set during decode
threat_index, // defaults to "undefined" if not set during decode
threat_language, // defaults "undefined" if not set during decode
})
),
]);

View file

@ -48,6 +48,13 @@ import {
severity_mapping,
event_category_override,
} from '../common/schemas';
import {
threat_index,
threat_query,
threat_filters,
threat_mapping,
threat_language,
} from '../types/threat_mapping';
import { listArrayOrUndefined } from '../types/lists';
/**
@ -97,6 +104,11 @@ export const patchRulesSchema = t.exact(
note,
version,
exceptions_list: listArrayOrUndefined,
threat_index,
threat_query,
threat_filters,
threat_mapping,
threat_language,
})
);

View file

@ -49,6 +49,13 @@ import {
SeverityMapping,
event_category_override,
} from '../common/schemas';
import {
threat_index,
threat_query,
threat_filters,
threat_mapping,
threat_language,
} from '../types/threat_mapping';
import {
DefaultStringArray,
@ -122,6 +129,11 @@ export const updateRulesSchema = t.intersection([
note, // defaults to "undefined" if not set during decode
version, // defaults to "undefined" if not set during decode
exceptions_list: DefaultListArray, // defaults to empty array if not set during decode
threat_mapping, // defaults to "undefined" if not set during decode
threat_query, // defaults to "undefined" if not set during decode
threat_filters, // defaults to "undefined" if not set during decode
threat_index, // defaults to "undefined" if not set during decode
threat_language, // defaults "undefined" if not set during decode
})
),
]);

View file

@ -87,7 +87,7 @@ export const getThreatMatchingSchemaMock = (anchorDate: string = ANCHOR_DATE): R
return {
...getRulesSchemaMock(anchorDate),
type: 'threat_match',
threat_index: 'index-123',
threat_index: ['index-123'],
threat_mapping: [{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }],
threat_query: '*:*',
threat_filters: [

View file

@ -626,7 +626,7 @@ describe('rules_schema', () => {
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'invalid keys "threat_index,threat_mapping,[{"entries":[{"field":"host.name","type":"mapping","value":"host.name"}]}],threat_query,threat_filters,[{"bool":{"must":[{"query_string":{"query":"host.name: linux","analyze_wildcard":true,"time_zone":"Zulu"}}],"filter":[],"should":[],"must_not":[]}}]"',
'invalid keys "threat_index,["index-123"],threat_mapping,[{"entries":[{"field":"host.name","type":"mapping","value":"host.name"}]}],threat_query,threat_filters,[{"bool":{"must":[{"query_string":{"query":"host.name: linux","analyze_wildcard":true,"time_zone":"Zulu"}}],"filter":[],"should":[],"must_not":[]}}]"',
]);
expect(message.schema).toEqual({});
});
@ -764,7 +764,7 @@ describe('rules_schema', () => {
test('should return 5 fields for a rule of type "threat_match"', () => {
const fields = addThreatMatchFields({ type: 'threat_match' });
expect(fields.length).toEqual(5);
expect(fields.length).toEqual(6);
});
});

View file

@ -66,6 +66,7 @@ import {
threat_query,
threat_filters,
threat_mapping,
threat_language,
} from '../types/threat_mapping';
import { DefaultListArray } from '../types/lists_default_array';
@ -144,6 +145,7 @@ export const dependentRulesSchema = t.partial({
threat_index,
threat_query,
threat_mapping,
threat_language,
});
/**
@ -277,6 +279,7 @@ export const addThreatMatchFields = (typeAndTimelineOnly: TypeAndTimelineOnly):
t.exact(t.type({ threat_query: dependentRulesSchema.props.threat_query })),
t.exact(t.type({ threat_index: dependentRulesSchema.props.threat_index })),
t.exact(t.type({ threat_mapping: dependentRulesSchema.props.threat_mapping })),
t.exact(t.partial({ threat_language: dependentRulesSchema.props.threat_language })),
t.exact(t.partial({ threat_filters: dependentRulesSchema.props.threat_filters })),
t.exact(t.partial({ saved_id: dependentRulesSchema.props.saved_id })),
];

View file

@ -33,4 +33,5 @@ export * from './positive_integer';
export * from './positive_integer_greater_than_zero';
export * from './references_default_array';
export * from './risk_score';
export * from './threat_mapping';
export * from './uuid';

View file

@ -7,6 +7,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import * as t from 'io-ts';
import { language } from '../common/schemas';
import { NonEmptyString } from './non_empty_string';
export const threat_query = t.string;
@ -19,29 +20,38 @@ export type ThreatFilters = t.TypeOf<typeof threat_filters>;
export const threatFiltersOrUndefined = t.union([threat_filters, t.undefined]);
export type ThreatFiltersOrUndefined = t.TypeOf<typeof threatFiltersOrUndefined>;
export const threatMappingEntries = t.array(
t.exact(
t.type({
field: NonEmptyString,
type: t.keyof({ mapping: null }),
value: NonEmptyString,
})
)
export const threatMapEntry = t.exact(
t.type({
field: NonEmptyString,
type: t.keyof({ mapping: null }),
value: NonEmptyString,
})
);
export type ThreatMapEntry = t.TypeOf<typeof threatMapEntry>;
export const threatMappingEntries = t.array(threatMapEntry);
export type ThreatMappingEntries = t.TypeOf<typeof threatMappingEntries>;
export const threat_mapping = t.array(
t.exact(
t.type({
entries: threatMappingEntries,
})
)
export const threatMap = t.exact(
t.type({
entries: threatMappingEntries,
})
);
export type ThreatMap = t.TypeOf<typeof threatMap>;
export const threat_mapping = t.array(threatMap);
export type ThreatMapping = t.TypeOf<typeof threat_mapping>;
export const threatMappingOrUndefined = t.union([threat_mapping, t.undefined]);
export type ThreatMappingOrUndefined = t.TypeOf<typeof threatMappingOrUndefined>;
export const threat_index = t.string;
export const threat_index = t.array(t.string);
export type ThreatIndex = t.TypeOf<typeof threat_index>;
export const threatIndexOrUndefined = t.union([threat_index, t.undefined]);
export type ThreatIndexOrUndefined = t.TypeOf<typeof threatIndexOrUndefined>;
export const threat_language = t.union([language, t.undefined]);
export type ThreatLanguage = t.TypeOf<typeof threat_language>;
export const threatLanguageOrUndefined = t.union([threat_language, t.undefined]);
export type ThreatLanguageOrUndefined = t.TypeOf<typeof threatLanguageOrUndefined>;

View file

@ -0,0 +1,46 @@
/*
* 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 React from 'react';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { AndBadgeComponent } from './and_badge';
describe('AndBadgeComponent', () => {
test('it renders entryItemIndexItemEntryFirstRowAndBadge for very first item', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<AndBadgeComponent entriesLength={2} entryItemIndex={0} />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeTruthy();
});
test('it renders entryItemEntryInvisibleAndBadge if "entriesLength" is 1 or less', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<AndBadgeComponent entriesLength={1} entryItemIndex={0} />
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="entryItemEntryInvisibleAndBadge"]').exists()
).toBeTruthy();
});
test('it renders regular "and" badge if item is not the first one and includes more than one entry', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<AndBadgeComponent entriesLength={2} entryItemIndex={1} />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="entryItemEntryAndBadge"]').exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 React from 'react';
import { EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { AndOrBadge } from '../and_or_badge';
const MyInvisibleAndBadge = styled(EuiFlexItem)`
visibility: hidden;
`;
const MyFirstRowContainer = styled(EuiFlexItem)`
padding-top: 20px;
`;
interface AndBadgeProps {
entriesLength: number;
entryItemIndex: number;
}
export const AndBadgeComponent = React.memo<AndBadgeProps>(({ entriesLength, entryItemIndex }) => {
const badge = <AndOrBadge includeAntennas type="and" />;
if (entriesLength > 1 && entryItemIndex === 0) {
return (
<MyFirstRowContainer grow={false} data-test-subj="entryItemEntryFirstRowAndBadge">
{badge}
</MyFirstRowContainer>
);
} else if (entriesLength <= 1) {
return (
<MyInvisibleAndBadge grow={false} data-test-subj="entryItemEntryInvisibleAndBadge">
{badge}
</MyInvisibleAndBadge>
);
} else {
return (
<EuiFlexItem grow={false} data-test-subj="entryItemEntryAndBadge">
{badge}
</EuiFlexItem>
);
}
});
AndBadgeComponent.displayName = 'AndBadge';

View file

@ -0,0 +1,123 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { ThreatMappingEntries } from '../../../../common/detection_engine/schemas/types';
import { EntryDeleteButtonComponent } from './entry_delete_button';
const entries: ThreatMappingEntries = [
{
field: 'field.one',
type: 'mapping',
value: 'field.one',
},
];
describe('EntryDeleteButtonComponent', () => {
test('it renders firstRowDeleteButton for very first entry', () => {
const wrapper = mount(
<EntryDeleteButtonComponent
entryIndex={0}
itemIndex={0}
isOnlyItem={false}
entries={entries}
onDelete={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="firstRowDeleteButton"] button')).toHaveLength(1);
});
test('it does not render firstRowDeleteButton if entryIndex is not 0', () => {
const wrapper = mount(
<EntryDeleteButtonComponent
entryIndex={1}
itemIndex={0}
isOnlyItem={false}
entries={entries}
onDelete={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="firstRowDeleteButton"]')).toHaveLength(0);
expect(wrapper.find('[data-test-subj="deleteButton"] button')).toHaveLength(1);
});
test('it does not render firstRowDeleteButton if itemIndex is not 0', () => {
const wrapper = mount(
<EntryDeleteButtonComponent
entryIndex={0}
itemIndex={1}
isOnlyItem={false}
entries={entries}
onDelete={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="firstRowDeleteButton"]')).toHaveLength(0);
expect(wrapper.find('[data-test-subj="deleteButton"] button')).toHaveLength(1);
});
test('it invokes "onDelete" when button is clicked', () => {
const onDelete = jest.fn();
const wrapper = mount(
<EntryDeleteButtonComponent
entryIndex={0}
itemIndex={1}
isOnlyItem={false}
entries={entries}
onDelete={onDelete}
/>
);
wrapper.find('[data-test-subj="deleteButton"] button').simulate('click');
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith(0);
});
test('it disables button if it is the only entry left and no field has been selected', () => {
const emptyEntries: ThreatMappingEntries = [
{
field: '',
type: 'mapping',
value: 'field.one',
},
];
const wrapper = mount(
<EntryDeleteButtonComponent
entryIndex={0}
itemIndex={0}
isOnlyItem
entries={emptyEntries}
onDelete={jest.fn()}
/>
);
const button = wrapper.find('[data-test-subj="firstRowDeleteButton"] button').at(0);
expect(button.prop('disabled')).toBeTruthy();
});
test('it does not disable button if it is the only entry left and field has been selected', () => {
const wrapper = mount(
<EntryDeleteButtonComponent
entryIndex={1}
itemIndex={0}
isOnlyItem
entries={entries}
onDelete={jest.fn()}
/>
);
const button = wrapper.find('[data-test-subj="deleteButton"] button').at(0);
expect(button.prop('disabled')).toBeFalsy();
});
});

View file

@ -0,0 +1,67 @@
/*
* 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 React, { useCallback } from 'react';
import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { Entry } from './types';
const MyFirstRowContainer = styled(EuiFlexItem)`
padding-top: 20px;
`;
interface EntryDeleteButtonProps {
entries: Entry[];
isOnlyItem: boolean;
entryIndex: number;
itemIndex: number;
onDelete: (item: number) => void;
}
export const EntryDeleteButtonComponent = React.memo<EntryDeleteButtonProps>(
({ entries, isOnlyItem, entryIndex, itemIndex, onDelete }) => {
const isDisabled: boolean =
isOnlyItem &&
entries.length === 1 &&
itemIndex === 0 &&
(entries[0].field == null || entries[0].field === '');
const handleDelete = useCallback((): void => {
onDelete(entryIndex);
}, [onDelete, entryIndex]);
const button = (
<EuiButtonIcon
color="danger"
iconType="trash"
onClick={handleDelete}
isDisabled={isDisabled}
aria-label="entryDeleteButton"
className="itemEntryDeleteButton"
data-test-subj="itemEntryDeleteButton"
/>
);
if (entryIndex === 0 && itemIndex === 0) {
// This logic was added to work around it including the field
// labels in centering the delete icon for the first row
return (
<MyFirstRowContainer grow={false} data-test-subj="firstRowDeleteButton">
{button}
</MyFirstRowContainer>
);
} else {
return (
<EuiFlexItem grow={false} data-test-subj="deleteButton">
{button}
</EuiFlexItem>
);
}
}
);
EntryDeleteButtonComponent.displayName = 'EntryDeleteButton';

View file

@ -0,0 +1,130 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { EntryItem } from './entry_item';
import {
fields,
getField,
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { IndexPattern } from 'src/plugins/data/public';
jest.mock('../../../common/lib/kibana');
describe('EntryItem', () => {
test('it renders field labels if "showLabel" is "true"', () => {
const wrapper = mount(
<EntryItem
entry={{
field: undefined,
value: undefined,
type: 'mapping',
entryIndex: 0,
}}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
showLabel={true}
onChange={jest.fn()}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
/>
);
expect(wrapper.find('[data-test-subj="threatFieldInputFormRow"]')).not.toEqual(0);
});
test('it invokes "onChange" when new field is selected and resets value fields', () => {
const mockOnChange = jest.fn();
const wrapper = mount(
<EntryItem
entry={{
field: getField('ip'),
type: 'mapping',
value: getField('ip'),
entryIndex: 0,
}}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
showLabel={false}
onChange={mockOnChange}
/>
);
((wrapper.find(EuiComboBox).at(0).props() as unknown) as {
onChange: (a: EuiComboBoxOptionOption[]) => void;
}).onChange([{ label: 'machine.os' }]);
expect(mockOnChange).toHaveBeenCalledWith(
{
field: 'machine.os',
type: 'mapping',
value: 'ip',
},
0
);
});
test('it invokes "onChange" when new value is selected', () => {
const mockOnChange = jest.fn();
const wrapper = mount(
<EntryItem
entry={{
field: getField('ip'),
type: 'mapping',
value: getField('ip'),
entryIndex: 0,
}}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
showLabel={false}
onChange={mockOnChange}
/>
);
((wrapper.find(EuiComboBox).at(1).props() as unknown) as {
onChange: (a: EuiComboBoxOptionOption[]) => void;
}).onChange([{ label: 'is not' }]);
expect(mockOnChange).toHaveBeenCalledWith({ field: 'ip', type: 'mapping', value: '' }, 0);
});
});

View file

@ -0,0 +1,131 @@
/*
* 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 React, { useCallback, useMemo } from 'react';
import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { IFieldType, IndexPattern } from '../../../../../../../src/plugins/data/common';
import { FieldComponent } from '../autocomplete/field';
import { FormattedEntry, Entry } from './types';
import * as i18n from './translations';
import { getEntryOnFieldChange, getEntryOnThreatFieldChange } from './helpers';
interface EntryItemProps {
entry: FormattedEntry;
indexPattern: IndexPattern;
threatIndexPatterns: IndexPattern;
showLabel: boolean;
onChange: (arg: Entry, i: number) => void;
}
const FlexItemWithLabel = styled(EuiFlexItem)`
padding-top: 20px;
text-align: center;
`;
const FlexItemWithoutLabel = styled(EuiFlexItem)`
text-align: center;
`;
export const EntryItem: React.FC<EntryItemProps> = ({
entry,
indexPattern,
threatIndexPatterns,
showLabel,
onChange,
}): JSX.Element => {
const handleFieldChange = useCallback(
([newField]: IFieldType[]): void => {
const { updatedEntry, index } = getEntryOnFieldChange(entry, newField);
onChange(updatedEntry, index);
},
[onChange, entry]
);
const handleThreatFieldChange = useCallback(
([newField]: IFieldType[]): void => {
const { updatedEntry, index } = getEntryOnThreatFieldChange(entry, newField);
onChange(updatedEntry, index);
},
[onChange, entry]
);
const renderFieldInput = useMemo(() => {
const comboBox = (
<FieldComponent
placeholder={i18n.FIELD_PLACEHOLDER}
indexPattern={indexPattern}
selectedField={entry.field}
isClearable={false}
isLoading={false}
isDisabled={indexPattern == null}
onChange={handleFieldChange}
data-test-subj="entryField"
fieldInputWidth={360}
/>
);
if (showLabel) {
return (
<EuiFormRow label={i18n.FIELD} data-test-subj="entryItemFieldInputFormRow">
{comboBox}
</EuiFormRow>
);
} else {
return comboBox;
}
}, [handleFieldChange, indexPattern, entry, showLabel]);
const renderThreatFieldInput = useMemo(() => {
const comboBox = (
<FieldComponent
placeholder={i18n.FIELD_PLACEHOLDER}
indexPattern={threatIndexPatterns}
selectedField={entry.value}
isClearable={false}
isLoading={false}
isDisabled={threatIndexPatterns == null}
onChange={handleThreatFieldChange}
data-test-subj="threatEntryField"
fieldInputWidth={360}
/>
);
if (showLabel) {
return (
<EuiFormRow label={i18n.THREAT_FIELD} data-test-subj="threatFieldInputFormRow">
{comboBox}
</EuiFormRow>
);
} else {
return comboBox;
}
}, [handleThreatFieldChange, threatIndexPatterns, entry, showLabel]);
return (
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
justifyContent="spaceAround"
data-test-subj="itemEntryContainer"
>
<EuiFlexItem grow={false}>{renderFieldInput}</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiFlexGroup justifyContent="spaceAround" alignItems="center">
{showLabel ? (
<FlexItemWithLabel grow={true}>{i18n.MATCHES}</FlexItemWithLabel>
) : (
<FlexItemWithoutLabel grow={true}>{i18n.MATCHES}</FlexItemWithoutLabel>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>{renderThreatFieldInput}</EuiFlexItem>
</EuiFlexGroup>
);
};
EntryItem.displayName = 'EntryItem';

View file

@ -0,0 +1,225 @@
/*
* 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 {
fields,
getField,
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { Entry, EmptyEntry, ThreatMapEntries, FormattedEntry } from './types';
import { IndexPattern } from '../../../../../../../src/plugins/data/common';
import moment from 'moment-timezone';
import {
filterItems,
getEntryOnFieldChange,
getFormattedEntries,
getFormattedEntry,
getUpdatedEntriesOnDelete,
} from './helpers';
import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types';
const getMockIndexPattern = (): IndexPattern =>
({
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern);
const getMockEntry = (): FormattedEntry => ({
field: getField('ip'),
value: getField('ip'),
type: 'mapping',
entryIndex: 0,
});
describe('Helpers', () => {
beforeEach(() => {
moment.tz.setDefault('UTC');
});
afterEach(() => {
moment.tz.setDefault('Browser');
});
describe('#getFormattedEntry', () => {
test('it returns entry with a value when "item.field" is of type "text" and matching keyword field exists', () => {
const payloadIndexPattern: IndexPattern = {
...getMockIndexPattern(),
fields: [
...fields,
{
name: 'machine.os.raw.text',
type: 'string',
esTypes: ['text'],
count: 0,
scripted: false,
searchable: false,
aggregatable: false,
readFromDocValues: true,
},
],
} as IndexPattern;
const payloadItem: Entry = {
field: 'machine.os.raw.text',
type: 'mapping',
value: 'some os',
};
const output = getFormattedEntry(payloadIndexPattern, payloadItem, 0);
const expected: FormattedEntry = {
entryIndex: 0,
field: {
name: 'machine.os.raw.text',
type: 'string',
esTypes: ['text'],
count: 0,
scripted: false,
searchable: false,
aggregatable: false,
readFromDocValues: true,
},
type: 'mapping',
value: undefined,
};
expect(output).toEqual(expected);
});
});
describe('#getFormattedEntries', () => {
test('it returns formatted entry with fields undefined if it unable to find a matching index pattern field', () => {
const payloadIndexPattern: IndexPattern = getMockIndexPattern();
const payloadItems: Entry[] = [{ field: 'field.one', type: 'mapping', value: 'field.one' }];
const output = getFormattedEntries(payloadIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
entryIndex: 0,
field: undefined,
value: undefined,
type: 'mapping',
},
];
expect(output).toEqual(expected);
});
test('it returns formatted entries', () => {
const payloadIndexPattern: IndexPattern = getMockIndexPattern();
const payloadItems: Entry[] = [
{ field: 'machine.os', type: 'mapping', value: 'machine.os' },
{ field: 'ip', type: 'mapping', value: 'ip' },
];
const output = getFormattedEntries(payloadIndexPattern, payloadItems);
const expected: FormattedEntry[] = [
{
field: {
name: 'machine.os',
type: 'string',
esTypes: ['text'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: false,
},
type: 'mapping',
value: {
name: 'machine.os',
type: 'string',
esTypes: ['text'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: false,
},
entryIndex: 0,
},
{
field: {
name: 'ip',
type: 'ip',
esTypes: ['ip'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
type: 'mapping',
value: {
name: 'ip',
type: 'ip',
esTypes: ['ip'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
entryIndex: 1,
},
];
expect(output).toEqual(expected);
});
});
describe('#getUpdatedEntriesOnDelete', () => {
test('it removes entry corresponding to "entryIndex"', () => {
const payloadItem: ThreatMapEntries = {
entries: [
{ field: 'field.one', type: 'mapping', value: 'field.one' },
{ field: 'field.two', type: 'mapping', value: 'field.two' },
],
};
const output = getUpdatedEntriesOnDelete(payloadItem, 0);
const expected: ThreatMapEntries = {
entries: [
{
field: 'field.two',
type: 'mapping',
value: 'field.two',
},
],
};
expect(output).toEqual(expected);
});
});
describe('#getEntryOnFieldChange', () => {
test('it returns field of type "match" with updated field', () => {
const payloadItem = getMockEntry();
const payloadIFieldType = getField('ip');
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
const expected: { updatedEntry: Entry; index: number } = {
index: 0,
updatedEntry: {
field: 'ip',
type: 'mapping',
value: 'ip',
},
};
expect(output).toEqual(expected);
});
});
describe('#filterItems', () => {
test('it removes entry items with "value" of "undefined"', () => {
const entry: ThreatMapEntry = { field: 'host.name', type: 'mapping', value: 'host.name' };
const mockEmpty: EmptyEntry = {
field: 'host.name',
type: 'mapping',
value: undefined,
};
const items = filterItems([
{
entries: [entry],
},
{
entries: [mockEmpty],
},
]);
expect(items).toEqual([{ entries: [entry] }]);
});
});
});

View file

@ -0,0 +1,171 @@
/*
* 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 {
ThreatMap,
threatMap,
ThreatMapping,
} from '../../../../common/detection_engine/schemas/types';
import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common';
import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types';
/**
* Formats the entry into one that is easily usable for the UI.
*
* @param patterns IndexPattern containing available fields on rule index
* @param item item entry
* @param itemIndex entry index
*/
export const getFormattedEntry = (
indexPattern: IndexPattern,
item: Entry,
itemIndex: number
): FormattedEntry => {
const { fields } = indexPattern;
const field = item.field;
const threatField = item.value;
const [foundField] = fields.filter(({ name }) => field != null && field === name);
const [threatFoundField] = fields.filter(
({ name }) => threatField != null && threatField === name
);
return {
field: foundField,
type: 'mapping',
value: threatFoundField,
entryIndex: itemIndex,
};
};
/**
* Formats the entries to be easily usable for the UI
*
* @param patterns IndexPattern containing available fields on rule index
* @param entries item entries
*/
export const getFormattedEntries = (
indexPattern: IndexPattern,
entries: Entry[]
): FormattedEntry[] => {
return entries.reduce<FormattedEntry[]>((acc, item, index) => {
const newItemEntry = getFormattedEntry(indexPattern, item, index);
return [...acc, newItemEntry];
}, []);
};
/**
* Determines whether an entire entry or item need to be removed
*
* @param item
* @param entryIndex index of given entry
*
*/
export const getUpdatedEntriesOnDelete = (
item: ThreatMapEntries,
entryIndex: number
): ThreatMapEntries => {
return {
...item,
entries: [...item.entries.slice(0, entryIndex), ...item.entries.slice(entryIndex + 1)],
};
};
/**
* Determines proper entry update when user selects new field
*
* @param item - current item entry values
* @param newField - newly selected field
*
*/
export const getEntryOnFieldChange = (
item: FormattedEntry,
newField: IFieldType
): { updatedEntry: Entry; index: number } => {
const { entryIndex } = item;
return {
updatedEntry: {
field: newField != null ? newField.name : '',
type: 'mapping',
value: item.value != null ? item.value.name : '',
},
index: entryIndex,
};
};
/**
* Determines proper entry update when user selects new field
*
* @param item - current item entry values
* @param newField - newly selected field
*
*/
export const getEntryOnThreatFieldChange = (
item: FormattedEntry,
newField: IFieldType
): { updatedEntry: Entry; index: number } => {
const { entryIndex } = item;
return {
updatedEntry: {
field: item.field != null ? item.field.name : '',
type: 'mapping',
value: newField != null ? newField.name : '',
},
index: entryIndex,
};
};
export const getDefaultEmptyEntry = (): EmptyEntry => ({
field: '',
type: 'mapping',
value: '',
});
export const getNewItem = (): ThreatMap => {
return {
entries: [
{
field: '',
type: 'mapping',
value: '',
},
],
};
};
export const filterItems = (items: ThreatMapEntries[]): ThreatMapping => {
return items.reduce<ThreatMapping>((acc, item) => {
const newItem = { ...item, entries: item.entries };
if (threatMap.is(newItem)) {
return [...acc, newItem];
} else {
return acc;
}
}, []);
};
/**
* Given a list of items checks each one to see if any of them have an empty field
* or an empty value.
* @param items The items to check if we have an empty entries.
*/
export const containsInvalidItems = (items: ThreatMapEntries[]): boolean => {
return items.some((item) =>
item.entries.some((subEntry) => subEntry.field === '' || subEntry.value === '')
);
};
/**
* Given a list of items checks if we have a single empty entry and if we do returns true.
* @param items The items to check if we have a single empty entry.
*/
export const singleEntryThreat = (items: ThreatMapEntries[]): boolean => {
return (
items.length === 1 &&
items[0].entries.length === 1 &&
items[0].entries[0].field === '' &&
items[0].entries[0].value === ''
);
};

View file

@ -0,0 +1,304 @@
/*
* 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 React from 'react';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { waitFor } from '@testing-library/react';
import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { useKibana } from '../../../common/lib/kibana';
import { ThreatMatchComponent } from './';
import { ThreatMapEntries } from './types';
import { IndexPattern } from 'src/plugins/data/public';
jest.mock('../../../common/lib/kibana');
const getPayLoad = (): ThreatMapEntries[] => [
{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] },
];
const getDoublePayLoad = (): ThreatMapEntries[] => [
{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] },
{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] },
];
describe('ThreatMatchComponent', () => {
const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']);
beforeEach(() => {
(useKibana as jest.Mock).mockReturnValue({
services: {
data: {
autocomplete: {
getValueSuggestions: getValueSuggestionsMock,
},
},
},
});
});
afterEach(() => {
getValueSuggestionsMock.mockClear();
});
test('it displays empty entry if no "listItems" are passed in', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ThreatMatchComponent
listItems={[]}
indexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
onChange={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiFlexGroup[data-test-subj="itemEntryContainer"]')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="entryField"]').text()).toEqual('Search');
expect(wrapper.find('[data-test-subj="threatEntryField"]').text()).toEqual('Search');
});
test('it displays "Search" for "listItems" that are passed in', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ThreatMatchComponent
listItems={getPayLoad()}
indexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
onChange={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiFlexGroup[data-test-subj="itemEntryContainer"]')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="entryField"]').at(0).text()).toEqual('Search');
wrapper.unmount();
});
test('it displays "or", "and" enabled', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ThreatMatchComponent
listItems={[]}
indexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
onChange={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="andButton"] button').prop('disabled')).toBeFalsy();
expect(wrapper.find('[data-test-subj="orButton"] button').prop('disabled')).toBeFalsy();
});
test('it adds an entry when "and" clicked', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ThreatMatchComponent
listItems={[]}
indexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
onChange={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiFlexGroup[data-test-subj="itemEntryContainer"]')).toHaveLength(1);
wrapper.find('[data-test-subj="andButton"] button').simulate('click');
await waitFor(() => {
expect(wrapper.find('EuiFlexGroup[data-test-subj="itemEntryContainer"]')).toHaveLength(2);
expect(wrapper.find('[data-test-subj="entryField"]').at(0).text()).toEqual('Search');
expect(wrapper.find('[data-test-subj="threatEntryField"]').at(0).text()).toEqual('Search');
expect(wrapper.find('[data-test-subj="entryField"]').at(1).text()).toEqual('Search');
expect(wrapper.find('[data-test-subj="threatEntryField"]').at(1).text()).toEqual('Search');
});
});
test('it adds an item when "or" clicked', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ThreatMatchComponent
listItems={[]}
indexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
onChange={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiFlexGroup[data-test-subj="entriesContainer"]')).toHaveLength(1);
wrapper.find('[data-test-subj="orButton"] button').simulate('click');
await waitFor(() => {
expect(wrapper.find('EuiFlexGroup[data-test-subj="entriesContainer"]')).toHaveLength(2);
expect(wrapper.find('[data-test-subj="entryField"]').at(0).text()).toEqual('Search');
expect(wrapper.find('[data-test-subj="threatEntryField"]').at(0).text()).toEqual('Search');
expect(wrapper.find('[data-test-subj="entryField"]').at(1).text()).toEqual('Search');
expect(wrapper.find('[data-test-subj="threatEntryField"]').at(1).text()).toEqual('Search');
});
});
test('it removes one row if user deletes a row', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ThreatMatchComponent
listItems={getDoublePayLoad()}
indexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
onChange={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="entriesContainer"]').length).toEqual(4);
wrapper.find('[data-test-subj="firstRowDeleteButton"] button').simulate('click');
expect(wrapper.find('[data-test-subj="entriesContainer"]').length).toEqual(2);
wrapper.unmount();
});
test('it displays "and" badge if at least one item includes more than one entry', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ThreatMatchComponent
listItems={[]}
indexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
onChange={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeFalsy();
wrapper.find('[data-test-subj="andButton"] button').simulate('click');
expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeTruthy();
});
test('it does not display "and" badge if none of the items include more than one entry', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ThreatMatchComponent
listItems={[]}
indexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
onChange={jest.fn()}
/>
</ThemeProvider>
);
wrapper.find('[data-test-subj="orButton"] button').simulate('click');
expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeFalsy();
wrapper.find('[data-test-subj="orButton"] button').simulate('click');
expect(wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()).toBeFalsy();
});
});

View file

@ -0,0 +1,220 @@
/*
* 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 React, { useCallback, useEffect, useReducer } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { ThreatMapping } from '../../../../common/detection_engine/schemas/types';
import { ListItemComponent } from './list_item';
import { IndexPattern } from '../../../../../../../src/plugins/data/common';
import { AndOrBadge } from '../and_or_badge';
import { LogicButtons } from './logic_buttons';
import { ThreatMapEntries } from './types';
import { State, reducer } from './reducer';
import { getDefaultEmptyEntry, getNewItem, filterItems } from './helpers';
const MyInvisibleAndBadge = styled(EuiFlexItem)`
visibility: hidden;
`;
const MyAndBadge = styled(AndOrBadge)`
& > .euiFlexItem {
margin: 0;
}
`;
const MyButtonsContainer = styled(EuiFlexItem)`
margin: 16px 0;
`;
const initialState: State = {
andLogicIncluded: false,
entries: [],
entriesToDelete: [],
};
interface OnChangeProps {
entryItems: ThreatMapping;
entriesToDelete: ThreatMapEntries[];
}
interface ThreatMatchComponentProps {
listItems: ThreatMapEntries[];
indexPatterns: IndexPattern;
threatIndexPatterns: IndexPattern;
onChange: (arg: OnChangeProps) => void;
}
export const ThreatMatchComponent = ({
listItems,
indexPatterns,
threatIndexPatterns,
onChange,
}: ThreatMatchComponentProps) => {
const [{ entries, entriesToDelete, andLogicIncluded }, dispatch] = useReducer(reducer(), {
...initialState,
});
const setUpdateEntries = useCallback(
(items: ThreatMapEntries[]): void => {
dispatch({
type: 'setEntries',
entries: items,
});
},
[dispatch]
);
const setDefaultEntries = useCallback(
(item: ThreatMapEntries): void => {
dispatch({
type: 'setDefault',
initialState,
lastEntry: item,
});
},
[dispatch]
);
const handleEntryItemChange = useCallback(
(item: ThreatMapEntries, index: number): void => {
const updatedEntries = [
...entries.slice(0, index),
{
...item,
},
...entries.slice(index + 1),
];
setUpdateEntries(updatedEntries);
},
[setUpdateEntries, entries]
);
const handleDeleteEntryItem = useCallback(
(item: ThreatMapEntries, itemIndex: number): void => {
if (item.entries.length === 0) {
const updatedEntries = [...entries.slice(0, itemIndex), ...entries.slice(itemIndex + 1)];
// if it's the only item left, don't delete it just add a default entry to it
if (updatedEntries.length === 0) {
setDefaultEntries(item);
} else {
setUpdateEntries([...entries.slice(0, itemIndex), ...entries.slice(itemIndex + 1)]);
}
} else {
handleEntryItemChange(item, itemIndex);
}
},
[handleEntryItemChange, setUpdateEntries, entries, setDefaultEntries]
);
const handleAddNewEntryItemEntry = useCallback((): void => {
const lastEntry = entries[entries.length - 1];
const { entries: innerEntries } = lastEntry;
const updatedEntry: ThreatMapEntries = {
...lastEntry,
entries: [...innerEntries, getDefaultEmptyEntry()],
};
setUpdateEntries([...entries.slice(0, entries.length - 1), { ...updatedEntry }]);
}, [setUpdateEntries, entries]);
const handleAddNewEntryItem = useCallback((): void => {
// There is a case where there are numerous list items, all with
// empty `entries` array.
const newItem = getNewItem();
setUpdateEntries([...entries, { ...newItem }]);
}, [setUpdateEntries, entries]);
const handleAddClick = useCallback((): void => {
handleAddNewEntryItemEntry();
}, [handleAddNewEntryItemEntry]);
// Bubble up changes to parent
useEffect(() => {
onChange({ entryItems: filterItems(entries), entriesToDelete });
}, [onChange, entriesToDelete, entries]);
// Defaults to never be sans entry, instead
// always falls back to an empty entry if user deletes all
useEffect(() => {
if (
entries.length === 0 ||
(entries.length === 1 && entries[0].entries != null && entries[0].entries.length === 0)
) {
handleAddNewEntryItem();
}
}, [entries, handleAddNewEntryItem]);
useEffect(() => {
if (listItems.length > 0) {
setUpdateEntries(listItems);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<EuiFlexGroup gutterSize="s" direction="column">
{entries.map((entryListItem, index) => (
<EuiFlexItem grow={1} key={`${index}`}>
<EuiFlexGroup gutterSize="s" direction="column">
{index !== 0 &&
(andLogicIncluded ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="none" direction="row">
<MyInvisibleAndBadge grow={false}>
<MyAndBadge includeAntennas type="and" />
</MyInvisibleAndBadge>
<EuiFlexItem grow={false}>
<MyAndBadge type="or" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : (
<EuiFlexItem grow={false}>
<MyAndBadge type="or" />
</EuiFlexItem>
))}
<EuiFlexItem grow={false}>
<ListItemComponent
key={`${index}`}
listItem={entryListItem}
listId={`${index}`}
indexPattern={indexPatterns}
threatIndexPatterns={threatIndexPatterns}
listItemIndex={index}
andLogicIncluded={andLogicIncluded}
isOnlyItem={entries.length === 1}
onDeleteEntryItem={handleDeleteEntryItem}
onChangeEntryItem={handleEntryItemChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
))}
<MyButtonsContainer data-test-subj={'andOrOperatorButtons'}>
<EuiFlexGroup gutterSize="s">
{andLogicIncluded && (
<MyInvisibleAndBadge grow={false}>
<AndOrBadge includeAntennas type="and" />
</MyInvisibleAndBadge>
)}
<EuiFlexItem grow={1}>
<LogicButtons
isOrDisabled={false}
isAndDisabled={false}
onOrClicked={handleAddNewEntryItem}
onAndClicked={handleAddClick}
/>
</EuiFlexItem>
</EuiFlexGroup>
</MyButtonsContainer>
</EuiFlexGroup>
);
};
ThreatMatchComponent.displayName = 'ThreatMatch';

View file

@ -0,0 +1,382 @@
/*
* 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 React from 'react';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { useKibana } from '../../../common/lib/kibana';
import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { ListItemComponent } from './list_item';
import { ThreatMapEntries } from './types';
import { IndexPattern } from 'src/plugins/data/public';
jest.mock('../../../common/lib/kibana');
const singlePayload = (): ThreatMapEntries => ({
entries: [
{
field: 'field.one',
type: 'mapping',
value: 'field.one',
},
],
});
const doublePayload = (): ThreatMapEntries => ({
entries: [
{
field: 'field.one',
type: 'mapping',
value: 'field.one',
},
{
field: 'field.two',
type: 'mapping',
value: 'field.two',
},
],
});
describe('ListItemComponent', () => {
const getValueSuggestionsMock = jest.fn().mockResolvedValue(['field.one', 'field.two']);
beforeAll(() => {
(useKibana as jest.Mock).mockReturnValue({
services: {
data: {
autocomplete: {
getValueSuggestions: getValueSuggestionsMock,
},
},
},
});
});
afterEach(() => {
getValueSuggestionsMock.mockClear();
});
describe('and badge logic', () => {
test('it renders "and" badge with extra top padding for the first item when "andLogicIncluded" is "true"', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ListItemComponent
listItem={doublePayload()}
listId={'123'}
listItemIndex={0}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={true}
isOnlyItem={false}
onDeleteEntryItem={jest.fn()}
onChangeEntryItem={jest.fn()}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()
).toBeTruthy();
});
test('it renders "and" badge when more than one item entry exists and it is not the first item', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ListItemComponent
listItem={doublePayload()}
listId={'123'}
listItemIndex={1}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={true}
isOnlyItem={false}
onDeleteEntryItem={jest.fn()}
onChangeEntryItem={jest.fn()}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="entryItemEntryAndBadge"]').exists()).toBeTruthy();
});
test('it renders indented "and" badge when "andLogicIncluded" is "true" and only one entry exists', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ListItemComponent
listItem={singlePayload()}
listId={'123'}
listItemIndex={1}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={true}
isOnlyItem={false}
onDeleteEntryItem={jest.fn()}
onChangeEntryItem={jest.fn()}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="entryItemEntryInvisibleAndBadge"]').exists()
).toBeTruthy();
});
test('it renders no "and" badge when "andLogicIncluded" is "false"', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ListItemComponent
listItem={singlePayload()}
listId={'123'}
listItemIndex={1}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={false}
isOnlyItem={false}
onDeleteEntryItem={jest.fn()}
onChangeEntryItem={jest.fn()}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="entryItemEntryInvisibleAndBadge"]').exists()
).toBeFalsy();
expect(wrapper.find('[data-test-subj="entryItemEntryAndBadge"]').exists()).toBeFalsy();
expect(
wrapper.find('[data-test-subj="entryItemEntryFirstRowAndBadge"]').exists()
).toBeFalsy();
});
});
describe('delete button logic', () => {
test('it renders delete button disabled when it is only entry left', () => {
const item: ThreatMapEntries = {
entries: [{ ...singlePayload(), field: '', type: 'mapping', value: '' }],
};
const wrapper = mount(
<ListItemComponent
listItem={item}
listId={'123'}
listItemIndex={0}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={false}
isOnlyItem={true}
onDeleteEntryItem={jest.fn()}
onChangeEntryItem={jest.fn()}
/>
);
expect(
wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').props().disabled
).toBeTruthy();
});
test('it does not render delete button disabled when it is not the only entry left', () => {
const wrapper = mount(
<ListItemComponent
listItem={singlePayload()}
listId={'123'}
listItemIndex={0}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={false}
isOnlyItem={false}
onDeleteEntryItem={jest.fn()}
onChangeEntryItem={jest.fn()}
/>
);
expect(
wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').props().disabled
).toBeFalsy();
});
test('it does not render delete button disabled when "entryItemIndex" is not "0"', () => {
const wrapper = mount(
<ListItemComponent
listItem={singlePayload()}
listId={'123'}
listItemIndex={1}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={false}
// if entryItemIndex is not 0, wouldn't make sense for
// this to be true, but done for testing purposes
isOnlyItem={true}
onDeleteEntryItem={jest.fn()}
onChangeEntryItem={jest.fn()}
/>
);
expect(
wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').props().disabled
).toBeFalsy();
});
test('it does not render delete button disabled when more than one entry exists', () => {
const wrapper = mount(
<ListItemComponent
listItem={doublePayload()}
listId={'123'}
listItemIndex={0}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={false}
isOnlyItem={true}
onDeleteEntryItem={jest.fn()}
onChangeEntryItem={jest.fn()}
/>
);
expect(
wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').at(0).props().disabled
).toBeFalsy();
});
test('it invokes "onChangeEntryItem" when delete button clicked', () => {
const mockOnDeleteEntryItem = jest.fn();
const wrapper = mount(
<ListItemComponent
listItem={doublePayload()}
listId={'123'}
listItemIndex={0}
indexPattern={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
threatIndexPatterns={
{
id: '1234',
title: 'logstash-*',
fields,
} as IndexPattern
}
andLogicIncluded={false}
isOnlyItem={true}
onDeleteEntryItem={mockOnDeleteEntryItem}
onChangeEntryItem={jest.fn()}
/>
);
wrapper.find('[data-test-subj="itemEntryDeleteButton"] button').at(0).simulate('click');
const expected: ThreatMapEntries = {
entries: [
{
field: 'field.two',
type: 'mapping',
value: 'field.two',
},
],
};
expect(mockOnDeleteEntryItem).toHaveBeenCalledWith(expected, 0);
});
});
});

View file

@ -0,0 +1,120 @@
/*
* 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 React, { useMemo, useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { IndexPattern } from '../../../../../../../src/plugins/data/common';
import { getFormattedEntries, getUpdatedEntriesOnDelete } from './helpers';
import { FormattedEntry, ThreatMapEntries, Entry } from './types';
import { EntryItem } from './entry_item';
import { EntryDeleteButtonComponent } from './entry_delete_button';
import { AndBadgeComponent } from './and_badge';
const MyOverflowContainer = styled(EuiFlexItem)`
overflow: hidden;
width: 100%;
`;
interface ListItemProps {
listItem: ThreatMapEntries;
listId: string;
listItemIndex: number;
indexPattern: IndexPattern;
threatIndexPatterns: IndexPattern;
andLogicIncluded: boolean;
isOnlyItem: boolean;
onDeleteEntryItem: (item: ThreatMapEntries, index: number) => void;
onChangeEntryItem: (item: ThreatMapEntries, index: number) => void;
}
export const ListItemComponent = React.memo<ListItemProps>(
({
listItem,
listId,
listItemIndex,
indexPattern,
threatIndexPatterns,
isOnlyItem,
andLogicIncluded,
onDeleteEntryItem,
onChangeEntryItem,
}) => {
const handleEntryChange = useCallback(
(entry: Entry, entryIndex: number): void => {
const updatedEntries: Entry[] = [
...listItem.entries.slice(0, entryIndex),
{ ...entry },
...listItem.entries.slice(entryIndex + 1),
];
const updatedEntryItem: ThreatMapEntries = {
...listItem,
entries: updatedEntries,
};
onChangeEntryItem(updatedEntryItem, listItemIndex);
},
[onChangeEntryItem, listItem, listItemIndex]
);
const handleDeleteEntry = useCallback(
(entryIndex: number): void => {
const updatedEntryItem = getUpdatedEntriesOnDelete(listItem, entryIndex);
onDeleteEntryItem(updatedEntryItem, listItemIndex);
},
[listItem, onDeleteEntryItem, listItemIndex]
);
const entries = useMemo(
(): FormattedEntry[] =>
indexPattern != null && listItem.entries.length > 0
? getFormattedEntries(indexPattern, listItem.entries)
: [],
[listItem.entries, indexPattern]
);
return (
<EuiFlexItem>
<EuiFlexGroup gutterSize="s" data-test-subj="entriesContainer">
{andLogicIncluded && (
<AndBadgeComponent
entriesLength={listItem.entries.length}
entryItemIndex={listItemIndex}
/>
)}
<MyOverflowContainer grow={6}>
<EuiFlexGroup gutterSize="s" direction="column">
{entries.map((item, index) => (
<EuiFlexItem key={`${listId}-${index}`} grow={1}>
<EuiFlexGroup gutterSize="xs" alignItems="center" direction="row">
<MyOverflowContainer grow={1}>
<EntryItem
entry={item}
threatIndexPatterns={threatIndexPatterns}
indexPattern={indexPattern}
showLabel={listItemIndex === 0 && index === 0}
onChange={handleEntryChange}
/>
</MyOverflowContainer>
<EntryDeleteButtonComponent
entries={listItem.entries}
isOnlyItem={isOnlyItem}
entryIndex={item.entryIndex}
itemIndex={listItemIndex}
onDelete={handleDeleteEntry}
/>
</EuiFlexGroup>
</EuiFlexItem>
))}
</EuiFlexGroup>
</MyOverflowContainer>
</EuiFlexGroup>
</EuiFlexItem>
);
}
);
ListItemComponent.displayName = 'ListItem';

View file

@ -0,0 +1,49 @@
/*
* 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 { storiesOf, addDecorator } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { LogicButtons } from './logic_buttons';
addDecorator((storyFn) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>
));
storiesOf('ThreatMatching|LogicButtons', module)
.add('and/or buttons', () => {
return (
<LogicButtons
isAndDisabled={false}
isOrDisabled={false}
onOrClicked={action('onClick')}
onAndClicked={action('onClick')}
/>
);
})
.add('and disabled', () => {
return (
<LogicButtons
isAndDisabled
isOrDisabled={false}
onOrClicked={action('onClick')}
onAndClicked={action('onClick')}
/>
);
})
.add('or disabled', () => {
return (
<LogicButtons
isAndDisabled={false}
isOrDisabled
onOrClicked={action('onClick')}
onAndClicked={action('onClick')}
/>
);
});

View file

@ -0,0 +1,90 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { LogicButtons } from './logic_buttons';
describe('LogicButtons', () => {
test('it renders "and" and "or" buttons', () => {
const wrapper = mount(
<LogicButtons
isAndDisabled={false}
isOrDisabled={false}
onOrClicked={jest.fn()}
onAndClicked={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="andButton"] button')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="orButton"] button')).toHaveLength(1);
});
test('it invokes "onOrClicked" when "or" button is clicked', () => {
const onOrClicked = jest.fn();
const wrapper = mount(
<LogicButtons
isAndDisabled={false}
isOrDisabled={false}
onOrClicked={onOrClicked}
onAndClicked={jest.fn()}
/>
);
wrapper.find('[data-test-subj="orButton"] button').simulate('click');
expect(onOrClicked).toHaveBeenCalledTimes(1);
});
test('it invokes "onAndClicked" when "and" button is clicked', () => {
const onAndClicked = jest.fn();
const wrapper = mount(
<LogicButtons
isAndDisabled={false}
isOrDisabled={false}
onOrClicked={jest.fn()}
onAndClicked={onAndClicked}
/>
);
wrapper.find('[data-test-subj="andButton"] button').simulate('click');
expect(onAndClicked).toHaveBeenCalledTimes(1);
});
test('it disables "and" button if "isAndDisabled" is true', () => {
const wrapper = mount(
<LogicButtons
isOrDisabled={false}
isAndDisabled
onOrClicked={jest.fn()}
onAndClicked={jest.fn()}
/>
);
const andButton = wrapper.find('[data-test-subj="andButton"] button').at(0);
expect(andButton.prop('disabled')).toBeTruthy();
});
test('it disables "or" button if "isOrDisabled" is "true"', () => {
const wrapper = mount(
<LogicButtons
isOrDisabled
isAndDisabled={false}
onOrClicked={jest.fn()}
onAndClicked={jest.fn()}
/>
);
const orButton = wrapper.find('[data-test-subj="orButton"] button').at(0);
expect(orButton.prop('disabled')).toBeTruthy();
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
import styled from 'styled-components';
import * as i18n from './translations';
const MyEuiButton = styled(EuiButton)`
min-width: 95px;
`;
interface LogicButtonsProps {
isOrDisabled: boolean;
isAndDisabled: boolean;
onAndClicked: () => void;
onOrClicked: () => void;
}
export const LogicButtons: React.FC<LogicButtonsProps> = ({
isOrDisabled = false,
isAndDisabled = false,
onAndClicked,
onOrClicked,
}) => (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<MyEuiButton
fill
size="s"
iconType="plusInCircle"
onClick={onAndClicked}
data-test-subj="andButton"
isDisabled={isAndDisabled}
>
{i18n.AND}
</MyEuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MyEuiButton
fill
size="s"
iconType="plusInCircle"
onClick={onOrClicked}
isDisabled={isOrDisabled}
data-test-subj="orButton"
>
{i18n.OR}
</MyEuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -0,0 +1,101 @@
/*
* 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 { ThreatMapEntries } from './types';
import { State, reducer } from './reducer';
import { getDefaultEmptyEntry } from './helpers';
import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types';
const initialState: State = {
andLogicIncluded: false,
entries: [],
entriesToDelete: [],
};
const getEntry = (): ThreatMapEntry => ({
field: 'host.name',
type: 'mapping',
value: 'host.name',
});
describe('reducer', () => {
describe('#setEntries', () => {
test('should return "andLogicIncluded" ', () => {
const update = reducer()(initialState, {
type: 'setEntries',
entries: [],
});
const expected: State = {
andLogicIncluded: false,
entries: [],
entriesToDelete: [],
};
expect(update).toEqual(expected);
});
test('should set "andLogicIncluded" to true if any of the entries include entries with length greater than 1 ', () => {
const entries: ThreatMapEntries[] = [
{
entries: [getEntry(), getEntry()],
},
];
const { andLogicIncluded } = reducer()(initialState, {
type: 'setEntries',
entries,
});
expect(andLogicIncluded).toBeTruthy();
});
test('should set "andLogicIncluded" to false if any of the entries include entries with length greater than 1 ', () => {
const entries: ThreatMapEntries[] = [
{
entries: [getEntry()],
},
];
const { andLogicIncluded } = reducer()(initialState, {
type: 'setEntries',
entries,
});
expect(andLogicIncluded).toBeFalsy();
});
});
describe('#setDefault', () => {
test('should restore initial state and add default empty entry to item" ', () => {
const entries: ThreatMapEntries[] = [
{
entries: [getEntry()],
},
];
const update = reducer()(
{
andLogicIncluded: true,
entries,
entriesToDelete: [],
},
{
type: 'setDefault',
initialState,
lastEntry: {
entries: [],
},
}
);
expect(update).toEqual({
...initialState,
entries: [
{
entries: [getDefaultEmptyEntry()],
},
],
});
});
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 { ThreatMapEntries } from './types';
import { getDefaultEmptyEntry } from './helpers';
export type ViewerModalName = 'addModal' | 'editModal' | null;
export interface State {
andLogicIncluded: boolean;
entries: ThreatMapEntries[];
entriesToDelete: ThreatMapEntries[];
}
export type Action =
| {
type: 'setEntries';
entries: ThreatMapEntries[];
}
| {
type: 'setDefault';
initialState: State;
lastEntry: ThreatMapEntries;
};
export const reducer = () => (state: State, action: Action): State => {
switch (action.type) {
case 'setEntries': {
const isAndLogicIncluded =
action.entries.filter(({ entries }) => entries.length > 1).length > 0;
const returnState = {
...state,
andLogicIncluded: isAndLogicIncluded,
entries: action.entries,
};
return returnState;
}
case 'setDefault': {
return {
...state,
...action.initialState,
entries: [{ ...action.lastEntry, entries: [getDefaultEmptyEntry()] }],
};
}
default:
return state;
}
};

View file

@ -0,0 +1,37 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const FIELD = i18n.translate('xpack.securitySolution.threatMatch.fieldDescription', {
defaultMessage: 'Field',
});
export const THREAT_FIELD = i18n.translate(
'xpack.securitySolution.threatMatch.threatFieldDescription',
{
defaultMessage: 'Threat index field',
}
);
export const FIELD_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.threatMatch.fieldPlaceholderDescription',
{
defaultMessage: 'Search',
}
);
export const MATCHES = i18n.translate('xpack.securitySolution.threatMatch.matchesLabel', {
defaultMessage: 'MATCHES',
});
export const AND = i18n.translate('xpack.securitySolution.threatMatch.andDescription', {
defaultMessage: 'AND',
});
export const OR = i18n.translate('xpack.securitySolution.threatMatch.orDescription', {
defaultMessage: 'OR',
});

View file

@ -0,0 +1,26 @@
/*
* 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 { ThreatMap, ThreatMapEntry } from '../../../../common/detection_engine/schemas/types';
import { IFieldType } from '../../../../../../../src/plugins/data/common';
export interface FormattedEntry {
field: IFieldType | undefined;
type: 'mapping';
value: IFieldType | undefined;
entryIndex: number;
}
export interface EmptyEntry {
field: string | undefined;
type: 'mapping';
value: string | undefined;
}
export type Entry = ThreatMapEntry | EmptyEntry;
export type ThreatMapEntries = Omit<ThreatMap, 'entries'> & {
entries: Entry[];
};

View file

@ -21,6 +21,8 @@ import { isEmpty } from 'lodash/fp';
import React from 'react';
import styled from 'styled-components';
import { MATCHES, AND, OR } from '../../../../common/components/threat_match/translations';
import { ThreatMapping } from '../../../../../common/detection_engine/schemas/types';
import { assertUnreachable } from '../../../../../common/utility_types';
import * as i18nSeverity from '../severity_mapping/translations';
import * as i18nRiskScore from '../risk_score_mapping/translations';
@ -56,6 +58,7 @@ export const buildQueryBarDescription = ({
query,
savedId,
indexPatterns,
queryLabel,
}: BuildQueryBarDescription): ListItems[] => {
let items: ListItems[] = [];
if (!isEmpty(filters)) {
@ -89,7 +92,7 @@ export const buildQueryBarDescription = ({
items = [
...items,
{
title: <>{i18n.QUERY_LABEL} </>,
title: <>{queryLabel ?? i18n.QUERY_LABEL} </>,
description: <>{query} </>,
},
];
@ -416,3 +419,40 @@ export const buildThresholdDescription = (label: string, threshold: Threshold):
),
},
];
export const buildThreatMappingDescription = (
title: string,
threatMapping: ThreatMapping
): ListItems[] => {
const description = threatMapping.reduce<string>(
(accumThreatMaps, threatMap, threatMapIndex, { length: threatMappingLength }) => {
const matches = threatMap.entries.reduce<string>(
(accumItems, item, itemsIndex, { length: threatMapLength }) => {
if (threatMapLength === 1) {
return `${item.field} ${MATCHES} ${item.value}`;
} else if (itemsIndex === 0) {
return `(${item.field} ${MATCHES} ${item.value})`;
} else {
return `${accumItems} ${AND} (${item.field} ${MATCHES} ${item.value})`;
}
},
''
);
if (threatMappingLength === 1) {
return `${matches}`;
} else if (threatMapIndex === 0) {
return `(${matches})`;
} else {
return `${accumThreatMaps} ${OR} (${matches})`;
}
},
''
);
return [
{
title,
description,
},
];
};

View file

@ -9,6 +9,7 @@ import { isEmpty, chunk, get, pick, isNumber } from 'lodash/fp';
import React, { memo, useState } from 'react';
import styled from 'styled-components';
import { ThreatMapping } from '../../../../../common/detection_engine/schemas/types';
import {
IIndexPattern,
Filter,
@ -36,11 +37,13 @@ import {
buildRiskScoreDescription,
buildRuleTypeDescription,
buildThresholdDescription,
buildThreatMappingDescription,
} from './helpers';
import { buildMlJobDescription } from './ml_job_description';
import { buildActionsDescription } from './actions_description';
import { buildThrottleDescription } from './throttle_description';
import { Type } from '../../../../../common/detection_engine/schemas/common/schemas';
import { THREAT_QUERY_LABEL } from './translations';
const DescriptionListContainer = styled(EuiDescriptionList)`
&.euiDescriptionList--column .euiDescriptionList__title {
@ -156,6 +159,7 @@ export const addFilterStateIfNotThere = (filters: Filter[]): Filter[] => {
});
};
/* eslint complexity: ["error", 21]*/
export const getDescriptionItem = (
field: string,
label: string,
@ -189,7 +193,7 @@ export const getDescriptionItem = (
} else if (field === 'falsePositives') {
const values: string[] = get(field, data);
return buildUnorderedListArrayDescription(label, field, values);
} else if (Array.isArray(get(field, data))) {
} else if (Array.isArray(get(field, data)) && field !== 'threatMapping') {
const values: string[] = get(field, data);
return buildStringArrayDescription(label, field, values);
} else if (field === 'riskScore') {
@ -214,6 +218,22 @@ export const getDescriptionItem = (
return buildRuleTypeDescription(label, ruleType);
} else if (field === 'kibanaSiemAppUrl') {
return [];
} else if (field === 'threatQueryBar') {
const filters = addFilterStateIfNotThere(get('threatQueryBar.filters', data) ?? []);
const query = get('threatQueryBar.query.query', data);
const savedId = get('threatQueryBar.saved_id', data);
return buildQueryBarDescription({
field,
filters,
filterManager,
query,
savedId,
indexPatterns,
queryLabel: THREAT_QUERY_LABEL,
});
} else if (field === 'threatMapping') {
const threatMap: ThreatMapping = get(field, data);
return buildThreatMappingDescription(label, threatMap);
}
const description: string = get(field, data);

View file

@ -20,6 +20,13 @@ export const QUERY_LABEL = i18n.translate(
}
);
export const THREAT_QUERY_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.threatQueryLabel',
{
defaultMessage: 'Threat query',
}
);
export const SAVED_ID_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.savedIdLabel',
{

View file

@ -24,6 +24,7 @@ export interface BuildQueryBarDescription {
query: string;
savedId: string;
indexPatterns?: IIndexPattern;
queryLabel?: string;
}
export interface BuildThreatDescription {

View file

@ -12,6 +12,7 @@ import {
isThresholdRule,
isEqlRule,
isQueryRule,
isThreatMatchRule,
} from '../../../../../common/detection_engine/utils';
import { FieldHook } from '../../../../shared_imports';
import { useKibana } from '../../../../common/lib/kibana';
@ -45,6 +46,7 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({
const setMl = useCallback(() => setType('machine_learning'), [setType]);
const setQuery = useCallback(() => setType('query'), [setType]);
const setThreshold = useCallback(() => setType('threshold'), [setType]);
const setThreatMatch = useCallback(() => setType('threat_match'), [setType]);
const mlCardDisabled = isReadOnly || !hasValidLicense || !isMlAdmin;
const licensingUrl = useKibana().services.application.getUrlForApp('kibana', {
path: '#/management/stack/license_management',
@ -86,6 +88,15 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({
[isReadOnly, ruleType, setThreshold]
);
const threatMatchSelectableConfig = useMemo(
() => ({
isDisabled: isReadOnly,
onClick: setThreatMatch,
isSelected: isThreatMatchRule(ruleType),
}),
[isReadOnly, ruleType, setThreatMatch]
);
return (
<EuiFormRow
fullWidth
@ -138,6 +149,18 @@ export const SelectRuleType: React.FC<SelectRuleTypeProps> = ({
selectable={eqlSelectableConfig}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiCard
data-test-subj="threatMatchRuleType"
title={i18n.THREAT_MATCH_TYPE_TITLE}
description={i18n.THREAT_MATCH_TYPE_DESCRIPTION}
icon={<EuiIcon size="l" type="list" />}
isDisabled={
threatMatchSelectableConfig.isDisabled && !threatMatchSelectableConfig.isSelected
}
selectable={threatMatchSelectableConfig}
/>
</EuiFlexItem>
</EuiFlexGrid>
</EuiFormRow>
);

View file

@ -62,3 +62,17 @@ export const THRESHOLD_TYPE_DESCRIPTION = i18n.translate(
defaultMessage: 'Aggregate query results to detect when number of matches exceeds threshold.',
}
);
export const THREAT_MATCH_TYPE_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ruleTypeField.threatMatchTitle',
{
defaultMessage: 'Threat Match',
}
);
export const THREAT_MATCH_TYPE_DESCRIPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ruleTypeField.threatMatchDescription',
{
defaultMessage: 'Upload value lists to write rules around a list of known bad attributes',
}
);

View file

@ -11,6 +11,7 @@ import styled from 'styled-components';
// eslint-disable-next-line no-restricted-imports
import isEqual from 'lodash/isEqual';
import { IndexPattern } from 'src/plugins/data/public';
import { DEFAULT_INDEX_KEY } from '../../../../../common/constants';
import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
@ -47,8 +48,13 @@ import {
} from '../../../../shared_imports';
import { schema } from './schema';
import * as i18n from './translations';
import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils';
import {
isEqlRule,
isThreatMatchRule,
isThresholdRule,
} from '../../../../../common/detection_engine/utils';
import { EqlQueryBar } from '../eql_query_bar';
import { ThreatMatchInput } from '../threatmatch_input';
import { useFetchIndex } from '../../../../common/containers/source';
const CommonUseField = getUseField({ component: Field });
@ -62,11 +68,18 @@ const stepDefineDefaultValue: DefineStepRule = {
index: [],
machineLearningJobId: '',
ruleType: 'query',
threatIndex: [],
queryBar: {
query: { query: '', language: 'kuery' },
filters: [],
saved_id: undefined,
},
threatQueryBar: {
query: { query: '*:*', language: 'kuery' },
filters: [],
saved_id: undefined,
},
threatMapping: [],
threshold: {
field: [],
value: '200',
@ -121,14 +134,22 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
schema,
});
const { getFields, getFormData, reset, submit } = form;
const [{ index: formIndex, ruleType: formRuleType }] = (useFormData({
form,
watch: ['index', 'ruleType'],
}) as unknown) as [Partial<DefineStepRule>];
const [{ index: formIndex, ruleType: formRuleType, threatIndex: formThreatIndex }] = (useFormData(
{
form,
watch: ['index', 'ruleType', 'threatIndex'],
}
) as unknown) as [Partial<DefineStepRule>];
const index = formIndex || initialState.index;
const threatIndex = formThreatIndex || initialState.threatIndex;
const ruleType = formRuleType || initialState.ruleType;
const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(index);
const [
threatIndexPatternsLoading,
{ browserFields: threatBrowserFields, indexPatterns: threatIndexPatterns },
] = useFetchIndex(threatIndex);
// reset form when rule type changes
useEffect(() => {
reset({ resetValues: false });
@ -146,7 +167,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
const getData = useCallback(async () => {
const result = await submit();
return result?.isValid
return result.isValid
? result
: {
isValid: false,
@ -184,6 +205,19 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
[browserFields]
);
const ThreatMatchInputChildren = useCallback(
({ threatMapping }) => (
<ThreatMatchInput
threatBrowserFields={threatBrowserFields}
indexPatterns={indexPatterns as IndexPattern}
threatIndexPatterns={threatIndexPatterns as IndexPattern}
threatMapping={threatMapping}
threatIndexPatternsLoading={threatIndexPatternsLoading}
/>
),
[threatBrowserFields, threatIndexPatternsLoading, threatIndexPatterns, indexPatterns]
);
return isReadOnlyView ? (
<StepContentWrapper data-test-subj="definitionRule" addPadding={addPadding}>
<StepRuleDescription
@ -309,6 +343,23 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
</UseMultiFields>
</>
</RuleTypeEuiFormRow>
<RuleTypeEuiFormRow
$isVisible={isThreatMatchRule(ruleType)}
data-test-subj="threatMatchInput"
fullWidth
>
<>
<UseMultiFields
fields={{
threatMapping: {
path: 'threatMapping',
},
}}
>
{ThreatMatchInputChildren}
</UseMultiFields>
</>
</RuleTypeEuiFormRow>
<UseField
path="timeline"
component={PickTimeline}

View file

@ -9,6 +9,11 @@ import { EuiText } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React from 'react';
import {
singleEntryThreat,
containsInvalidItems,
} from '../../../../common/components/threat_match/helpers';
import { isThreatMatchRule } from '../../../../../common/detection_engine/utils';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
import { esKuery } from '../../../../../../../../src/plugins/data/public';
import { FieldValueQueryBar } from '../query_bar';
@ -20,7 +25,14 @@ import {
ValidationFunc,
} from '../../../../shared_imports';
import { DefineStepRule } from '../../../pages/detection_engine/rules/types';
import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations';
import {
CUSTOM_QUERY_REQUIRED,
INVALID_CUSTOM_QUERY,
INDEX_HELPER_TEXT,
THREAT_MATCH_INDEX_HELPER_TEXT,
THREAT_MATCH_REQUIRED,
THREAT_MATCH_EMPTIES,
} from './translations';
export const schema: FormSchema<DefineStepRule> = {
index: {
@ -219,4 +231,126 @@ export const schema: FormSchema<DefineStepRule> = {
],
},
},
threatIndex: {
type: FIELD_TYPES.COMBO_BOX,
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThreatIndexPatternsLabel',
{
defaultMessage: 'Threat index patterns',
}
),
helpText: <EuiText size="xs">{THREAT_MATCH_INDEX_HELPER_TEXT}</EuiText>,
validations: [
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ formData }] = args;
const needsValidation = isThreatMatchRule(formData.ruleType);
if (!needsValidation) {
return;
}
return fieldValidators.emptyField(
i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.threatMatchoutputIndiceNameFieldRequiredError',
{
defaultMessage: 'A minimum of one index pattern is required.',
}
)
)(...args);
},
},
],
},
threatMapping: {
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThreatMappingLabel',
{
defaultMessage: 'Threat Mapping',
}
),
validations: [
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ path, formData }] = args;
const needsValidation = isThreatMatchRule(formData.ruleType);
if (!needsValidation) {
return;
}
if (singleEntryThreat(formData.threatMapping)) {
return {
code: 'ERR_FIELD_MISSING',
path,
message: THREAT_MATCH_REQUIRED,
};
} else if (containsInvalidItems(formData.threatMapping)) {
return {
code: 'ERR_FIELD_MISSING',
path,
message: THREAT_MATCH_EMPTIES,
};
} else {
return undefined;
}
},
},
],
},
threatQueryBar: {
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThreatQueryBarLabel',
{
defaultMessage: 'Threat index query',
}
),
validations: [
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ value, path, formData }] = args;
const needsValidation = isThreatMatchRule(formData.ruleType);
if (!needsValidation) {
return;
}
const { query, filters } = value as FieldValueQueryBar;
return isEmpty(query.query as string) && isEmpty(filters)
? {
code: 'ERR_FIELD_MISSING',
path,
message: CUSTOM_QUERY_REQUIRED,
}
: undefined;
},
},
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ value, path, formData }] = args;
const needsValidation = isThreatMatchRule(formData.ruleType);
if (!needsValidation) {
return;
}
const { query } = value as FieldValueQueryBar;
if (!isEmpty(query.query as string) && query.language === 'kuery') {
try {
esKuery.fromKueryExpression(query.query);
} catch (err) {
return {
code: 'ERR_FIELD_FORMAT',
path,
message: INVALID_CUSTOM_QUERY,
};
}
}
},
},
],
},
};

View file

@ -70,3 +70,24 @@ export const ENABLE_ML_JOB_WARNING = i18n.translate(
'This ML job is not currently running. Please set this job to run via "ML job settings" before activating this rule.',
}
);
export const THREAT_MATCH_INDEX_HELPER_TEXT = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.threatMatchingIcesHelperDescription',
{
defaultMessage: 'Select threat indices',
}
);
export const THREAT_MATCH_REQUIRED = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredError',
{
defaultMessage: 'At least one threat match is required.',
}
);
export const THREAT_MATCH_EMPTIES = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.customThreatQueryFieldRequiredEmptyError',
{
defaultMessage: 'All matches require both a field and threat index field.',
}
);

View file

@ -0,0 +1,114 @@
/*
* 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 React, { useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiFormRow } from '@elastic/eui';
import { ThreatMapEntries } from '../../../../common/components/threat_match/types';
import { ThreatMatchComponent } from '../../../../common/components/threat_match';
import { BrowserField } from '../../../../common/containers/source';
import {
FieldHook,
Field,
getUseField,
UseField,
getFieldValidityAndErrorMessage,
} from '../../../../shared_imports';
import { schema } from '../step_define_rule/schema';
import { QueryBarDefineRule } from '../query_bar';
import { IndexPattern } from '../../../../../../../../src/plugins/data/public';
const CommonUseField = getUseField({ component: Field });
interface ThreatMatchInputProps {
threatMapping: FieldHook;
threatBrowserFields: Readonly<Record<string, Partial<BrowserField>>>;
threatIndexPatterns: IndexPattern;
indexPatterns: IndexPattern;
threatIndexPatternsLoading: boolean;
}
const ThreatMatchInputComponent: React.FC<ThreatMatchInputProps> = ({
threatMapping,
indexPatterns,
threatIndexPatterns,
threatIndexPatternsLoading,
threatBrowserFields,
}: ThreatMatchInputProps) => {
const { setValue, value: threatItems } = threatMapping;
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(threatMapping);
const handleBuilderOnChange = useCallback(
({ entryItems }: { entryItems: ThreatMapEntries[] }): void => {
setValue(entryItems);
},
[setValue]
);
return (
<>
<EuiSpacer size="m" />
<EuiFlexGroup direction="column">
<EuiFlexItem grow={true}>
<CommonUseField
path="threatIndex"
config={{
...schema.threatIndex,
labelAppend: null,
}}
componentProps={{
idAria: 'detectionEngineStepDefineRuleThreatMatchIndices',
'data-test-subj': 'detectionEngineStepDefineRuleThreatMatchIndices',
euiFieldProps: {
fullWidth: true,
isDisabled: false,
placeholder: '',
},
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<UseField
path="threatQueryBar"
config={{
...schema.threatQueryBar,
labelAppend: null,
}}
component={QueryBarDefineRule}
componentProps={{
browserFields: threatBrowserFields,
idAria: 'detectionEngineStepDefineThreatRuleQueryBar',
indexPattern: threatIndexPatterns,
isDisabled: false,
isLoading: threatIndexPatternsLoading,
dataTestSubj: 'detectionEngineStepDefineThreatRuleQueryBar',
openTimelineSearch: false,
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFormRow
label={threatMapping.label}
labelAppend={threatMapping.labelAppend}
helpText={threatMapping.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
>
<ThreatMatchComponent
listItems={threatItems as ThreatMapEntries[]}
indexPatterns={indexPatterns}
threatIndexPatterns={threatIndexPatterns}
data-test-subj="threatmatch-builder"
id-aria="threatmatch-builder"
onChange={handleBuilderOnChange}
/>
</EuiFormRow>
<EuiSpacer size="m" />
</>
);
};
export const ThreatMatchInput = React.memo(ThreatMatchInputComponent);

View file

@ -0,0 +1,14 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const THREAT_MATCH_FIELD_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.threatMatchField.threatMatchFieldPlaceholderText',
{
defaultMessage: 'All results',
}
);

View file

@ -18,7 +18,14 @@ import {
threshold,
type,
} from '../../../../../common/detection_engine/schemas/common/schemas';
import { listArray } from '../../../../../common/detection_engine/schemas/types';
import {
listArray,
threat_query,
threat_index,
threat_mapping,
threat_language,
threat_filters,
} from '../../../../../common/detection_engine/schemas/types';
import {
CreateRulesSchema,
PatchRulesSchema,
@ -110,6 +117,11 @@ export const RuleSchema = t.intersection([
status: t.string,
status_date: t.string,
threshold,
threat_query,
threat_filters,
threat_index,
threat_mapping,
threat_language,
timeline_id: t.string,
timeline_title: t.string,
timestamp_override,

View file

@ -212,10 +212,13 @@ export const mockDefineStepRule = (): DefineStepRule => ({
machineLearningJobId: '',
index: ['filebeat-'],
queryBar: mockQueryBar,
threatQueryBar: mockQueryBar,
threatMapping: [],
timeline: {
id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
title: 'Titled timeline',
},
threatIndex: [],
threshold: {
field: [''],
value: '100',

View file

@ -73,28 +73,77 @@ export interface RuleFields {
index: unknown;
ruleType: unknown;
threshold?: unknown;
threatIndex?: unknown;
threatQueryBar?: unknown;
threatMapping?: unknown;
threatLanguage?: unknown;
}
type QueryRuleFields<T> = Omit<T, 'anomalyThreshold' | 'machineLearningJobId' | 'threshold'>;
type ThresholdRuleFields<T> = Omit<T, 'anomalyThreshold' | 'machineLearningJobId'>;
type MlRuleFields<T> = Omit<T, 'queryBar' | 'index' | 'threshold'>;
type QueryRuleFields<T> = Omit<
T,
| 'anomalyThreshold'
| 'machineLearningJobId'
| 'threshold'
| 'threatIndex'
| 'threatQueryBar'
| 'threatMapping'
>;
type ThresholdRuleFields<T> = Omit<
T,
'anomalyThreshold' | 'machineLearningJobId' | 'threatIndex' | 'threatQueryBar' | 'threatMapping'
>;
type MlRuleFields<T> = Omit<
T,
'queryBar' | 'index' | 'threshold' | 'threatIndex' | 'threatQueryBar' | 'threatMapping'
>;
type ThreatMatchRuleFields<T> = Omit<T, 'anomalyThreshold' | 'machineLearningJobId' | 'threshold'>;
const isMlFields = <T>(
fields: QueryRuleFields<T> | MlRuleFields<T> | ThresholdRuleFields<T>
fields: QueryRuleFields<T> | MlRuleFields<T> | ThresholdRuleFields<T> | ThreatMatchRuleFields<T>
): fields is MlRuleFields<T> => has('anomalyThreshold', fields);
const isThresholdFields = <T>(
fields: QueryRuleFields<T> | MlRuleFields<T> | ThresholdRuleFields<T>
fields: QueryRuleFields<T> | MlRuleFields<T> | ThresholdRuleFields<T> | ThreatMatchRuleFields<T>
): fields is ThresholdRuleFields<T> => has('threshold', fields);
export const filterRuleFieldsForType = <T extends RuleFields>(fields: T, type: Type) => {
const isThreatMatchFields = <T>(
fields: QueryRuleFields<T> | MlRuleFields<T> | ThresholdRuleFields<T> | ThreatMatchRuleFields<T>
): fields is ThreatMatchRuleFields<T> => has('threatIndex', fields);
export const filterRuleFieldsForType = <T extends RuleFields>(
fields: T,
type: Type
): QueryRuleFields<T> | MlRuleFields<T> | ThresholdRuleFields<T> | ThreatMatchRuleFields<T> => {
switch (type) {
case 'machine_learning':
const { index, queryBar, threshold, ...mlRuleFields } = fields;
const {
index,
queryBar,
threshold,
threatIndex,
threatQueryBar,
threatMapping,
...mlRuleFields
} = fields;
return mlRuleFields;
case 'threshold':
const { anomalyThreshold, machineLearningJobId, ...thresholdRuleFields } = fields;
const {
anomalyThreshold,
machineLearningJobId,
threatIndex: _removedThreatIndex,
threatQueryBar: _removedThreatQueryBar,
threatMapping: _removedThreatMapping,
...thresholdRuleFields
} = fields;
return thresholdRuleFields;
case 'threat_match':
const {
anomalyThreshold: _removedAnomalyThreshold,
machineLearningJobId: _removedMachineLearningJobId,
threshold: _removedThreshold,
...threatMatchRuleFields
} = fields;
return threatMatchRuleFields;
case 'query':
case 'saved_query':
case 'eql':
@ -102,6 +151,9 @@ export const filterRuleFieldsForType = <T extends RuleFields>(fields: T, type: T
anomalyThreshold: _a,
machineLearningJobId: _m,
threshold: _t,
threatIndex: __removedThreatIndex,
threatQueryBar: __removedThreatQueryBar,
threatMapping: __removedThreatMapping,
...queryRuleFields
} = fields;
return queryRuleFields;
@ -140,6 +192,18 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep
},
}),
}
: isThreatMatchFields(ruleFields)
? {
index: ruleFields.index,
filters: ruleFields.queryBar?.filters,
language: ruleFields.queryBar?.query?.language,
query: ruleFields.queryBar?.query?.query as string,
saved_id: ruleFields.queryBar?.saved_id,
threat_index: ruleFields.threatIndex,
threat_query: ruleFields.threatQueryBar?.query?.query as string,
threat_mapping: ruleFields.threatMapping,
threat_language: ruleFields.threatQueryBar?.query?.language,
}
: {
index: ruleFields.index,
filters: ruleFields.queryBar?.filters,

View file

@ -183,10 +183,10 @@ const CreateRulePageComponent: React.FC = () => {
if (nextStep != null) {
goToStep(nextStep);
} else {
const defineStep = await stepsData.current[RuleStep.defineRule];
const aboutStep = await stepsData.current[RuleStep.aboutRule];
const scheduleStep = await stepsData.current[RuleStep.scheduleRule];
const actionsStep = await stepsData.current[RuleStep.ruleActions];
const defineStep = stepsData.current[RuleStep.defineRule];
const aboutStep = stepsData.current[RuleStep.aboutRule];
const scheduleStep = stepsData.current[RuleStep.scheduleRule];
const actionsStep = stepsData.current[RuleStep.ruleActions];
if (
stepIsValid(defineStep) &&

View file

@ -82,6 +82,16 @@ describe('rule helpers', () => {
field: ['host.name'],
value: '50',
},
threatIndex: [],
threatMapping: [],
threatQueryBar: {
query: {
query: '',
language: '',
},
filters: [],
saved_id: undefined,
},
timeline: {
id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
title: 'Titled timeline',
@ -217,6 +227,16 @@ describe('rule helpers', () => {
field: [],
value: '100',
},
threatIndex: [],
threatMapping: [],
threatQueryBar: {
query: {
query: '',
language: '',
},
filters: [],
saved_id: undefined,
},
timeline: {
id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
title: 'Untitled timeline',
@ -249,6 +269,16 @@ describe('rule helpers', () => {
field: [],
value: '100',
},
threatIndex: [],
threatMapping: [],
threatQueryBar: {
query: {
query: '',
language: '',
},
filters: [],
saved_id: undefined,
},
timeline: {
id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2',
title: 'Untitled timeline',

View file

@ -79,6 +79,13 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({
anomalyThreshold: rule.anomaly_threshold ?? 50,
machineLearningJobId: rule.machine_learning_job_id ?? '',
index: rule.index ?? [],
threatIndex: rule.threat_index ?? [],
threatQueryBar: {
query: { query: rule.threat_query ?? '', language: rule.threat_language ?? '' },
filters: (rule.threat_filters ?? []) as Filter[],
saved_id: undefined,
},
threatMapping: rule.threat_mapping ?? [],
queryBar: {
query: { query: rule.query ?? '', language: rule.language ?? '' },
filters: (rule.filters ?? []) as Filter[],
@ -341,7 +348,6 @@ export const getActionMessageRuleParams = (ruleType: Type): string[] => {
'threat',
'type',
'version',
// 'lists',
];
const ruleParamsKeys = [

View file

@ -22,7 +22,11 @@ import {
Type,
Severity,
} from '../../../../../common/detection_engine/schemas/common/schemas';
import { List } from '../../../../../common/detection_engine/schemas/types';
import {
List,
ThreatIndex,
ThreatMapping,
} from '../../../../../common/detection_engine/schemas/types';
export interface EuiBasicTableSortTypes {
field: string;
@ -124,6 +128,9 @@ export interface DefineStepRule {
ruleType: Type;
timeline: FieldValueTimeline;
threshold: FieldValueThreshold;
threatIndex: ThreatIndex;
threatQueryBar: FieldValueQueryBar;
threatMapping: ThreatMapping;
}
export interface ScheduleStepRule {

View file

@ -399,6 +399,7 @@ export const getResult = (): RuleAlertType => ({
timestampOverride: undefined,
threatFilters: undefined,
threatMapping: undefined,
threatLanguage: undefined,
threatIndex: undefined,
threatQuery: undefined,
references: ['http://www.example.com', 'https://ww.example.com'],

View file

@ -96,6 +96,7 @@ export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) =>
threat_index: threatIndex,
threat_mapping: threatMapping,
threat_query: threatQuery,
threat_language: threatLanguage,
threshold,
throttle,
timestamp_override: timestampOverride,
@ -186,6 +187,7 @@ export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) =>
threatMapping,
threatQuery,
threatIndex,
threatLanguage,
threshold,
timestampOverride,
references,

View file

@ -84,6 +84,7 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void
threat_index: threatIndex,
threat_query: threatQuery,
threat_mapping: threatMapping,
threat_language: threatLanguage,
throttle,
timestamp_override: timestampOverride,
to,
@ -175,6 +176,7 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
references,
note,

View file

@ -163,6 +163,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP
threat_index: threatIndex,
threat_query: threatQuery,
threat_mapping: threatMapping,
threat_language: threatLanguage,
threshold,
timestamp_override: timestampOverride,
to,
@ -223,11 +224,12 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP
to,
type,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threshold,
threatMapping,
threatLanguage,
timestampOverride,
references,
note,
@ -272,6 +274,11 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP
type,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
references,
note,
version,

View file

@ -87,6 +87,11 @@ export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) =>
type,
threat,
threshold,
threat_filters: threatFilters,
threat_index: threatIndex,
threat_query: threatQuery,
threat_mapping: threatMapping,
threat_language: threatLanguage,
timestamp_override: timestampOverride,
throttle,
references,
@ -147,6 +152,11 @@ export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) =>
type,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
references,
note,

View file

@ -78,6 +78,11 @@ export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => {
type,
threat,
threshold,
threat_filters: threatFilters,
threat_index: threatIndex,
threat_query: threatQuery,
threat_mapping: threatMapping,
threat_language: threatLanguage,
timestamp_override: timestampOverride,
throttle,
references,
@ -146,6 +151,11 @@ export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => {
type,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
references,
note,

View file

@ -91,6 +91,11 @@ export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) =>
type,
threat,
threshold,
threat_filters: threatFilters,
threat_index: threatIndex,
threat_query: threatQuery,
threat_mapping: threatMapping,
threat_language: threatLanguage,
throttle,
timestamp_override: timestampOverride,
references,
@ -158,6 +163,11 @@ export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) =>
type,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
references,
note,

View file

@ -81,6 +81,11 @@ export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => {
type,
threat,
threshold,
threat_filters: threatFilters,
threat_index: threatIndex,
threat_query: threatQuery,
threat_mapping: threatMapping,
threat_language: threatLanguage,
throttle,
timestamp_override: timestampOverride,
references,
@ -148,6 +153,11 @@ export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => {
type,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
references,
note,

View file

@ -156,7 +156,7 @@ describe('utils', () => {
],
},
];
threatRule.params.threatIndex = 'index-123';
threatRule.params.threatIndex = ['index-123'];
threatRule.params.threatFilters = threatFilters;
threatRule.params.threatMapping = threatMapping;
threatRule.params.threatQuery = '*:*';
@ -164,7 +164,7 @@ describe('utils', () => {
const rule = transformAlertToRule(threatRule);
expect(rule).toEqual(
expect.objectContaining({
threat_index: 'index-123',
threat_index: ['index-123'],
threat_filters: threatFilters,
threat_mapping: threatMapping,
threat_query: '*:*',

View file

@ -150,6 +150,7 @@ export const transformAlertToRule = (
threat_index: alert.params.threatIndex,
threat_query: alert.params.threatQuery,
threat_mapping: alert.params.threatMapping,
threat_language: alert.params.threatLanguage,
throttle: ruleActions?.ruleThrottle || 'no_actions',
timestamp_override: alert.params.timestampOverride,
note: alert.params.note,

View file

@ -42,6 +42,7 @@ export const getCreateRulesOptionsMock = (): CreateRulesOptions => ({
threat: [],
threatFilters: undefined,
threatMapping: undefined,
threatLanguage: undefined,
threatQuery: undefined,
threatIndex: undefined,
threshold: undefined,
@ -92,6 +93,7 @@ export const getCreateMlRulesOptionsMock = (): CreateRulesOptions => ({
threatIndex: undefined,
threatMapping: undefined,
threatQuery: undefined,
threatLanguage: undefined,
threshold: undefined,
timestampOverride: undefined,
to: 'now',

View file

@ -45,6 +45,7 @@ export const createRules = async ({
threat,
threatFilters,
threatIndex,
threatLanguage,
threatQuery,
threatMapping,
threshold,
@ -96,6 +97,7 @@ export const createRules = async ({
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
to,
type,

View file

@ -50,6 +50,7 @@ export const installPrepackagedRules = (
threat,
threat_filters: threatFilters,
threat_mapping: threatMapping,
threat_language: threatLanguage,
threat_query: threatQuery,
threat_index: threatIndex,
threshold,
@ -101,6 +102,7 @@ export const installPrepackagedRules = (
threat,
threatFilters,
threatMapping,
threatLanguage,
threatQuery,
threatIndex,
threshold,

View file

@ -149,6 +149,11 @@ export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({
tags: [],
threat: [],
threshold: undefined,
threatFilters: undefined,
threatIndex: undefined,
threatQuery: undefined,
threatMapping: undefined,
threatLanguage: undefined,
timestampOverride: undefined,
to: 'now',
type: 'query',
@ -193,6 +198,11 @@ export const getPatchMlRulesOptionsMock = (): PatchRulesOptions => ({
tags: [],
threat: [],
threshold: undefined,
threatFilters: undefined,
threatIndex: undefined,
threatQuery: undefined,
threatMapping: undefined,
threatLanguage: undefined,
timestampOverride: undefined,
to: 'now',
type: 'machine_learning',

View file

@ -44,6 +44,11 @@ export const patchRules = async ({
tags,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
to,
type,
@ -87,6 +92,11 @@ export const patchRules = async ({
tags,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
to,
type,
@ -126,6 +136,11 @@ export const patchRules = async ({
severityMapping,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
to,
type,

View file

@ -91,6 +91,7 @@ import {
ThreatQueryOrUndefined,
ThreatMappingOrUndefined,
ThreatFiltersOrUndefined,
ThreatLanguageOrUndefined,
} from '../../../../common/detection_engine/schemas/types/threat_mapping';
import { AlertsClient, PartialAlert } from '../../../../../alerts/server';
@ -219,6 +220,7 @@ export interface CreateRulesOptions {
threatIndex: ThreatIndexOrUndefined;
threatQuery: ThreatQueryOrUndefined;
threatMapping: ThreatMappingOrUndefined;
threatLanguage: ThreatLanguageOrUndefined;
timestampOverride: TimestampOverrideOrUndefined;
to: To;
type: Type;
@ -264,6 +266,11 @@ export interface UpdateRulesOptions {
tags: Tags;
threat: Threat;
threshold: ThresholdOrUndefined;
threatFilters: ThreatFiltersOrUndefined;
threatIndex: ThreatIndexOrUndefined;
threatQuery: ThreatQueryOrUndefined;
threatMapping: ThreatMappingOrUndefined;
threatLanguage: ThreatLanguageOrUndefined;
timestampOverride: TimestampOverrideOrUndefined;
to: To;
type: Type;
@ -307,6 +314,11 @@ export interface PatchRulesOptions {
tags: TagsOrUndefined;
threat: ThreatOrUndefined;
threshold: ThresholdOrUndefined;
threatFilters: ThreatFiltersOrUndefined;
threatIndex: ThreatIndexOrUndefined;
threatQuery: ThreatQueryOrUndefined;
threatMapping: ThreatMappingOrUndefined;
threatLanguage: ThreatLanguageOrUndefined;
timestampOverride: TimestampOverrideOrUndefined;
to: ToOrUndefined;
type: TypeOrUndefined;

View file

@ -47,6 +47,11 @@ export const updatePrepackagedRules = async (
type,
threat,
threshold,
threat_filters: threatFilters,
threat_index: threatIndex,
threat_query: threatQuery,
threat_mapping: threatMapping,
threat_language: threatLanguage,
timestamp_override: timestampOverride,
references,
version,
@ -97,6 +102,11 @@ export const updatePrepackagedRules = async (
type,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
references,
version,
note,

View file

@ -43,6 +43,11 @@ export const getUpdateRulesOptionsMock = (): UpdateRulesOptions => ({
tags: [],
threat: [],
threshold: undefined,
threatFilters: undefined,
threatIndex: undefined,
threatQuery: undefined,
threatMapping: undefined,
threatLanguage: undefined,
timestampOverride: undefined,
to: 'now',
type: 'query',
@ -88,6 +93,11 @@ export const getUpdateMlRulesOptionsMock = (): UpdateRulesOptions => ({
tags: [],
threat: [],
threshold: undefined,
threatFilters: undefined,
threatIndex: undefined,
threatQuery: undefined,
threatMapping: undefined,
threatLanguage: undefined,
timestampOverride: undefined,
to: 'now',
type: 'machine_learning',

View file

@ -45,6 +45,11 @@ export const updateRules = async ({
tags,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
to,
type,
@ -89,6 +94,11 @@ export const updateRules = async ({
tags,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
to,
type,
@ -134,6 +144,11 @@ export const updateRules = async ({
severityMapping,
threat,
threshold,
threatFilters,
threatIndex,
threatQuery,
threatMapping,
threatLanguage,
timestampOverride,
to,
type,

View file

@ -55,6 +55,11 @@ describe('utils', () => {
tags: undefined,
threat: undefined,
threshold: undefined,
threatFilters: undefined,
threatIndex: undefined,
threatQuery: undefined,
threatMapping: undefined,
threatLanguage: undefined,
to: undefined,
timestampOverride: undefined,
type: undefined,
@ -98,6 +103,11 @@ describe('utils', () => {
tags: undefined,
threat: undefined,
threshold: undefined,
threatFilters: undefined,
threatIndex: undefined,
threatQuery: undefined,
threatMapping: undefined,
threatLanguage: undefined,
to: undefined,
timestampOverride: undefined,
type: undefined,
@ -141,6 +151,11 @@ describe('utils', () => {
tags: undefined,
threat: undefined,
threshold: undefined,
threatFilters: undefined,
threatIndex: undefined,
threatQuery: undefined,
threatMapping: undefined,
threatLanguage: undefined,
to: undefined,
timestampOverride: undefined,
type: undefined,

View file

@ -42,7 +42,14 @@ import {
EventCategoryOverrideOrUndefined,
} from '../../../../common/detection_engine/schemas/common/schemas';
import { PartialFilter } from '../types';
import { ListArrayOrUndefined } from '../../../../common/detection_engine/schemas/types';
import {
ListArrayOrUndefined,
ThreatFiltersOrUndefined,
ThreatIndexOrUndefined,
ThreatLanguageOrUndefined,
ThreatMappingOrUndefined,
ThreatQueryOrUndefined,
} from '../../../../common/detection_engine/schemas/types';
export const calculateInterval = (
interval: string | undefined,
@ -86,6 +93,11 @@ export interface UpdateProperties {
tags: TagsOrUndefined;
threat: ThreatOrUndefined;
threshold: ThresholdOrUndefined;
threatFilters: ThreatFiltersOrUndefined;
threatIndex: ThreatIndexOrUndefined;
threatQuery: ThreatQueryOrUndefined;
threatMapping: ThreatMappingOrUndefined;
threatLanguage: ThreatLanguageOrUndefined;
timestampOverride: TimestampOverrideOrUndefined;
to: ToOrUndefined;
type: TypeOrUndefined;

View file

@ -7,7 +7,7 @@
"type": "threat_match",
"query": "*:*",
"tags": ["tag_1", "tag_2"],
"threat_index": "mock-threat-list",
"threat_index": ["mock-threat-list"],
"threat_query": "*:*",
"threat_mapping": [
{

View file

@ -60,6 +60,7 @@ export const sampleRuleAlertParams = (
threatQuery: undefined,
threatMapping: undefined,
threatIndex: undefined,
threatLanguage: undefined,
timelineId: undefined,
timelineTitle: undefined,
timestampOverride: undefined,

View file

@ -168,6 +168,7 @@ export const buildRuleWithoutOverrides = (
threat_index: ruleParams.threatIndex,
threat_query: ruleParams.threatQuery,
threat_mapping: ruleParams.threatMapping,
threat_language: ruleParams.threatLanguage,
};
return removeInternalTagsFromRule(rule);
};

View file

@ -50,9 +50,10 @@ const signalSchema = schema.object({
exceptions_list: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), // For backwards compatibility with customers that had a data bug in 7.8. Once we use a migration script please remove this.
exceptionsList: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))),
threatFilters: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))),
threatIndex: schema.maybe(schema.string()),
threatIndex: schema.maybe(schema.arrayOf(schema.string())),
threatQuery: schema.maybe(schema.string()),
threatMapping: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))),
threatLanguage: schema.maybe(schema.string()),
});
/**

View file

@ -112,6 +112,7 @@ export const signalRulesAlertType = ({
threatQuery,
threatIndex,
threatMapping,
threatLanguage,
type,
exceptionsList,
} = params;
@ -389,6 +390,7 @@ export const signalRulesAlertType = ({
throttle,
threatFilters: threatFilters ?? [],
threatQuery,
threatLanguage,
buildRuleMessage,
threatIndex,
});

View file

@ -45,6 +45,7 @@ export const createThreatSignal = async ({
throttle,
threatFilters,
threatQuery,
threatLanguage,
buildRuleMessage,
threatIndex,
name,
@ -105,8 +106,9 @@ export const createThreatSignal = async ({
callCluster: services.callCluster,
exceptionItems,
query: threatQuery,
language: threatLanguage,
threatFilters,
index: [threatIndex],
index: threatIndex,
searchAfter,
sortField: undefined,
sortOrder: undefined,

View file

@ -41,6 +41,7 @@ export const createThreatSignals = async ({
throttle,
threatFilters,
threatQuery,
threatLanguage,
buildRuleMessage,
threatIndex,
name,
@ -59,7 +60,8 @@ export const createThreatSignals = async ({
exceptionItems,
threatFilters,
query: threatQuery,
index: [threatIndex],
language: threatLanguage,
index: threatIndex,
searchAfter: undefined,
sortField: undefined,
sortOrder: undefined,
@ -99,6 +101,7 @@ export const createThreatSignals = async ({
threatQuery,
buildRuleMessage,
threatIndex,
threatLanguage,
name,
currentThreatList: threatList,
currentResult: results,

View file

@ -21,6 +21,7 @@ export const MAX_PER_PAGE = 9000;
export const getThreatList = async ({
callCluster,
query,
language,
index,
perPage,
searchAfter,
@ -33,7 +34,13 @@ export const getThreatList = async ({
if (calculatedPerPage > 10000) {
throw new TypeError('perPage cannot exceed the size of 10000');
}
const queryFilter = getQueryFilter(query, 'kuery', threatFilters, index, exceptionItems);
const queryFilter = getQueryFilter(
query,
language ?? 'kuery',
threatFilters,
index,
exceptionItems
);
const response: SearchResponse<ThreatListItem> = await callCluster('search', {
body: {
query: queryFilter,

View file

@ -15,6 +15,8 @@ import {
ThreatQuery,
ThreatMapping,
ThreatMappingEntries,
ThreatIndex,
ThreatLanguageOrUndefined,
} from '../../../../../common/detection_engine/schemas/types/threat_mapping';
import { PartialFilter, RuleTypeParams } from '../../types';
import { AlertServices } from '../../../../../../alerts/server';
@ -57,7 +59,8 @@ export interface CreateThreatSignalsOptions {
threatFilters: PartialFilter[];
threatQuery: ThreatQuery;
buildRuleMessage: BuildRuleMessage;
threatIndex: string;
threatIndex: ThreatIndex;
threatLanguage: ThreatLanguageOrUndefined;
name: string;
}
@ -93,7 +96,8 @@ export interface CreateThreatSignalOptions {
threatFilters: PartialFilter[];
threatQuery: ThreatQuery;
buildRuleMessage: BuildRuleMessage;
threatIndex: string;
threatIndex: ThreatIndex;
threatLanguage: ThreatLanguageOrUndefined;
name: string;
currentThreatList: SearchResponse<ThreatListItem>;
currentResult: SearchAfterAndBulkCreateReturnType;
@ -138,6 +142,7 @@ export interface BooleanFilter {
export interface GetThreatListOptions {
callCluster: ILegacyScopedClusterClient['callAsCurrentUser'];
query: string;
language: ThreatLanguageOrUndefined;
index: string[];
perPage?: number;
searchAfter: string[] | undefined;

View file

@ -43,6 +43,7 @@ import {
ThreatIndexOrUndefined,
ThreatQueryOrUndefined,
ThreatMappingOrUndefined,
ThreatLanguageOrUndefined,
} from '../../../common/detection_engine/schemas/types/threat_mapping';
import { LegacyCallAPIOptions } from '../../../../../../src/core/server';
@ -85,6 +86,7 @@ export interface RuleTypeParams {
threatIndex: ThreatIndexOrUndefined;
threatQuery: ThreatQueryOrUndefined;
threatMapping: ThreatMappingOrUndefined;
threatLanguage: ThreatLanguageOrUndefined;
timestampOverride: TimestampOverrideOrUndefined;
to: To;
type: Type;