[Security Solution][Detections] Adds Nested CTI row renderer (#96275)

* Move alert-specific mocks to more declarative mock file

* Add placeholder interface for ECS threat fields

* Test and implement CTI row renderer

The display details are not yet implemented, but those will be fleshed
out in the ThreatMatchRow component.

* Pass full fields data to our row renderers

This data is not used by any existing row renderers and so this commit
is mostly just plumbing that data through.

This is necessary, however, for our new threat match row renderer as it
requires nested fields, which cannot be retrieved through the mechanism
that retrieves the existing row renderer data. However, these nested
fields are available, if requested, through this other data structure,
hence this plumbing.

For now to minimize changes I'm marking this as an optional field;
however in reality a value will always be present.

* Rewrite existing row renderer in terms of flattened data

Updates logic, tests and mocks accordingly.

* Moving logic into discrete files

* helpers
* explicit fields file, which will hopefully be part of the renderer API
  at some point
* parent component to split data into "rows" as defined by our renderer
* row component for stateless presentation of a single match

* Register threat match row rendere

Adds tentative copy, example row, and accompanying mock data.

* WIP: Rendering draggable fields but hit the data loss issue with nested fields being flattened

* WIP: implementing row renderer against new data format

I haven't yet deleted the old (new?) unused path yet. Cleanup to come.

* Updating based on new data

* Rewrites isInstance logic for new data as helper, hasThreatMatchValue
* Updating types and tests
  * Adds to the previously empty ThreatEcs

* Revert "Pass full fields data to our row renderers"

This reverts commit 19c93ee0732166747b5472433cd5fc813638e21b.

We ended up extending the existing data (albeit from the fields
response!).

* Fix draggables

* adds contextId and eventId to pass to draggable
* We don't have a order-independent key for each individual
  ThreatMatchRow, due to matched.id not being mapped/returned in the
  fields response
* Fixes up a few things related to using the new data format

* Move indicator field strings to constants

* Fix example data for CTI row renderer

* Adds missing Threat ECS types

* Move CTI field constants to common folder

In order to use these in both the row renderer and the server request,
we need to move them to common/

* Remove redundant CTI fields from client request

These are currently hardcoded on the backend of the events/all query
(via TIMELINE_EVENTS_FIELDS); declaring them on both ends is arguably
confusing, and we're going with YAGNI for now.

* Add missing graphQL type

This was causing type errors as this enum exists both here and in
common/, and I had only updated one of them.

* Updates tests

One is still failing due to an outdated test subject, but I expect this
to change after an upcoming meeting so leaving it for now.

* Split ThreatMatchRow into subcomponents

One for displaying match details, and another for indicator details

The indicator details will be sparse, so there's going to be some
conditional rendering in there.

* Make CTI row renderer look nice

* Adds translations for copy
* Fixes most of our layout woes with more flexbox!
* Conditional rendering of indicator details based on data
* tests

* Make indicator reference field an external link

Leverages the existing FormattedFieldValue component, with one minor
tweak to add this field to the URL allowlist.

* Back to consistent horizontal spacing, here

The draggable badges are a little odd in that their full box isn't
indicated until hover, making the visual weight a little off.

* Add hr as a visual separator between each match "row" of the row renderer

* Fix tests broken due to addition of a new row renderer

These tests are all implicitly testing the list of row renderers.

* Full-width hr

At certain container widths, a half-width hr is not sufficient.

* More descriptive constant

Obviates the need for the accompanying comments.

* More realistic data

Also ensures less traffic to urlhaus ;)

* Remove useless comment

* Add threat_match row renderer type to GQL client

Gennin' beanz

* Ensure contextId is unique for each CTI subrow

We need to add the row index to our contextId to ensure that our
draggables work correctly for multiple rows, since each row will
necessarily have the same eventId and timelineId.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ryland Herrick 2021-04-15 20:28:18 -05:00 committed by GitHub
parent 6e5c9278ba
commit 540924b5be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 963 additions and 80 deletions

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { INDICATOR_DESTINATION_PATH } from '../constants';
export const MATCHED_ATOMIC = 'matched.atomic';
export const MATCHED_FIELD = 'matched.field';
export const MATCHED_TYPE = 'matched.type';
export const INDICATOR_MATCH_SUBFIELDS = [MATCHED_ATOMIC, MATCHED_FIELD, MATCHED_TYPE];
export const INDICATOR_MATCHED_ATOMIC = `${INDICATOR_DESTINATION_PATH}.${MATCHED_ATOMIC}`;
export const INDICATOR_MATCHED_FIELD = `${INDICATOR_DESTINATION_PATH}.${MATCHED_FIELD}`;
export const INDICATOR_MATCHED_TYPE = `${INDICATOR_DESTINATION_PATH}.${MATCHED_TYPE}`;
export const EVENT_DATASET = 'event.dataset';
export const EVENT_REFERENCE = 'event.reference';
export const PROVIDER = 'provider';
export const INDICATOR_DATASET = `${INDICATOR_DESTINATION_PATH}.${EVENT_DATASET}`;
export const INDICATOR_REFERENCE = `${INDICATOR_DESTINATION_PATH}.${EVENT_REFERENCE}`;
export const INDICATOR_PROVIDER = `${INDICATOR_DESTINATION_PATH}.${PROVIDER}`;
export const CTI_ROW_RENDERER_FIELDS = [
INDICATOR_MATCHED_ATOMIC,
INDICATOR_MATCHED_FIELD,
INDICATOR_MATCHED_TYPE,
INDICATOR_DATASET,
INDICATOR_REFERENCE,
INDICATOR_PROVIDER,
];

View file

@ -28,6 +28,7 @@ import { UserEcs } from './user';
import { WinlogEcs } from './winlog';
import { ProcessEcs } from './process';
import { SystemEcs } from './system';
import { ThreatEcs } from './threat';
import { Ransomware } from './ransomware';
export interface Ecs {
@ -58,6 +59,7 @@ export interface Ecs {
process?: ProcessEcs;
file?: FileEcs;
system?: SystemEcs;
threat?: ThreatEcs;
// This should be temporary
eql?: { parentId: string; sequenceNumber: string };
Ransomware?: Ransomware;

View file

@ -9,7 +9,7 @@ export interface RuleEcs {
id?: string[];
rule_id?: string[];
name?: string[];
false_positives: string[];
false_positives?: string[];
saved_id?: string[];
timeline_id?: string[];
timeline_title?: string[];

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EventEcs } from '../event';
interface ThreatMatchEcs {
atomic?: string[];
field?: string[];
type?: string[];
}
export interface ThreatIndicatorEcs {
matched?: ThreatMatchEcs;
event?: EventEcs & { reference?: string[] };
provider?: string[];
type?: string[];
}
export interface ThreatEcs {
indicator: ThreatIndicatorEcs[];
}

View file

@ -26,7 +26,9 @@ export interface EventsActionGroupData {
doc_count: number;
}
export type Fields = Record<string, unknown[] | Fields[]>;
export interface Fields<T = unknown[]> {
[x: string]: T | Array<Fields<T>>;
}
export interface EventHit extends SearchHit {
sort: string[];

View file

@ -206,6 +206,7 @@ export enum RowRendererId {
system_fim = 'system_fim',
system_security_event = 'system_security_event',
system_socket = 'system_socket',
threat_match = 'threat_match',
zeek = 'zeek',
}

View file

@ -10,6 +10,7 @@ export * from './header';
export * from './hook_wrapper';
export * from './index_pattern';
export * from './mock_detail_item';
export * from './mock_detection_alerts';
export * from './mock_ecs';
export * from './mock_local_storage';
export * from './mock_timeline_data';

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Ecs } from '../../../common/ecs';
import { TimelineNonEcsData } from '../../../common/search_strategy';
export const mockEcsDataWithAlert: Ecs = {
_id: '1',
timestamp: '2018-11-05T19:03:25.937Z',
host: {
name: ['apache'],
ip: ['192.168.0.1'],
},
event: {
id: ['1'],
action: ['Action'],
category: ['Access'],
module: ['nginx'],
severity: [3],
},
source: {
ip: ['192.168.0.1'],
port: [80],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
user: {
id: ['1'],
name: ['john.dee'],
},
geo: {
region_name: ['xx'],
country_iso_code: ['xx'],
},
signal: {
rule: {
created_at: ['2020-01-10T21:11:45.839Z'],
updated_at: ['2020-01-10T21:11:45.839Z'],
created_by: ['elastic'],
description: ['24/7'],
enabled: [true],
false_positives: ['test-1'],
filters: [],
from: ['now-300s'],
id: ['b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
immutable: [false],
index: ['auditbeat-*'],
interval: ['5m'],
rule_id: ['rule-id-1'],
language: ['kuery'],
output_index: ['.siem-signals-default'],
max_signals: [100],
risk_score: ['21'],
query: ['user.name: root or user.name: admin'],
references: ['www.test.co'],
saved_id: ["Garrett's IP"],
timeline_id: ['1234-2136-11ea-9864-ebc8cc1cb8c2'],
timeline_title: ['Untitled timeline'],
severity: ['low'],
updated_by: ['elastic'],
tags: [],
to: ['now'],
type: ['saved_query'],
threat: [],
note: ['# this is some markdown documentation'],
version: ['1'],
},
},
};
export const getDetectionAlertMock = (overrides: Partial<Ecs> = {}): Ecs => ({
...mockEcsDataWithAlert,
...overrides,
});
export const getThreatMatchDetectionAlert = (overrides: Partial<Ecs> = {}): Ecs => ({
...mockEcsDataWithAlert,
signal: {
...mockEcsDataWithAlert.signal,
rule: {
...mockEcsDataWithAlert.rule,
name: ['mock threat_match rule'],
type: ['threat_match'],
},
},
threat: {
indicator: [
{
matched: {
atomic: ['matched.atomic'],
field: ['matched.atomic'],
type: ['matched.domain'],
},
},
],
},
...overrides,
});
export const getDetectionAlertFieldsMock = (
fields: TimelineNonEcsData[] = []
): TimelineNonEcsData[] => [
{ field: '@timestamp', value: ['2021-03-27T06:28:47.292Z'] },
{ field: 'signal.rule.type', value: ['threat_match'] },
...fields,
];

View file

@ -1026,69 +1026,3 @@ export const mockEcsData: Ecs[] = [
},
},
];
export const mockEcsDataWithAlert: Ecs = {
_id: '1',
timestamp: '2018-11-05T19:03:25.937Z',
host: {
name: ['apache'],
ip: ['192.168.0.1'],
},
event: {
id: ['1'],
action: ['Action'],
category: ['Access'],
module: ['nginx'],
severity: [3],
},
source: {
ip: ['192.168.0.1'],
port: [80],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
user: {
id: ['1'],
name: ['john.dee'],
},
geo: {
region_name: ['xx'],
country_iso_code: ['xx'],
},
signal: {
rule: {
created_at: ['2020-01-10T21:11:45.839Z'],
updated_at: ['2020-01-10T21:11:45.839Z'],
created_by: ['elastic'],
description: ['24/7'],
enabled: [true],
false_positives: ['test-1'],
filters: [],
from: ['now-300s'],
id: ['b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
immutable: [false],
index: ['auditbeat-*'],
interval: ['5m'],
rule_id: ['rule-id-1'],
language: ['kuery'],
output_index: ['.siem-signals-default'],
max_signals: [100],
risk_score: ['21'],
query: ['user.name: root or user.name: admin'],
references: ['www.test.co'],
saved_id: ["Garrett's IP"],
timeline_id: ['1234-2136-11ea-9864-ebc8cc1cb8c2'],
timeline_title: ['Untitled timeline'],
severity: ['low'],
updated_by: ['elastic'],
tags: [],
to: ['now'],
type: ['saved_query'],
threat: [],
note: ['# this is some markdown documentation'],
version: ['1'],
},
},
};

View file

@ -1088,6 +1088,30 @@ export const mockTimelineData: TimelineItem[] = [
geo: { region_name: ['xx'], country_iso_code: ['xx'] },
},
},
{
_id: '32',
data: [],
ecs: {
_id: 'BuBP4W0BOpWiDweSoYSg',
timestamp: '2019-10-18T23:59:15.091Z',
threat: {
indicator: [
{
matched: {
atomic: ['192.168.1.1'],
field: ['source.ip'],
type: ['ip'],
},
event: {
dataset: ['threatintel.example_dataset'],
reference: ['https://example.com'],
},
provider: ['indicator_provider'],
},
],
},
},
},
];
export const mockFimFileCreatedEvent: Ecs = {

View file

@ -1699,6 +1699,12 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "threat_match",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{ "name": "zeek", "description": "", "isDeprecated": false, "deprecationReason": null }
],
"possibleTypes": null

View file

@ -298,6 +298,7 @@ export enum RowRendererId {
system_fim = 'system_fim',
system_security_event = 'system_security_event',
system_socket = 'system_socket',
threat_match = 'threat_match',
zeek = 'zeek',
}

View file

@ -24,6 +24,7 @@ import {
SystemFimExample,
SystemSecurityEventExample,
SystemSocketExample,
ThreatMatchExample,
ZeekExample,
} from '../examples';
import * as i18n from './translations';
@ -204,6 +205,13 @@ export const renderers: RowRendererOption[] = [
example: SuricataExample,
searchableDescription: `${i18n.SURICATA_DESCRIPTION_PART1} ${i18n.SURICATA_NAME} ${i18n.SURICATA_DESCRIPTION_PART2}`,
},
{
id: RowRendererId.threat_match,
name: i18n.THREAT_MATCH_NAME,
description: i18n.THREAT_MATCH_DESCRIPTION,
example: ThreatMatchExample,
searchableDescription: `${i18n.THREAT_MATCH_NAME} ${i18n.THREAT_MATCH_DESCRIPTION}`,
},
{
id: RowRendererId.zeek,
name: i18n.ZEEK_NAME,

View file

@ -230,6 +230,19 @@ export const SYSTEM_DESCRIPTION_PART3 = i18n.translate(
'All datasets send both periodic state information (e.g. all currently running processes) and real-time changes (e.g. when a new process starts or stops).',
}
);
export const THREAT_MATCH_NAME = i18n.translate(
'xpack.securitySolution.eventRenderers.threatMatchName',
{
defaultMessage: 'Threat Indicator Match',
}
);
export const THREAT_MATCH_DESCRIPTION = i18n.translate(
'xpack.securitySolution.eventRenderers.threatMatchDescription',
{
defaultMessage: 'Summarizes events that matched threat indicators',
}
);
export const ZEEK_NAME = i18n.translate('xpack.securitySolution.eventRenderers.zeekName', {
defaultMessage: 'Zeek (formerly Bro)',

View file

@ -19,4 +19,5 @@ export * from './system_file';
export * from './system_fim';
export * from './system_security_event';
export * from './system_socket';
export * from './threat_match';
export * from './zeek';

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mockTimelineData } from '../../../../common/mock/mock_timeline_data';
import { threatMatchRowRenderer } from '../../timeline/body/renderers/cti/threat_match_row_renderer';
import { ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID } from '../constants';
const ThreatMatchExampleComponent: React.FC = () => (
<>
{threatMatchRowRenderer.renderRow({
browserFields: {},
data: mockTimelineData[31].ecs,
timelineId: ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID,
})}
</>
);
export const ThreatMatchExample = React.memo(ThreatMatchExampleComponent);

View file

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThreatMatchRowView matches the registered snapshot 1`] = `
<EuiFlexGroup
alignItems="center"
data-test-subj="threat-match-row"
gutterSize="s"
justifyContent="center"
>
<EuiFlexItem
grow={false}
>
<MatchDetails
contextId="contextId"
eventId="eventId"
sourceField="host.name"
sourceValue="http://elastic.co"
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<IndicatorDetails
contextId="contextId"
eventId="eventId"
indicatorDataset="dataset"
indicatorProvider="provider"
indicatorReference="http://example.com"
indicatorType="domain"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`threatMatchRowRenderer #renderRow renders correctly against snapshot 1`] = `
<span>
<RowRendererContainer
data-test-subj="threat-match-row-renderer"
>
<styled.div>
<ThreatMatchRow
contextId="threat-match-row-test-1-0"
data={
Object {
"matched": Object {
"atomic": Array [
"matched.atomic",
],
"field": Array [
"matched.atomic",
],
"type": Array [
"matched.domain",
],
},
}
}
eventId="1"
/>
</styled.div>
</RowRendererContainer>
</span>
`;

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get, isEmpty } from 'lodash';
import styled from 'styled-components';
import { INDICATOR_DESTINATION_PATH } from '../../../../../../../common/constants';
import { INDICATOR_MATCH_SUBFIELDS } from '../../../../../../../common/cti/constants';
import { Ecs } from '../../../../../../../common/ecs';
import { ThreatIndicatorEcs } from '../../../../../../../common/ecs/threat';
const getIndicatorEcs = (data: Ecs): ThreatIndicatorEcs[] =>
get(data, INDICATOR_DESTINATION_PATH) ?? [];
export const hasThreatMatchValue = (data: Ecs): boolean =>
getIndicatorEcs(data).some((indicator) =>
INDICATOR_MATCH_SUBFIELDS.some(
(indicatorMatchSubField) => !isEmpty(get(indicator, indicatorMatchSubField))
)
);
export const HorizontalSpacer = styled.div`
margin: 0 ${({ theme }) => theme.eui.paddingSizes.xs};
`;

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import {
INDICATOR_DATASET,
INDICATOR_MATCHED_TYPE,
INDICATOR_PROVIDER,
INDICATOR_REFERENCE,
} from '../../../../../../../common/cti/constants';
import { DraggableBadge } from '../../../../../../common/components/draggables';
import { FormattedFieldValue } from '../formatted_field';
import { HorizontalSpacer } from './helpers';
interface IndicatorDetailsProps {
contextId: string;
eventId: string;
indicatorDataset: string | undefined;
indicatorProvider: string | undefined;
indicatorReference: string | undefined;
indicatorType: string | undefined;
}
export const IndicatorDetails: React.FC<IndicatorDetailsProps> = ({
contextId,
eventId,
indicatorDataset,
indicatorProvider,
indicatorReference,
indicatorType,
}) => (
<EuiFlexGroup
alignItems="flexStart"
data-test-subj="threat-match-indicator-details"
direction="row"
justifyContent="center"
gutterSize="none"
wrap
>
{indicatorType && (
<EuiFlexItem grow={false}>
<DraggableBadge
contextId={contextId}
data-test-subj="threat-match-indicator-details-indicator-type"
eventId={eventId}
field={INDICATOR_MATCHED_TYPE}
value={indicatorType}
/>
</EuiFlexItem>
)}
{indicatorDataset && (
<>
<EuiFlexItem grow={false}>
<HorizontalSpacer>
<FormattedMessage
defaultMessage="from"
id="xpack.securitySolution.alerts.rowRenderers.cti.threatMatch.datasetPreposition"
/>
</HorizontalSpacer>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DraggableBadge
contextId={contextId}
data-test-subj="threat-match-indicator-details-indicator-dataset"
eventId={eventId}
field={INDICATOR_DATASET}
value={indicatorDataset}
/>
</EuiFlexItem>
</>
)}
{indicatorProvider && (
<>
<EuiFlexItem grow={false} component="span">
<HorizontalSpacer>
<FormattedMessage
defaultMessage="provided by"
id="xpack.securitySolution.alerts.rowRenderers.cti.threatMatch.providerPreposition"
/>
</HorizontalSpacer>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DraggableBadge
contextId={contextId}
data-test-subj="threat-match-indicator-details-indicator-provider"
eventId={eventId}
field={INDICATOR_PROVIDER}
value={indicatorProvider}
/>
</EuiFlexItem>
</>
)}
{indicatorReference && (
<>
<EuiFlexItem grow={false}>
<HorizontalSpacer>{':'}</HorizontalSpacer>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FormattedFieldValue
contextId={contextId}
data-test-subj="threat-match-indicator-details-indicator-reference"
eventId={eventId}
fieldName={INDICATOR_REFERENCE}
value={indicatorReference}
/>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
);

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { INDICATOR_MATCHED_FIELD } from '../../../../../../../common/cti/constants';
import { DraggableBadge } from '../../../../../../common/components/draggables';
import { HorizontalSpacer } from './helpers';
interface MatchDetailsProps {
contextId: string;
eventId: string;
sourceField: string;
sourceValue: string;
}
export const MatchDetails: React.FC<MatchDetailsProps> = ({
contextId,
eventId,
sourceField,
sourceValue,
}) => (
<EuiFlexGroup
alignItems="center"
data-test-subj="threat-match-details"
direction="row"
justifyContent="center"
gutterSize="none"
wrap
>
<EuiFlexItem grow={false}>
<DraggableBadge
contextId={contextId}
data-test-subj="threat-match-details-source-field"
eventId={eventId}
field={INDICATOR_MATCHED_FIELD}
value={sourceField}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<HorizontalSpacer>
<FormattedMessage
defaultMessage="matched"
id="xpack.securitySolution.alerts.rowRenderers.cti.threatMatch.matchedVerb"
/>
</HorizontalSpacer>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DraggableBadge
contextId={contextId}
data-test-subj="threat-match-details-source-value"
eventId={eventId}
field={sourceField}
value={sourceValue}
/>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -0,0 +1,187 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { TestProviders } from '../../../../../../common/mock';
import { useMountAppended } from '../../../../../../common/utils/use_mount_appended';
import { ThreatMatchRowProps, ThreatMatchRowView } from './threat_match_row';
describe('ThreatMatchRowView', () => {
const mount = useMountAppended();
it('renders an indicator match row', () => {
const wrapper = shallow(
<ThreatMatchRowView
contextId="contextId"
eventId="eventId"
indicatorDataset="dataset"
indicatorProvider="provider"
indicatorReference="http://example.com"
indicatorType="domain"
sourceField="host.name"
sourceValue="http://elastic.co"
/>
);
expect(wrapper.find('[data-test-subj="threat-match-row"]').exists()).toEqual(true);
});
it('matches the registered snapshot', () => {
const wrapper = shallow(
<ThreatMatchRowView
contextId="contextId"
eventId="eventId"
indicatorDataset="dataset"
indicatorProvider="provider"
indicatorReference="http://example.com"
indicatorType="domain"
sourceField="host.name"
sourceValue="http://elastic.co"
/>
);
expect(wrapper).toMatchSnapshot();
});
describe('field rendering', () => {
let baseProps: ThreatMatchRowProps;
const render = (props: ThreatMatchRowProps) =>
mount(
<TestProviders>
<ThreatMatchRowView {...props} />
</TestProviders>
);
beforeEach(() => {
baseProps = {
contextId: 'contextId',
eventId: 'eventId',
indicatorDataset: 'dataset',
indicatorProvider: 'provider',
indicatorReference: 'http://example.com',
indicatorType: 'domain',
sourceField: 'host.name',
sourceValue: 'http://elastic.co',
};
});
it('renders the match field', () => {
const wrapper = render(baseProps);
const matchField = wrapper.find('[data-test-subj="threat-match-details-source-field"]');
expect(matchField.props()).toEqual(
expect.objectContaining({
value: 'host.name',
})
);
});
it('renders the match value', () => {
const wrapper = render(baseProps);
const matchValue = wrapper.find('[data-test-subj="threat-match-details-source-value"]');
expect(matchValue.props()).toEqual(
expect.objectContaining({
field: 'host.name',
value: 'http://elastic.co',
})
);
});
it('renders the indicator type, if present', () => {
const wrapper = render(baseProps);
const indicatorType = wrapper.find(
'[data-test-subj="threat-match-indicator-details-indicator-type"]'
);
expect(indicatorType.props()).toEqual(
expect.objectContaining({
value: 'domain',
})
);
});
it('does not render the indicator type, if absent', () => {
const wrapper = render({
...baseProps,
indicatorType: undefined,
});
const indicatorType = wrapper.find(
'[data-test-subj="threat-match-indicator-details-indicator-type"]'
);
expect(indicatorType.exists()).toBeFalsy();
});
it('renders the indicator dataset, if present', () => {
const wrapper = render(baseProps);
const indicatorDataset = wrapper.find(
'[data-test-subj="threat-match-indicator-details-indicator-dataset"]'
);
expect(indicatorDataset.props()).toEqual(
expect.objectContaining({
value: 'dataset',
})
);
});
it('does not render the indicator dataset, if absent', () => {
const wrapper = render({
...baseProps,
indicatorDataset: undefined,
});
const indicatorDataset = wrapper.find(
'[data-test-subj="threat-match-indicator-details-indicator-dataset"]'
);
expect(indicatorDataset.exists()).toBeFalsy();
});
it('renders the indicator provider, if present', () => {
const wrapper = render(baseProps);
const indicatorProvider = wrapper.find(
'[data-test-subj="threat-match-indicator-details-indicator-provider"]'
);
expect(indicatorProvider.props()).toEqual(
expect.objectContaining({
value: 'provider',
})
);
});
it('does not render the indicator provider, if absent', () => {
const wrapper = render({
...baseProps,
indicatorProvider: undefined,
});
const indicatorProvider = wrapper.find(
'[data-test-subj="threat-match-indicator-details-indicator-provider"]'
);
expect(indicatorProvider.exists()).toBeFalsy();
});
it('renders the indicator reference, if present', () => {
const wrapper = render(baseProps);
const indicatorReference = wrapper.find(
'[data-test-subj="threat-match-indicator-details-indicator-reference"]'
);
expect(indicatorReference.props()).toEqual(
expect.objectContaining({
value: 'http://example.com',
})
);
});
it('does not render the indicator reference, if absent', () => {
const wrapper = render({
...baseProps,
indicatorReference: undefined,
});
const indicatorReference = wrapper.find(
'[data-test-subj="threat-match-indicator-details-indicator-reference"]'
);
expect(indicatorReference.exists()).toBeFalsy();
});
});
});

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Fields } from '../../../../../../../common/search_strategy';
import {
EVENT_DATASET,
EVENT_REFERENCE,
MATCHED_ATOMIC,
MATCHED_FIELD,
MATCHED_TYPE,
PROVIDER,
} from '../../../../../../../common/cti/constants';
import { MatchDetails } from './match_details';
import { IndicatorDetails } from './indicator_details';
export interface ThreatMatchRowProps {
contextId: string;
eventId: string;
indicatorDataset: string | undefined;
indicatorProvider: string | undefined;
indicatorReference: string | undefined;
indicatorType: string | undefined;
sourceField: string;
sourceValue: string;
}
export const ThreatMatchRow = ({
contextId,
data,
eventId,
}: {
contextId: string;
data: Fields;
eventId: string;
}) => {
const props = {
contextId,
eventId,
indicatorDataset: get(data, EVENT_DATASET)[0] as string | undefined,
indicatorReference: get(data, EVENT_REFERENCE)[0] as string | undefined,
indicatorProvider: get(data, PROVIDER)[0] as string | undefined,
indicatorType: get(data, MATCHED_TYPE)[0] as string | undefined,
sourceField: get(data, MATCHED_FIELD)[0] as string,
sourceValue: get(data, MATCHED_ATOMIC)[0] as string,
};
return <ThreatMatchRowView {...props} />;
};
export const ThreatMatchRowView = ({
contextId,
eventId,
indicatorDataset,
indicatorProvider,
indicatorReference,
indicatorType,
sourceField,
sourceValue,
}: ThreatMatchRowProps) => {
return (
<EuiFlexGroup
alignItems="center"
data-test-subj="threat-match-row"
gutterSize="s"
justifyContent="center"
>
<EuiFlexItem grow={false}>
<MatchDetails
contextId={contextId}
eventId={eventId}
sourceField={sourceField}
sourceValue={sourceValue}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<IndicatorDetails
contextId={contextId}
eventId={eventId}
indicatorDataset={indicatorDataset}
indicatorProvider={indicatorProvider}
indicatorReference={indicatorReference}
indicatorType={indicatorType}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { getThreatMatchDetectionAlert } from '../../../../../../common/mock';
import { threatMatchRowRenderer } from './threat_match_row_renderer';
describe('threatMatchRowRenderer', () => {
let threatMatchData: ReturnType<typeof getThreatMatchDetectionAlert>;
beforeEach(() => {
threatMatchData = getThreatMatchDetectionAlert();
});
describe('#isInstance', () => {
it('is false for an empty event', () => {
const emptyEvent = {
_id: 'my_id',
'@timestamp': ['2020-11-17T14:48:08.922Z'],
};
expect(threatMatchRowRenderer.isInstance(emptyEvent)).toBe(false);
});
it('is false for an alert with indicator data but no match', () => {
const indicatorTypeData = getThreatMatchDetectionAlert({
threat: {
indicator: [{ type: ['url'] }],
},
});
expect(threatMatchRowRenderer.isInstance(indicatorTypeData)).toBe(false);
});
it('is false for an alert with threat match fields but no data', () => {
const emptyThreatMatchData = getThreatMatchDetectionAlert({
threat: {
indicator: [{ matched: { type: [] } }],
},
});
expect(threatMatchRowRenderer.isInstance(emptyThreatMatchData)).toBe(false);
});
it('is true for an alert event with present indicator match fields', () => {
expect(threatMatchRowRenderer.isInstance(threatMatchData)).toBe(true);
});
});
describe('#renderRow', () => {
it('renders correctly against snapshot', () => {
const children = threatMatchRowRenderer.renderRow({
browserFields: {},
data: threatMatchData,
timelineId: 'test',
});
const wrapper = shallow(<span>{children}</span>);
expect(wrapper).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RowRendererId } from '../../../../../../../common/types/timeline';
import { RowRenderer } from '../row_renderer';
import { hasThreatMatchValue } from './helpers';
import { ThreatMatchRows } from './threat_match_rows';
export const threatMatchRowRenderer: RowRenderer = {
id: RowRendererId.threat_match,
isInstance: hasThreatMatchValue,
renderRow: ThreatMatchRows,
};

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiHorizontalRule } from '@elastic/eui';
import { get } from 'lodash';
import React, { Fragment } from 'react';
import styled from 'styled-components';
import { Fields } from '../../../../../../../common/search_strategy';
import { ID_FIELD_NAME } from '../../../../../../common/components/event_details/event_id';
import { RowRenderer, RowRendererContainer } from '../row_renderer';
import { ThreatMatchRow } from './threat_match_row';
const SpacedContainer = styled.div`
margin: ${({ theme }) => theme.eui.paddingSizes.s} 0;
`;
export const ThreatMatchRows: RowRenderer['renderRow'] = ({ data, timelineId }) => {
const indicators = get(data, 'threat.indicator') as Fields[];
const eventId = get(data, ID_FIELD_NAME);
return (
<RowRendererContainer data-test-subj="threat-match-row-renderer">
<SpacedContainer>
{indicators.map((indicator, index) => {
const contextId = `threat-match-row-${timelineId}-${eventId}-${index}`;
return (
<Fragment key={contextId}>
<ThreatMatchRow contextId={contextId} data={indicator} eventId={eventId} />
{index < indicators.length - 1 && <EuiHorizontalRule margin="s" />}
</Fragment>
);
})}
</SpacedContainer>
</RowRendererContainer>
);
};

View file

@ -9,6 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { isNumber, isEmpty } from 'lodash/fp';
import React from 'react';
import { INDICATOR_REFERENCE } from '../../../../../../common/cti/constants';
import { DefaultDraggable } from '../../../../../common/components/draggables';
import { Bytes, BYTES_FORMAT } from './bytes';
import { Duration, EVENT_DURATION_FIELD_NAME } from '../../../duration';
@ -116,7 +117,12 @@ const FormattedFieldValueComponent: React.FC<{
<RuleStatus contextId={contextId} eventId={eventId} fieldName={fieldName} value={value} />
);
} else if (
[RULE_REFERENCE_FIELD_NAME, REFERENCE_URL_FIELD_NAME, EVENT_URL_FIELD_NAME].includes(fieldName)
[
RULE_REFERENCE_FIELD_NAME,
REFERENCE_URL_FIELD_NAME,
EVENT_URL_FIELD_NAME,
INDICATOR_REFERENCE,
].includes(fieldName)
) {
return renderUrl({ contextId, eventId, fieldName, linkValue, truncate, value });
} else if (columnNamesNotDraggable.includes(fieldName)) {

View file

@ -15,6 +15,7 @@ import { suricataRowRenderer } from './suricata/suricata_row_renderer';
import { unknownColumnRenderer } from './unknown_column_renderer';
import { zeekRowRenderer } from './zeek/zeek_row_renderer';
import { systemRowRenderers } from './system/generic_row_renderer';
import { threatMatchRowRenderer } from './cti/threat_match_row_renderer';
// The row renderers are order dependent and will return the first renderer
// which returns true from its isInstance call. The bottom renderers which
@ -24,6 +25,7 @@ import { systemRowRenderers } from './system/generic_row_renderer';
// plainRowRenderer always returns true to everything which is why it always
// should be last.
export const defaultRowRenderers: RowRenderer[] = [
threatMatchRowRenderer,
...auditdRowRenderers,
...systemRowRenderers,
suricataRowRenderer,

View file

@ -143,6 +143,11 @@ In other use cases the message field can be used to concatenate different values
renderCellValue={[Function]}
rowRenderers={
Array [
Object {
"id": "threat_match",
"isInstance": [Function],
"renderRow": [Function],
},
Object {
"id": "auditd",
"isInstance": [Function],

View file

@ -138,6 +138,11 @@ In other use cases the message field can be used to concatenate different values
renderCellValue={[Function]}
rowRenderers={
Array [
Object {
"id": "threat_match",
"isInstance": [Function],
"renderRow": [Function],
},
Object {
"id": "auditd",
"isInstance": [Function],

View file

@ -279,6 +279,11 @@ In other use cases the message field can be used to concatenate different values
renderCellValue={[Function]}
rowRenderers={
Array [
Object {
"id": "threat_match",
"isInstance": [Function],
"renderRow": [Function],
},
Object {
"id": "auditd",
"isInstance": [Function],

View file

@ -159,7 +159,7 @@ describe('useTimelineEvents', () => {
loadPage: result.current[1].loadPage,
pageInfo: result.current[1].pageInfo,
refetch: result.current[1].refetch,
totalCount: 31,
totalCount: 32,
updatedAt: result.current[1].updatedAt,
},
]);
@ -202,7 +202,7 @@ describe('useTimelineEvents', () => {
loadPage: result.current[1].loadPage,
pageInfo: result.current[1].pageInfo,
refetch: result.current[1].refetch,
totalCount: 31,
totalCount: 32,
updatedAt: result.current[1].updatedAt,
},
]);

View file

@ -171,6 +171,7 @@ export const timelineSchema = gql`
system_fim
system_security_event
system_socket
threat_match
zeek
}

View file

@ -300,6 +300,7 @@ export enum RowRendererId {
system_fim = 'system_fim',
system_security_event = 'system_security_event',
system_socket = 'system_socket',
threat_match = 'threat_match',
zeek = 'zeek',
}

View file

@ -5,14 +5,7 @@
* 2.0.
*/
export const TIMELINE_CTI_FIELDS = [
'threat.indicator.event.dataset',
'threat.indicator.event.reference',
'threat.indicator.matched.atomic',
'threat.indicator.matched.field',
'threat.indicator.matched.type',
'threat.indicator.provider',
];
import { CTI_ROW_RENDERER_FIELDS } from '../../../../../../common/cti/constants';
export const TIMELINE_EVENTS_FIELDS = [
'@timestamp',
@ -239,5 +232,5 @@ export const TIMELINE_EVENTS_FIELDS = [
'zeek.ssl.established',
'zeek.ssl.resumed',
'zeek.ssl.version',
...TIMELINE_CTI_FIELDS,
...CTI_ROW_RENDERER_FIELDS,
];