[Security Solution] Alerts details (#83963)

* init alert details tab

* styles

* readMore button

* readmore btn

* field mappings

* add unit tests

* unit test

* fix unit test

* functional test

* isolate lineClamp component

* review

* unit test

* fix rule name in events table

* originalvalue

* unit test

* add close event details button

* rollback cypress configs

* cypress

* close events details

* remove Ip

* review

* review

* review

* review

* review

* review

* review

* fix i18n check

* fix import

* fix eslint

* use connect

* close flyout when expanded event doesn't exist in the list

* Update x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx

Co-authored-by: Patryk Kopyciński <contact@patrykkopycinski.com>

* fix types

* unit test

* fix rule status badge

* isolate host name renderer

* fixup

* cypress

* cypress

* defaultModel

* review comments

* unit test

* replace findIndex with some

* review

* remove defaultModel from toggle event action

* review

* cleanup defaultModel

* unit test

* rollback handleClearSelection

* fixup

* fix i18n

* cleanup defaultmodel

* cleanup

* summary value

* fix showing timeline details

* layout

* fix timeline memoization

* fix long query

* styling

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Patryk Kopyciński <contact@patrykkopycinski.com>
This commit is contained in:
Angela Chuang 2020-12-12 08:24:32 +00:00 committed by GitHub
parent f28a80fd29
commit 7b32835226
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1618 additions and 177 deletions

View file

@ -9,6 +9,7 @@ import { Inspect, Maybe } from '../../../common';
import { TimelineRequestOptionsPaginated } from '../..';
export interface TimelineEventsDetailsItem {
category?: string;
field: string;
values?: Maybe<string[]>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -411,7 +411,6 @@ export type TimelineEventsType = 'all' | 'raw' | 'alert' | 'signal' | 'custom';
export interface TimelineExpandedEventType {
eventId: string;
indexName: string;
loading: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -4,14 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ALERT_ID } from '../screens/alerts';
import { PROVIDER_BADGE } from '../screens/timeline';
import {
expandFirstAlert,
investigateFirstAlertInTimeline,
waitForAlertsPanelToBeLoaded,
} from '../tasks/alerts';
import { investigateFirstAlertInTimeline, waitForAlertsPanelToBeLoaded } from '../tasks/alerts';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
import { loginAndWaitForPage } from '../tasks/login';
@ -29,13 +24,13 @@ describe('Alerts timeline', () => {
it('Investigate alert in default timeline', () => {
waitForAlertsPanelToBeLoaded();
expandFirstAlert();
cy.get(ALERT_ID)
investigateFirstAlertInTimeline();
cy.get(PROVIDER_BADGE)
.first()
.invoke('text')
.then((eventId) => {
investigateFirstAlertInTimeline();
cy.get(PROVIDER_BADGE).filter(':visible').should('have.text', `_id: "${eventId}"`);
cy.get(PROVIDER_BADGE).filter(':visible').should('have.text', eventId);
});
});
});

View file

@ -352,7 +352,6 @@ export const CaseComponent = React.memo<CaseProps>(
event: {
eventId: alertId,
indexName: index,
loading: false,
},
})
);

View file

@ -0,0 +1,657 @@
/*
* 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.
*/
export const mockAlertDetailsData = [
{ category: 'process', field: 'process.name', values: ['-'], originalValue: '-' },
{ category: 'process', field: 'process.pid', values: [0], originalValue: 0 },
{ category: 'process', field: 'process.executable', values: ['-'], originalValue: '-' },
{
category: 'agent',
field: 'agent.hostname',
values: ['windows-native'],
originalValue: 'windows-native',
},
{
category: 'agent',
field: 'agent.name',
values: ['windows-native'],
originalValue: 'windows-native',
},
{
category: 'agent',
field: 'agent.id',
values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
originalValue: 'abfe4a35-d5b4-42a0-a539-bd054c791769',
},
{ category: 'agent', field: 'agent.type', values: ['winlogbeat'], originalValue: 'winlogbeat' },
{
category: 'agent',
field: 'agent.ephemeral_id',
values: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'],
originalValue: 'b9850845-c000-4ddd-bd51-9978a07b7e7d',
},
{ category: 'agent', field: 'agent.version', values: ['7.10.0'], originalValue: '7.10.0' },
{
category: 'winlog',
field: 'winlog.computer_name',
values: ['windows-native'],
originalValue: 'windows-native',
},
{ category: 'winlog', field: 'winlog.process.pid', values: [624], originalValue: 624 },
{ category: 'winlog', field: 'winlog.process.thread.id', values: [1896], originalValue: 1896 },
{
category: 'winlog',
field: 'winlog.keywords',
values: ['Audit Failure'],
originalValue: ['Audit Failure'],
},
{
category: 'winlog',
field: 'winlog.logon.failure.reason',
values: ['Unknown user name or bad password.'],
originalValue: 'Unknown user name or bad password.',
},
{
category: 'winlog',
field: 'winlog.logon.failure.sub_status',
values: ['User logon with misspelled or bad password'],
originalValue: 'User logon with misspelled or bad password',
},
{
category: 'winlog',
field: 'winlog.logon.failure.status',
values: ['This is either due to a bad username or authentication information'],
originalValue: 'This is either due to a bad username or authentication information',
},
{ category: 'winlog', field: 'winlog.logon.id', values: ['0x0'], originalValue: '0x0' },
{ category: 'winlog', field: 'winlog.logon.type', values: ['Network'], originalValue: 'Network' },
{ category: 'winlog', field: 'winlog.channel', values: ['Security'], originalValue: 'Security' },
{
category: 'winlog',
field: 'winlog.event_data.Status',
values: ['0xc000006d'],
originalValue: '0xc000006d',
},
{ category: 'winlog', field: 'winlog.event_data.LogonType', values: ['3'], originalValue: '3' },
{
category: 'winlog',
field: 'winlog.event_data.SubjectLogonId',
values: ['0x0'],
originalValue: '0x0',
},
{
category: 'winlog',
field: 'winlog.event_data.TransmittedServices',
values: ['-'],
originalValue: '-',
},
{
category: 'winlog',
field: 'winlog.event_data.LmPackageName',
values: ['-'],
originalValue: '-',
},
{ category: 'winlog', field: 'winlog.event_data.KeyLength', values: ['0'], originalValue: '0' },
{
category: 'winlog',
field: 'winlog.event_data.SubjectUserName',
values: ['-'],
originalValue: '-',
},
{
category: 'winlog',
field: 'winlog.event_data.FailureReason',
values: ['%%2313'],
originalValue: '%%2313',
},
{
category: 'winlog',
field: 'winlog.event_data.SubjectDomainName',
values: ['-'],
originalValue: '-',
},
{
category: 'winlog',
field: 'winlog.event_data.TargetUserName',
values: ['administrator'],
originalValue: 'administrator',
},
{
category: 'winlog',
field: 'winlog.event_data.SubStatus',
values: ['0xc000006a'],
originalValue: '0xc000006a',
},
{
category: 'winlog',
field: 'winlog.event_data.LogonProcessName',
values: ['NtLmSsp '],
originalValue: 'NtLmSsp ',
},
{
category: 'winlog',
field: 'winlog.event_data.SubjectUserSid',
values: ['S-1-0-0'],
originalValue: 'S-1-0-0',
},
{
category: 'winlog',
field: 'winlog.event_data.AuthenticationPackageName',
values: ['NTLM'],
originalValue: 'NTLM',
},
{
category: 'winlog',
field: 'winlog.event_data.TargetUserSid',
values: ['S-1-0-0'],
originalValue: 'S-1-0-0',
},
{ category: 'winlog', field: 'winlog.opcode', values: ['Info'], originalValue: 'Info' },
{ category: 'winlog', field: 'winlog.record_id', values: [890770], originalValue: 890770 },
{ category: 'winlog', field: 'winlog.task', values: ['Logon'], originalValue: 'Logon' },
{ category: 'winlog', field: 'winlog.event_id', values: [4625], originalValue: 4625 },
{
category: 'winlog',
field: 'winlog.provider_guid',
values: ['{54849625-5478-4994-a5ba-3e3b0328c30d}'],
originalValue: '{54849625-5478-4994-a5ba-3e3b0328c30d}',
},
{
category: 'winlog',
field: 'winlog.activity_id',
values: ['{e148a943-f9c4-0001-5a39-81b88bbed601}'],
originalValue: '{e148a943-f9c4-0001-5a39-81b88bbed601}',
},
{
category: 'winlog',
field: 'winlog.api',
values: ['wineventlog'],
originalValue: 'wineventlog',
},
{
category: 'winlog',
field: 'winlog.provider_name',
values: ['Microsoft-Windows-Security-Auditing'],
originalValue: 'Microsoft-Windows-Security-Auditing',
},
{ category: 'log', field: 'log.level', values: ['information'], originalValue: 'information' },
{ category: 'source', field: 'source.port', values: [0], originalValue: 0 },
{ category: 'source', field: 'source.domain', values: ['-'], originalValue: '-' },
{
category: 'source',
field: 'source.ip',
values: ['185.156.74.3'],
originalValue: '185.156.74.3',
},
{
category: 'base',
field: 'message',
values: [
'An account failed to log on.\n\nSubject:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\t-\n\tAccount Domain:\t\t-\n\tLogon ID:\t\t0x0\n\nLogon Type:\t\t\t3\n\nAccount For Which Logon Failed:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\tadministrator\n\tAccount Domain:\t\t\n\nFailure Information:\n\tFailure Reason:\t\tUnknown user name or bad password.\n\tStatus:\t\t\t0xC000006D\n\tSub Status:\t\t0xC000006A\n\nProcess Information:\n\tCaller Process ID:\t0x0\n\tCaller Process Name:\t-\n\nNetwork Information:\n\tWorkstation Name:\t-\n\tSource Network Address:\t185.156.74.3\n\tSource Port:\t\t0\n\nDetailed Authentication Information:\n\tLogon Process:\t\tNtLmSsp \n\tAuthentication Package:\tNTLM\n\tTransited Services:\t-\n\tPackage Name (NTLM only):\t-\n\tKey Length:\t\t0\n\nThis event is generated when a logon request fails. It is generated on the computer where access was attempted.\n\nThe Subject fields indicate the account on the local system which requested the logon. This is most commonly a service such as the Server service, or a local process such as Winlogon.exe or Services.exe.\n\nThe Logon Type field indicates the kind of logon that was requested. The most common types are 2 (interactive) and 3 (network).\n\nThe Process Information fields indicate which account and process on the system requested the logon.\n\nThe Network Information fields indicate where a remote logon request originated. Workstation name is not always available and may be left blank in some cases.\n\nThe authentication information fields provide detailed information about this specific logon request.\n\t- Transited services indicate which intermediate services have participated in this logon request.\n\t- Package name indicates which sub-protocol was used among the NTLM protocols.\n\t- Key length indicates the length of the generated session key. This will be 0 if no session key was requested.',
],
originalValue:
'An account failed to log on.\n\nSubject:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\t-\n\tAccount Domain:\t\t-\n\tLogon ID:\t\t0x0\n\nLogon Type:\t\t\t3\n\nAccount For Which Logon Failed:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\tadministrator\n\tAccount Domain:\t\t\n\nFailure Information:\n\tFailure Reason:\t\tUnknown user name or bad password.\n\tStatus:\t\t\t0xC000006D\n\tSub Status:\t\t0xC000006A\n\nProcess Information:\n\tCaller Process ID:\t0x0\n\tCaller Process Name:\t-\n\nNetwork Information:\n\tWorkstation Name:\t-\n\tSource Network Address:\t185.156.74.3\n\tSource Port:\t\t0\n\nDetailed Authentication Information:\n\tLogon Process:\t\tNtLmSsp \n\tAuthentication Package:\tNTLM\n\tTransited Services:\t-\n\tPackage Name (NTLM only):\t-\n\tKey Length:\t\t0\n\nThis event is generated when a logon request fails. It is generated on the computer where access was attempted.\n\nThe Subject fields indicate the account on the local system which requested the logon. This is most commonly a service such as the Server service, or a local process such as Winlogon.exe or Services.exe.\n\nThe Logon Type field indicates the kind of logon that was requested. The most common types are 2 (interactive) and 3 (network).\n\nThe Process Information fields indicate which account and process on the system requested the logon.\n\nThe Network Information fields indicate where a remote logon request originated. Workstation name is not always available and may be left blank in some cases.\n\nThe authentication information fields provide detailed information about this specific logon request.\n\t- Transited services indicate which intermediate services have participated in this logon request.\n\t- Package name indicates which sub-protocol was used among the NTLM protocols.\n\t- Key length indicates the length of the generated session key. This will be 0 if no session key was requested.',
},
{
category: 'cloud',
field: 'cloud.availability_zone',
values: ['us-central1-a'],
originalValue: 'us-central1-a',
},
{
category: 'cloud',
field: 'cloud.instance.name',
values: ['windows-native'],
originalValue: 'windows-native',
},
{
category: 'cloud',
field: 'cloud.instance.id',
values: ['5896613765949631815'],
originalValue: '5896613765949631815',
},
{ category: 'cloud', field: 'cloud.provider', values: ['gcp'], originalValue: 'gcp' },
{
category: 'cloud',
field: 'cloud.machine.type',
values: ['e2-medium'],
originalValue: 'e2-medium',
},
{
category: 'cloud',
field: 'cloud.project.id',
values: ['elastic-siem'],
originalValue: 'elastic-siem',
},
{
category: 'base',
field: '@timestamp',
values: ['2020-11-25T15:42:39.417Z'],
originalValue: '2020-11-25T15:42:39.417Z',
},
{
category: 'related',
field: 'related.user',
values: ['administrator'],
originalValue: 'administrator',
},
{ category: 'ecs', field: 'ecs.version', values: ['1.5.0'], originalValue: '1.5.0' },
{
category: 'host',
field: 'host.hostname',
values: ['windows-native'],
originalValue: 'windows-native',
},
{ category: 'host', field: 'host.os.build', values: ['17763.1577'], originalValue: '17763.1577' },
{
category: 'host',
field: 'host.os.kernel',
values: ['10.0.17763.1577 (WinBuild.160101.0800)'],
originalValue: '10.0.17763.1577 (WinBuild.160101.0800)',
},
{
category: 'host',
field: 'host.os.name',
values: ['Windows Server 2019 Datacenter'],
originalValue: 'Windows Server 2019 Datacenter',
},
{ category: 'host', field: 'host.os.family', values: ['windows'], originalValue: 'windows' },
{ category: 'host', field: 'host.os.version', values: ['10.0'], originalValue: '10.0' },
{ category: 'host', field: 'host.os.platform', values: ['windows'], originalValue: 'windows' },
{
category: 'host',
field: 'host.ip',
values: ['fe80::406c:d205:5b46:767f', '10.128.15.228'],
originalValue: ['fe80::406c:d205:5b46:767f', '10.128.15.228'],
},
{
category: 'host',
field: 'host.name',
values: ['windows-native'],
originalValue: 'windows-native',
},
{
category: 'host',
field: 'host.id',
values: ['08f50e68-847a-4fae-a8eb-c7dc886447bb'],
originalValue: '08f50e68-847a-4fae-a8eb-c7dc886447bb',
},
{
category: 'host',
field: 'host.mac',
values: ['42:01:0a:80:0f:e4'],
originalValue: ['42:01:0a:80:0f:e4'],
},
{ category: 'host', field: 'host.architecture', values: ['x86_64'], originalValue: 'x86_64' },
{
category: 'event',
field: 'event.ingested',
values: ['2020-11-25T15:36:40.924914552Z'],
originalValue: '2020-11-25T15:36:40.924914552Z',
},
{ category: 'event', field: 'event.code', values: [4625], originalValue: 4625 },
{ category: 'event', field: 'event.lag.total', values: [2077], originalValue: 2077 },
{ category: 'event', field: 'event.lag.read', values: [1075], originalValue: 1075 },
{ category: 'event', field: 'event.lag.ingest', values: [1002], originalValue: 1002 },
{
category: 'event',
field: 'event.provider',
values: ['Microsoft-Windows-Security-Auditing'],
originalValue: 'Microsoft-Windows-Security-Auditing',
},
{
category: 'event',
field: 'event.created',
values: ['2020-11-25T15:36:39.922Z'],
originalValue: '2020-11-25T15:36:39.922Z',
},
{ category: 'event', field: 'event.kind', values: ['signal'], originalValue: 'signal' },
{ category: 'event', field: 'event.module', values: ['security'], originalValue: 'security' },
{
category: 'event',
field: 'event.action',
values: ['logon-failed'],
originalValue: 'logon-failed',
},
{ category: 'event', field: 'event.type', values: ['start'], originalValue: 'start' },
{
category: 'event',
field: 'event.category',
values: ['authentication'],
originalValue: 'authentication',
},
{ category: 'event', field: 'event.outcome', values: ['failure'], originalValue: 'failure' },
{
category: 'user',
field: 'user.name',
values: ['administrator'],
originalValue: 'administrator',
},
{ category: 'user', field: 'user.id', values: ['S-1-0-0'], originalValue: 'S-1-0-0' },
{
category: 'signal',
field: 'signal.parents',
values: [
'{"id":"688MAHYB7WTwW_Glsi_d","type":"event","index":"winlogbeat-7.10.0-2020.11.12-000001","depth":0}',
],
originalValue: [
{
id: '688MAHYB7WTwW_Glsi_d',
type: 'event',
index: 'winlogbeat-7.10.0-2020.11.12-000001',
depth: 0,
},
],
},
{
category: 'signal',
field: 'signal.ancestors',
values: [
'{"id":"688MAHYB7WTwW_Glsi_d","type":"event","index":"winlogbeat-7.10.0-2020.11.12-000001","depth":0}',
],
originalValue: [
{
id: '688MAHYB7WTwW_Glsi_d',
type: 'event',
index: 'winlogbeat-7.10.0-2020.11.12-000001',
depth: 0,
},
],
},
{ category: 'signal', field: 'signal.status', values: ['open'], originalValue: 'open' },
{
category: 'signal',
field: 'signal.rule.id',
values: ['b69d086c-325a-4f46-b17b-fb6d227006ba'],
originalValue: 'b69d086c-325a-4f46-b17b-fb6d227006ba',
},
{
category: 'signal',
field: 'signal.rule.rule_id',
values: ['e7cd9a53-ac62-44b5-bdec-9c94d85bb1a5'],
originalValue: 'e7cd9a53-ac62-44b5-bdec-9c94d85bb1a5',
},
{ category: 'signal', field: 'signal.rule.actions', values: [], originalValue: [] },
{ category: 'signal', field: 'signal.rule.author', values: [], originalValue: [] },
{ category: 'signal', field: 'signal.rule.false_positives', values: [], originalValue: [] },
{ category: 'signal', field: 'signal.rule.meta.from', values: ['1m'], originalValue: '1m' },
{
category: 'signal',
field: 'signal.rule.meta.kibana_siem_app_url',
values: ['http://localhost:5601/app/security'],
originalValue: 'http://localhost:5601/app/security',
},
{ category: 'signal', field: 'signal.rule.max_signals', values: [100], originalValue: 100 },
{ category: 'signal', field: 'signal.rule.risk_score', values: [21], originalValue: 21 },
{ category: 'signal', field: 'signal.rule.risk_score_mapping', values: [], originalValue: [] },
{
category: 'signal',
field: 'signal.rule.output_index',
values: ['.siem-signals-angelachuang-default'],
originalValue: '.siem-signals-angelachuang-default',
},
{ category: 'signal', field: 'signal.rule.description', values: ['xxx'], originalValue: 'xxx' },
{
category: 'signal',
field: 'signal.rule.from',
values: ['now-360s'],
originalValue: 'now-360s',
},
{
category: 'signal',
field: 'signal.rule.index',
values: [
'apm-*-transaction*',
'auditbeat-*',
'endgame-*',
'filebeat-*',
'logs-*',
'packetbeat-*',
'winlogbeat-*',
],
originalValue: [
'apm-*-transaction*',
'auditbeat-*',
'endgame-*',
'filebeat-*',
'logs-*',
'packetbeat-*',
'winlogbeat-*',
],
},
{ category: 'signal', field: 'signal.rule.interval', values: ['5m'], originalValue: '5m' },
{ category: 'signal', field: 'signal.rule.language', values: ['kuery'], originalValue: 'kuery' },
{ category: 'signal', field: 'signal.rule.license', values: [''], originalValue: '' },
{ category: 'signal', field: 'signal.rule.name', values: ['xxx'], originalValue: 'xxx' },
{
category: 'signal',
field: 'signal.rule.query',
values: ['@timestamp : * '],
originalValue: '@timestamp : * ',
},
{ category: 'signal', field: 'signal.rule.references', values: [], originalValue: [] },
{ category: 'signal', field: 'signal.rule.severity', values: ['low'], originalValue: 'low' },
{ category: 'signal', field: 'signal.rule.severity_mapping', values: [], originalValue: [] },
{ category: 'signal', field: 'signal.rule.tags', values: [], originalValue: [] },
{ category: 'signal', field: 'signal.rule.type', values: ['query'], originalValue: 'query' },
{ category: 'signal', field: 'signal.rule.to', values: ['now'], originalValue: 'now' },
{
category: 'signal',
field: 'signal.rule.filters',
values: [
'{"meta":{"alias":null,"negate":false,"disabled":false,"type":"exists","key":"message","value":"exists"},"exists":{"field":"message"},"$state":{"store":"appState"}}',
],
originalValue: [
{
meta: {
alias: null,
negate: false,
disabled: false,
type: 'exists',
key: 'message',
value: 'exists',
},
exists: { field: 'message' },
$state: { store: 'appState' },
},
],
},
{
category: 'signal',
field: 'signal.rule.created_by',
values: ['angela'],
originalValue: 'angela',
},
{
category: 'signal',
field: 'signal.rule.updated_by',
values: ['angela'],
originalValue: 'angela',
},
{ category: 'signal', field: 'signal.rule.threat', values: [], originalValue: [] },
{ category: 'signal', field: 'signal.rule.version', values: [2], originalValue: 2 },
{
category: 'signal',
field: 'signal.rule.created_at',
values: ['2020-11-24T10:30:33.660Z'],
originalValue: '2020-11-24T10:30:33.660Z',
},
{
category: 'signal',
field: 'signal.rule.updated_at',
values: ['2020-11-25T15:37:40.939Z'],
originalValue: '2020-11-25T15:37:40.939Z',
},
{ category: 'signal', field: 'signal.rule.exceptions_list', values: [], originalValue: [] },
{ category: 'signal', field: 'signal.depth', values: [1], originalValue: 1 },
{
category: 'signal',
field: 'signal.parent.id',
values: ['688MAHYB7WTwW_Glsi_d'],
originalValue: '688MAHYB7WTwW_Glsi_d',
},
{ category: 'signal', field: 'signal.parent.type', values: ['event'], originalValue: 'event' },
{
category: 'signal',
field: 'signal.parent.index',
values: ['winlogbeat-7.10.0-2020.11.12-000001'],
originalValue: 'winlogbeat-7.10.0-2020.11.12-000001',
},
{ category: 'signal', field: 'signal.parent.depth', values: [0], originalValue: 0 },
{
category: 'signal',
field: 'signal.original_time',
values: ['2020-11-25T15:36:38.847Z'],
originalValue: '2020-11-25T15:36:38.847Z',
},
{
category: 'signal',
field: 'signal.original_event.ingested',
values: ['2020-11-25T15:36:40.924914552Z'],
originalValue: '2020-11-25T15:36:40.924914552Z',
},
{ category: 'signal', field: 'signal.original_event.code', values: [4625], originalValue: 4625 },
{
category: 'signal',
field: 'signal.original_event.lag.total',
values: [2077],
originalValue: 2077,
},
{
category: 'signal',
field: 'signal.original_event.lag.read',
values: [1075],
originalValue: 1075,
},
{
category: 'signal',
field: 'signal.original_event.lag.ingest',
values: [1002],
originalValue: 1002,
},
{
category: 'signal',
field: 'signal.original_event.provider',
values: ['Microsoft-Windows-Security-Auditing'],
originalValue: 'Microsoft-Windows-Security-Auditing',
},
{
category: 'signal',
field: 'signal.original_event.created',
values: ['2020-11-25T15:36:39.922Z'],
originalValue: '2020-11-25T15:36:39.922Z',
},
{
category: 'signal',
field: 'signal.original_event.kind',
values: ['event'],
originalValue: 'event',
},
{
category: 'signal',
field: 'signal.original_event.module',
values: ['security'],
originalValue: 'security',
},
{
category: 'signal',
field: 'signal.original_event.action',
values: ['logon-failed'],
originalValue: 'logon-failed',
},
{
category: 'signal',
field: 'signal.original_event.type',
values: ['start'],
originalValue: 'start',
},
{
category: 'signal',
field: 'signal.original_event.category',
values: ['authentication'],
originalValue: 'authentication',
},
{
category: 'signal',
field: 'signal.original_event.outcome',
values: ['failure'],
originalValue: 'failure',
},
{
category: '_index',
field: '_index',
values: ['.siem-signals-angelachuang-default-000004'],
originalValue: '.siem-signals-angelachuang-default-000004',
},
{
category: '_id',
field: '_id',
values: ['5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31'],
originalValue: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31',
},
{ category: '_score', field: '_score', values: [1], originalValue: 1 },
{
category: 'fields',
field: 'fields.agent.name',
values: ['windows-native'],
originalValue: ['windows-native'],
},
{
category: 'fields',
field: 'fields.cloud.machine.type',
values: ['e2-medium'],
originalValue: ['e2-medium'],
},
{ category: 'fields', field: 'fields.cloud.provider', values: ['gcp'], originalValue: ['gcp'] },
{
category: 'fields',
field: 'fields.agent.id',
values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
originalValue: ['abfe4a35-d5b4-42a0-a539-bd054c791769'],
},
{
category: 'fields',
field: 'fields.cloud.instance.id',
values: ['5896613765949631815'],
originalValue: ['5896613765949631815'],
},
{
category: 'fields',
field: 'fields.agent.type',
values: ['winlogbeat'],
originalValue: ['winlogbeat'],
},
{
category: 'fields',
field: 'fields.@timestamp',
values: ['2020-11-25T15:42:39.417Z'],
originalValue: ['2020-11-25T15:42:39.417Z'],
},
{
category: 'fields',
field: 'fields.agent.ephemeral_id',
values: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'],
originalValue: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'],
},
{
category: 'fields',
field: 'fields.cloud.instance.name',
values: ['windows-native'],
originalValue: ['windows-native'],
},
{
category: 'fields',
field: 'fields.cloud.availability_zone',
values: ['us-central1-a'],
originalValue: ['us-central1-a'],
},
{
category: 'fields',
field: 'fields.agent.version',
values: ['7.10.0'],
originalValue: ['7.10.0'],
},
];

View file

@ -566,6 +566,13 @@ exports[`EventDetails rendering should match snapshot 1`] = `
"902",
],
},
Object {
"field": "event.kind",
"originalValue": "event",
"values": Array [
"event",
],
},
]
}
eventId="Y-6TfmcB0WOhS6qyMv3s"
@ -1139,6 +1146,13 @@ exports[`EventDetails rendering should match snapshot 1`] = `
"902",
],
},
Object {
"field": "event.kind",
"originalValue": "event",
"values": Array [
"event",
],
},
]
}
eventId="Y-6TfmcB0WOhS6qyMv3s"
@ -1296,6 +1310,13 @@ exports[`EventDetails rendering should match snapshot 1`] = `
"902",
],
},
Object {
"field": "event.kind",
"originalValue": "event",
"values": Array [
"event",
],
},
]
}
/>

View file

@ -43,6 +43,9 @@ exports[`JSON View rendering should match snapshot 1`] = `
\\"ip\\": \\"10.47.8.200\\",
\\"packets\\": 4,
\\"port\\": 902
},
\\"event\\": {
\\"kind\\": \\"event\\"
}
}"
width="100%"

View file

@ -58,6 +58,7 @@ export const getColumns = ({
onUpdateColumns,
contextId,
toggleColumn,
getLinkValue,
}: {
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
@ -65,6 +66,7 @@ export const getColumns = ({
onUpdateColumns: OnUpdateColumns;
contextId: string;
toggleColumn: (column: ColumnHeaderOptions) => void;
getLinkValue: (field: string) => string | null;
}) => [
{
field: 'field',
@ -187,6 +189,7 @@ export const getColumns = ({
fieldName={data.field}
fieldType={data.type}
value={value}
linkValue={getLinkValue(data.field)}
/>
)}
</EuiFlexItem>

View file

@ -9,37 +9,45 @@ import React from 'react';
import '../../mock/match_media';
import '../../mock/react_beautiful_dnd';
import {
defaultHeaders,
mockDetailItemData,
mockDetailItemDataId,
TestProviders,
} from '../../mock';
import { mockDetailItemData, mockDetailItemDataId, TestProviders } from '../../mock';
import { EventDetails, View } from './event_details';
import { EventDetails, EventsViewType } from './event_details';
import { mockBrowserFields } from '../../containers/source/mock';
import { useMountAppended } from '../../utils/use_mount_appended';
import { mockAlertDetailsData } from './__mocks__';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
jest.mock('../link_to');
describe('EventDetails', () => {
const mount = useMountAppended();
const defaultProps = {
browserFields: mockBrowserFields,
columnHeaders: defaultHeaders,
data: mockDetailItemData,
id: mockDetailItemDataId,
view: 'table-view' as View,
onUpdateColumns: jest.fn(),
isAlert: false,
onViewSelected: jest.fn(),
timelineId: 'test',
toggleColumn: jest.fn(),
view: EventsViewType.summaryView,
};
const alertsProps = {
...defaultProps,
data: mockAlertDetailsData as TimelineEventsDetailsItem[],
isAlert: true,
};
const wrapper = mount(
<TestProviders>
<EventDetails {...defaultProps} />
</TestProviders>
);
const alertsWrapper = mount(
<TestProviders>
<EventDetails {...alertsProps} />
</TestProviders>
);
describe('rendering', () => {
test('should match snapshot', () => {
const shallowWrap = shallow(<EventDetails {...defaultProps} />);
@ -65,4 +73,27 @@ describe('EventDetails', () => {
).toEqual('Table');
});
});
describe('alerts tabs', () => {
['Summary', 'Table', 'JSON View'].forEach((tab) => {
test(`it renders the ${tab} tab`, () => {
expect(
alertsWrapper
.find('[data-test-subj="eventDetails"]')
.find('[role="tablist"]')
.containsMatchingElement(<span>{tab}</span>)
).toBeTruthy();
});
});
test('the Summary tab is selected by default', () => {
expect(
alertsWrapper
.find('[data-test-subj="eventDetails"]')
.find('.euiTab-isSelected')
.first()
.text()
).toEqual('Summary');
});
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiSpacer, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui';
import { EuiTabbedContent, EuiTabbedContentTab, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
@ -13,17 +13,20 @@ import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/ti
import { EventFieldsBrowser } from './event_fields_browser';
import { JsonView } from './json_view';
import * as i18n from './translations';
import { SummaryView } from './summary_view';
export type View = EventsViewType.tableView | EventsViewType.jsonView;
export type View = EventsViewType.tableView | EventsViewType.jsonView | EventsViewType.summaryView;
export enum EventsViewType {
tableView = 'table-view',
jsonView = 'json-view',
summaryView = 'summary-view',
}
interface Props {
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
id: string;
isAlert: boolean;
view: EventsViewType;
onViewSelected: (selected: EventsViewType) => void;
timelineId: string;
@ -50,13 +53,33 @@ const EventDetailsComponent: React.FC<Props> = ({
view,
onViewSelected,
timelineId,
isAlert,
}) => {
const handleTabClick = useCallback((e) => onViewSelected(e.id as EventsViewType), [
onViewSelected,
]);
const handleTabClick = useCallback((e) => onViewSelected(e.id), [onViewSelected]);
const alerts = useMemo(
() => [
{
id: EventsViewType.summaryView,
name: i18n.SUMMARY,
content: (
<>
<EuiSpacer size="l" />
<SummaryView
data={data}
eventId={id}
browserFields={browserFields}
timelineId={timelineId}
/>
</>
),
},
],
[data, id, browserFields, timelineId]
);
const tabs: EuiTabbedContentTab[] = useMemo(
() => [
...(isAlert ? alerts : []),
{
id: EventsViewType.tableView,
name: i18n.TABLE,
@ -83,10 +106,10 @@ const EventDetailsComponent: React.FC<Props> = ({
),
},
],
[browserFields, data, id, timelineId]
[alerts, browserFields, data, id, isAlert, timelineId]
);
const selectedTab = view === EventsViewType.tableView ? tabs[0] : tabs[1];
const selectedTab = useMemo(() => tabs.find((t) => t.id === view) ?? tabs[0], [tabs, view]);
return (
<StyledEuiTabbedContent

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { sortBy } from 'lodash';
import { getOr, sortBy } from 'lodash/fp';
import { EuiInMemoryTable } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
@ -76,7 +76,7 @@ export const EventFieldsBrowser = React.memo<Props>(
const fieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]);
const items = useMemo(
() =>
sortBy(data, ['field']).map((item) => ({
sortBy(['field'], data).map((item) => ({
...item,
...fieldsByName[item.field],
valuesConcatenated: item.values != null ? item.values.join() : '',
@ -90,6 +90,19 @@ export const EventFieldsBrowser = React.memo<Props>(
return getColumnHeaders(columns, browserFields);
});
const getLinkValue = useCallback(
(field: string) => {
const linkField = (columnHeaders.find((col) => col.id === field) ?? {}).linkField;
if (!linkField) {
return null;
}
const linkFieldData = (data ?? []).find((d) => d.field === linkField);
const linkFieldValue = getOr(null, 'originalValue', linkFieldData);
return linkFieldValue;
},
[data, columnHeaders]
);
const toggleColumn = useCallback(
(column: ColumnHeaderOptions) => {
if (columnHeaders.some((c) => c.id === column.id)) {
@ -126,8 +139,17 @@ export const EventFieldsBrowser = React.memo<Props>(
onUpdateColumns,
contextId: timelineId,
toggleColumn,
getLinkValue,
}),
[browserFields, columnHeaders, eventId, onUpdateColumns, timelineId, toggleColumn]
[
browserFields,
columnHeaders,
eventId,
onUpdateColumns,
timelineId,
toggleColumn,
getLinkValue,
]
);
return (

View file

@ -54,6 +54,9 @@ describe('JSON View', () => {
packets: 4,
port: 902,
},
event: {
kind: 'event',
},
};
expect(buildJsonView(mockDetailItemData)).toEqual(expectedData);
});

View file

@ -0,0 +1,78 @@
/*
* 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 { waitFor } from '@testing-library/react';
import { SummaryViewComponent } from './summary_view';
import { mockAlertDetailsData } from './__mocks__';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async';
import { TestProviders } from '../../mock';
import { mockBrowserFields } from '../../containers/source/mock';
import { useMountAppended } from '../../utils/use_mount_appended';
jest.mock('../../../detections/containers/detection_engine/rules/use_rule_async', () => {
return {
useRuleAsync: jest.fn(),
};
});
const props = {
data: mockAlertDetailsData as TimelineEventsDetailsItem[],
browserFields: mockBrowserFields,
eventId: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31',
timelineId: 'detections-page',
};
describe('SummaryViewComponent', () => {
const mount = useMountAppended();
beforeEach(() => {
jest.clearAllMocks();
(useRuleAsync as jest.Mock).mockReturnValue({
rule: {
note: 'investigation guide',
},
});
});
test('render correct items', () => {
const wrapper = mount(
<TestProviders>
<SummaryViewComponent {...props} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="summary-view"]').exists()).toEqual(true);
});
test('render investigation guide', async () => {
const wrapper = mount(
<TestProviders>
<SummaryViewComponent {...props} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(true);
});
});
test("render no investigation guide if it doesn't exist", async () => {
(useRuleAsync as jest.Mock).mockReturnValue({
rule: {
note: null,
},
});
const wrapper = mount(
<TestProviders>
<SummaryViewComponent {...props} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(false);
});
});
});

View file

@ -0,0 +1,207 @@
/*
* 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 { get, getOr } from 'lodash/fp';
import {
EuiTitle,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiInMemoryTable,
EuiBasicTableColumn,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field';
import * as i18n from './translations';
import { BrowserFields } from '../../../../common/search_strategy/index_fields';
import {
ALERTS_HEADERS_RISK_SCORE,
ALERTS_HEADERS_RULE,
ALERTS_HEADERS_SEVERITY,
} from '../../../detections/components/alerts_table/translations';
import {
IP_FIELD_TYPE,
SIGNAL_RULE_NAME_FIELD_NAME,
} from '../../../timelines/components/timeline/body/renderers/constants';
import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../../../network/components/ip';
import { LineClamp } from '../line_clamp';
import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async';
interface SummaryRow {
title: string;
description: {
contextId: string;
eventId: string;
fieldName: string;
value: string;
fieldType: string;
linkValue: string | undefined;
};
}
type Summary = SummaryRow[];
const fields = [
{ id: 'signal.status' },
{ id: '@timestamp' },
{
id: SIGNAL_RULE_NAME_FIELD_NAME,
linkField: 'signal.rule.id',
label: ALERTS_HEADERS_RULE,
},
{ id: 'signal.rule.severity', label: ALERTS_HEADERS_SEVERITY },
{ id: 'signal.rule.risk_score', label: ALERTS_HEADERS_RISK_SCORE },
{ id: 'host.name' },
{ id: 'user.name' },
{ id: SOURCE_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE },
{ id: DESTINATION_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)`
.euiTableHeaderCell {
border: none;
}
.euiTableRowCell {
border: none;
}
`;
const StyledEuiDescriptionList = styled(EuiDescriptionList)`
padding: 24px 4px 4px;
`;
const getTitle = (title: SummaryRow['title']) => (
<EuiTitle size="xxs">
<h5>{title}</h5>
</EuiTitle>
);
getTitle.displayName = 'getTitle';
const getDescription = ({
contextId,
eventId,
fieldName,
value,
fieldType = '',
linkValue,
}: SummaryRow['description']) => (
<FormattedFieldValue
contextId={`alert-details-value-formatted-field-value-${contextId}-${eventId}-${fieldName}-${value}`}
eventId={eventId}
fieldName={fieldName}
fieldType={fieldType}
value={value}
linkValue={linkValue}
/>
);
const getSummary = ({
data,
browserFields,
timelineId,
eventId,
}: {
data: TimelineEventsDetailsItem[];
browserFields: BrowserFields;
timelineId: string;
eventId: string;
}) => {
return data != null
? fields.reduce<Summary>((acc, item) => {
const field = data.find((d) => d.field === item.id);
if (!field) {
return acc;
}
const linkValueField =
item.linkField != null && data.find((d) => d.field === item.linkField);
const linkValue = getOr(null, 'originalValue.0', linkValueField);
const value = getOr(null, 'originalValue.0', field);
const category = field.category;
const fieldType = get(`${category}.fields.${field.field}.type`, browserFields) as string;
const description = {
contextId: timelineId,
eventId,
fieldName: item.id,
value,
fieldType: item.fieldType ?? fieldType,
linkValue: linkValue ?? undefined,
};
return [
...acc,
{
title: item.label ?? item.id,
description,
},
];
}, [])
: [];
};
const summaryColumns: Array<EuiBasicTableColumn<SummaryRow>> = [
{
field: 'title',
truncateText: false,
render: getTitle,
width: '120px',
name: '',
},
{
field: 'description',
truncateText: false,
render: getDescription,
name: '',
},
];
export const SummaryViewComponent: React.FC<{
browserFields: BrowserFields;
data: TimelineEventsDetailsItem[];
eventId: string;
timelineId: string;
}> = ({ data, eventId, timelineId, browserFields }) => {
const ruleId = useMemo(
() =>
getOr(
null,
'originalValue',
data.find((d) => d.field === 'signal.rule.id')
),
[data]
);
const { rule: maybeRule } = useRuleAsync(ruleId);
const summaryList = useMemo(() => getSummary({ browserFields, data, eventId, timelineId }), [
browserFields,
data,
eventId,
timelineId,
]);
return (
<>
<StyledEuiInMemoryTable
data-test-subj="summary-view"
items={summaryList}
columns={summaryColumns}
compressed
/>
{maybeRule?.note && (
<StyledEuiDescriptionList data-test-subj="summary-view-guide" compressed>
<EuiDescriptionListTitle>{i18n.INVESTIGATION_GUIDE}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<LineClamp content={maybeRule?.note} />
</EuiDescriptionListDescription>
</StyledEuiDescriptionList>
)}
</>
);
};
export const SummaryView = React.memo(SummaryViewComponent);

View file

@ -6,6 +6,17 @@
import { i18n } from '@kbn/i18n';
export const SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.summary', {
defaultMessage: 'Summary',
});
export const INVESTIGATION_GUIDE = i18n.translate(
'xpack.securitySolution.alertDetails.summary.investigationGuide',
{
defaultMessage: 'Investigation guide',
}
);
export const TABLE = i18n.translate('xpack.securitySolution.eventDetails.table', {
defaultMessage: 'Table',
});

View file

@ -4,19 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader } from '@elastic/eui';
import React, { useCallback } from 'react';
import { some } from 'lodash/fp';
import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import { useDispatch } from 'react-redux';
import { timelineActions } from '../../../timelines/store/timeline';
import { BrowserFields, DocValueFields } from '../../containers/source';
import {
ExpandableEvent,
ExpandableEventTitle,
} from '../../../timelines/components/timeline/expandable_event';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { useTimelineEventsDetails } from '../../../timelines/containers/details';
import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
const StyledEuiFlyout = styled(EuiFlyout)`
z-index: ${({ theme }) => theme.eui.euiZLevel7};
@ -28,27 +31,33 @@ interface EventDetailsFlyoutProps {
timelineId: string;
}
const emptyExpandedEvent = {};
const EventDetailsFlyoutComponent: React.FC<EventDetailsFlyoutProps> = ({
browserFields,
docValueFields,
timelineId,
}) => {
const dispatch = useDispatch();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const expandedEvent = useDeepEqualSelector(
(state) => state.timeline.timelineById[timelineId]?.expandedEvent ?? emptyExpandedEvent
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent
);
const handleClearSelection = useCallback(() => {
dispatch(
timelineActions.toggleExpandedEvent({
timelineId,
event: emptyExpandedEvent,
})
);
dispatch(timelineActions.toggleExpandedEvent({ timelineId }));
}, [dispatch, timelineId]);
const [loading, detailsData] = useTimelineEventsDetails({
docValueFields,
indexName: expandedEvent.indexName!,
eventId: expandedEvent.eventId!,
skip: !expandedEvent.eventId,
});
const isAlert = useMemo(
() => some({ category: 'signal', field: 'signal.rule.id' }, detailsData),
[detailsData]
);
if (!expandedEvent.eventId) {
return null;
}
@ -56,13 +65,15 @@ const EventDetailsFlyoutComponent: React.FC<EventDetailsFlyoutProps> = ({
return (
<StyledEuiFlyout size="s" onClose={handleClearSelection}>
<EuiFlyoutHeader hasBorder>
<ExpandableEventTitle />
<ExpandableEventTitle isAlert={isAlert} loading={loading} />
</EuiFlyoutHeader>
<EuiFlyoutBody>
<ExpandableEvent
browserFields={browserFields}
docValueFields={docValueFields}
detailsData={detailsData}
event={expandedEvent}
isAlert={isAlert}
loading={loading}
timelineId={timelineId}
/>
</EuiFlyoutBody>

View file

@ -65,6 +65,7 @@ const eventsViewerDefaultProps = {
deletedEventIds: [],
docValueFields: [],
end: to,
expandedEvent: {},
filters: [],
id: TimelineId.detectionsPage,
indexNames: mockIndexNames,
@ -78,6 +79,7 @@ const eventsViewerDefaultProps = {
query: '',
language: 'kql',
},
handleCloseExpandedEvent: jest.fn(),
start: from,
sort: [
{

View file

@ -5,14 +5,16 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import { isEmpty, some } from 'lodash/fp';
import React, { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import { useDispatch } from 'react-redux';
import { Direction } from '../../../../common/search_strategy';
import { BrowserFields, DocValueFields } from '../../containers/source';
import { useTimelineEvents } from '../../../timelines/containers';
import { timelineActions } from '../../../timelines/store/timeline';
import { useKibana } from '../../lib/kibana';
import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model';
import { HeaderSection } from '../header_section';
@ -35,7 +37,7 @@ import { inputsModel } from '../../store';
import { useManageTimeline } from '../../../timelines/components/manage_timeline';
import { ExitFullScreen } from '../exit_full_screen';
import { useFullScreen } from '../../containers/use_full_screen';
import { TimelineId } from '../../../../common/types/timeline';
import { TimelineExpandedEvent, TimelineId } from '../../../../common/types/timeline';
import { GraphOverlay } from '../../../timelines/components/graph_overlay';
export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px
@ -101,6 +103,7 @@ interface Props {
deletedEventIds: Readonly<string[]>;
docValueFields: DocValueFields[];
end: string;
expandedEvent: TimelineExpandedEvent;
filters: Filter[];
headerFilterGroup?: React.ReactNode;
height?: number;
@ -128,6 +131,7 @@ const EventsViewerComponent: React.FC<Props> = ({
deletedEventIds,
docValueFields,
end,
expandedEvent,
filters,
headerFilterGroup,
id,
@ -145,6 +149,7 @@ const EventsViewerComponent: React.FC<Props> = ({
utilityBar,
graphEventId,
}) => {
const dispatch = useDispatch();
const { globalFullScreen, timelineFullScreen } = useFullScreen();
const columnsHeader = isEmpty(columns) ? defaultHeaders : columns;
const kibana = useKibana();
@ -226,6 +231,12 @@ const EventsViewerComponent: React.FC<Props> = ({
skip: !canQueryTimeline,
});
useEffect(() => {
if (!events || (expandedEvent.eventId && !some(['_id', expandedEvent.eventId], events))) {
dispatch(timelineActions.toggleExpandedEvent({ timelineId: id }));
}
}, [dispatch, events, expandedEvent, id]);
const totalCountMinusDeleted = useMemo(
() => (totalCount > 0 ? totalCount - deletedEventIds.length : 0),
[deletedEventIds.length, totalCount]

View file

@ -51,6 +51,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
deletedEventIds,
deleteEventQuery,
end,
expandedEvent,
excludedRowRendererIds,
filters,
headerFilterGroup,
@ -111,6 +112,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
dataProviders={dataProviders!}
deletedEventIds={deletedEventIds}
end={end}
expandedEvent={expandedEvent}
isLoadingIndexPattern={isLoadingIndexPattern}
filters={globalFilters}
headerFilterGroup={headerFilterGroup}
@ -142,27 +144,29 @@ const makeMapStateToProps = () => {
const getInputsTimeline = inputsSelectors.getTimelineSelector();
const getGlobalQuerySelector = inputsSelectors.globalQuerySelector();
const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector();
const getEvents = timelineSelectors.getEventsByIdSelector();
const getTimeline = timelineSelectors.getTimelineByIdSelector();
const mapStateToProps = (state: State, { id, defaultModel }: OwnProps) => {
const input: inputsModel.InputsRange = getInputsTimeline(state);
const events: TimelineModel = getEvents(state, id) ?? defaultModel;
const timeline: TimelineModel = getTimeline(state, id) ?? defaultModel;
const {
columns,
dataProviders,
deletedEventIds,
excludedRowRendererIds,
expandedEvent,
graphEventId,
itemsPerPage,
itemsPerPageOptions,
kqlMode,
sort,
showCheckboxes,
} = events;
} = timeline;
return {
columns,
dataProviders,
deletedEventIds,
expandedEvent,
excludedRowRendererIds,
filters: getGlobalFiltersQuerySelector(state),
id,
@ -175,7 +179,7 @@ const makeMapStateToProps = () => {
showCheckboxes,
// Used to determine whether the footer should show (since it is hidden if the graph is showing.)
// `getTimeline` actually returns `TimelineModel | undefined`
graphEventId: (getTimeline(state, id) as TimelineModel | undefined)?.graphEventId,
graphEventId,
};
};
return mapStateToProps;

View file

@ -0,0 +1,77 @@
/*
* 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 { EuiButtonEmpty, EuiText } from '@elastic/eui';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
const LINE_CLAMP = 3;
const LINE_CLAMP_HEIGHT = 4.5;
const StyledLineClamp = styled.div`
display: -webkit-box;
-webkit-line-clamp: ${LINE_CLAMP};
-webkit-box-orient: vertical;
overflow: hidden;
max-height: ${`${LINE_CLAMP_HEIGHT}em`};
height: ${`${LINE_CLAMP_HEIGHT}em`};
`;
const ReadMore = styled(EuiButtonEmpty)`
span.euiButtonContent {
padding: 0;
}
`;
const LineClampComponent: React.FC<{ content?: string | null }> = ({ content }) => {
const [isOverflow, setIsOverflow] = useState<boolean | null>(null);
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
const descriptionRef = useRef<HTMLDivElement>(null);
const toggleReadMore = useCallback(() => {
setIsExpanded((prevState) => !prevState);
}, []);
useEffect(() => {
if (content != null && descriptionRef?.current?.clientHeight != null) {
if (
(descriptionRef?.current?.scrollHeight ?? 0) > (descriptionRef?.current?.clientHeight ?? 0)
) {
setIsOverflow(true);
}
if (
((content == null || descriptionRef?.current?.scrollHeight) ?? 0) <=
(descriptionRef?.current?.clientHeight ?? 0)
) {
setIsOverflow(false);
}
}
}, [content]);
if (!content) {
return null;
}
return (
<>
{isExpanded ? (
<p>{content}</p>
) : isOverflow == null || isOverflow === true ? (
<StyledLineClamp ref={descriptionRef}>{content}</StyledLineClamp>
) : (
<EuiText>{content}</EuiText>
)}
{isOverflow && (
<ReadMore onClick={toggleReadMore} size="s" data-test-subj="summary-view-readmore">
{isExpanded ? i18n.READ_LESS : i18n.READ_MORE}
</ReadMore>
)}
</>
);
};
export const LineClamp = React.memo(LineClampComponent);

View file

@ -0,0 +1,15 @@
/*
* 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 READ_MORE = i18n.translate('xpack.securitySolution.alertDetails.summary.readMore', {
defaultMessage: 'Read More',
});
export const READ_LESS = i18n.translate('xpack.securitySolution.alertDetails.summary.readLess', {
defaultMessage: 'Read Less',
});

View file

@ -109,4 +109,9 @@ export const mockDetailItemData: TimelineEventsDetailsItem[] = [
originalValue: 902,
values: ['902'],
},
{
field: 'event.kind',
originalValue: 'event',
values: ['event'],
},
];

View file

@ -11,7 +11,7 @@ import { FormattedFieldValue } from '../../../timelines/components/timeline/body
export const SOURCE_IP_FIELD_NAME = 'source.ip';
export const DESTINATION_IP_FIELD_NAME = 'destination.ip';
const IP_FIELD_TYPE = 'ip';
export const IP_FIELD_TYPE = 'ip';
/**
* Renders text containing a draggable IP address (e.g. `source.ip`,

View file

@ -6,6 +6,7 @@
import { omit } from 'lodash/fp';
import React from 'react';
import { waitFor } from '@testing-library/react';
import { mockBrowserFields } from '../../../common/containers/source/mock';
import { TestProviders } from '../../../common/mock';
@ -204,6 +205,65 @@ describe('field_items', () => {
});
});
test('it returns the expected signal column settings', async () => {
const mockSelectedCategoryId = 'signal';
const mockBrowserFieldsWithSignal = {
...mockBrowserFields,
signal: {
fields: {
'signal.rule.name': {
aggregatable: true,
category: 'signal',
description: 'rule name',
example: '',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'signal.rule.name',
searchable: true,
type: 'string',
},
},
},
};
const toggleColumn = jest.fn();
const wrapper = mount(
<TestProviders>
<Category
categoryId={mockSelectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFieldsWithSignal}
fieldItems={getFieldItems({
browserFields: mockBrowserFieldsWithSignal,
category: mockBrowserFieldsWithSignal[mockSelectedCategoryId],
categoryId: mockSelectedCategoryId,
columnHeaders,
highlight: '',
onUpdateColumns: jest.fn(),
timelineId,
toggleColumn,
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find(`[data-test-subj="field-signal.rule.name-checkbox"]`)
.last()
.simulate('change', {
target: { checked: true },
});
await waitFor(() => {
expect(toggleColumn).toBeCalledWith({
columnHeaderType: 'not-filtered',
id: 'signal.rule.name',
width: 180,
});
});
});
test('it renders the expected icon for a field', () => {
const wrapper = mount(
<TestProviders>

View file

@ -6,13 +6,6 @@
import { i18n } from '@kbn/i18n';
export const ALL_ACTIONS = i18n.translate(
'xpack.securitySolution.open.timeline.allActionsTooltip',
{
defaultMessage: 'All actions',
}
);
export const BATCH_ACTIONS = i18n.translate(
'xpack.securitySolution.open.timeline.batchActionsTitle',
{

View file

@ -19,16 +19,16 @@ import {
EuiFlexItem,
EuiInMemoryTable,
} from '@elastic/eui';
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback, useMemo, useRef } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { RowRendererId } from '../../../../common/types/timeline';
import { State } from '../../../common/store';
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { setExcludedRowRendererIds as dispatchSetExcludedRowRendererIds } from '../../store/timeline/actions';
import { timelineSelectors } from '../../store/timeline';
import { timelineDefaults } from '../../store/timeline/defaults';
import { renderers } from './catalog';
import { setExcludedRowRendererIds as dispatchSetExcludedRowRendererIds } from '../../../timelines/store/timeline/actions';
import { RowRenderersBrowser } from './row_renderers_browser';
import * as i18n from './translations';
@ -78,16 +78,14 @@ interface StatefulRowRenderersBrowserProps {
timelineId: string;
}
const emptyExcludedRowRendererIds: RowRendererId[] = [];
const StatefulRowRenderersBrowserComponent: React.FC<StatefulRowRenderersBrowserProps> = ({
timelineId,
}) => {
const tableRef = useRef<EuiInMemoryTable<{}>>();
const dispatch = useDispatch();
const excludedRowRendererIds = useShallowEqualSelector(
(state: State) =>
state.timeline.timelineById[timelineId]?.excludedRowRendererIds || emptyExcludedRowRendererIds
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const excludedRowRendererIds = useDeepEqualSelector(
(state: State) => (getTimeline(state, timelineId) ?? timelineDefaults).excludedRowRendererIds
);
const [show, setShow] = useState(false);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import { EuiButtonIcon, EuiLoadingSpinner, EuiCheckbox } from '@elastic/eui';
import { EuiButtonIcon, EuiLoadingSpinner, EuiCheckbox, EuiToolTip } from '@elastic/eui';
import { EventsTd, EventsTdContent, EventsTdGroupActions } from '../../styles';
import * as i18n from '../translations';
@ -66,14 +66,16 @@ const ActionsComponent: React.FC<Props> = ({
)}
<EventsTd key="expand-event">
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
<EuiButtonIcon
aria-label={expanded ? i18n.COLLAPSE : i18n.EXPAND}
data-test-subj="expand-event"
disabled={expanded}
iconType="arrowRight"
id={eventId}
onClick={onEventToggled}
/>
<EuiToolTip data-test-subj="expand-event-tool-tip" content={i18n.EXPAND_EVENT}>
<EuiButtonIcon
aria-label={i18n.COLLAPSE}
data-test-subj="expand-event"
disabled={expanded}
iconType="arrowRight"
id={eventId}
onClick={onEventToggled}
/>
</EuiToolTip>
</EventsTdContent>
</EventsTd>

View file

@ -11,6 +11,7 @@ import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
import { Ecs } from '../../../../../../common/ecs';
import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model';
import { timelineSelectors } from '../../../../store/timeline';
import { AssociateNote } from '../../../notes/helpers';
import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events';
import { EventsTrData } from '../../styles';
@ -85,8 +86,9 @@ export const EventColumnView = React.memo<Props>(
timelineId,
toggleShowNotes,
}) => {
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const { timelineType, status } = useDeepEqualSelector((state) =>
pick(['timelineType', 'status'], state.timeline.timelineById[timelineId])
pick(['timelineType', 'status'], getTimeline(state, timelineId))
);
const handlePinClicked = useCallback(

View file

@ -26,8 +26,9 @@ import { NoteCards } from '../../../notes/note_cards';
import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context';
import { EventColumnView } from './event_column_view';
import { inputsModel } from '../../../../../common/store';
import { timelineActions } from '../../../../store/timeline';
import { timelineActions, timelineSelectors } from '../../../../store/timeline';
import { activeTimeline } from '../../../../containers/active_timeline_context';
import { timelineDefaults } from '../../../../store/timeline/defaults';
interface Props {
actionsColumnWidth: number;
@ -77,8 +78,9 @@ const StatefulEventComponent: React.FC<Props> = ({
}) => {
const dispatch = useDispatch();
const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({});
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const expandedEvent = useDeepEqualSelector(
(state) => state.timeline.timelineById[timelineId].expandedEvent
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent
);
const divElement = useRef<HTMLDivElement | null>(null);
@ -112,13 +114,12 @@ const StatefulEventComponent: React.FC<Props> = ({
event: {
eventId,
indexName,
loading: false,
},
})
);
if (timelineId === TimelineId.active) {
activeTimeline.toggleExpandedEvent({ eventId, indexName, loading: false });
activeTimeline.toggleExpandedEvent({ eventId, indexName });
}
}, [dispatch, event._id, event._index, timelineId]);

View file

@ -14,3 +14,4 @@ export const RULE_REFERENCE_FIELD_NAME = 'rule.reference';
export const REFERENCE_URL_FIELD_NAME = 'reference.url';
export const EVENT_URL_FIELD_NAME = 'event.url';
export const SIGNAL_RULE_NAME_FIELD_NAME = 'signal.rule.name';
export const SIGNAL_STATUS_FIELD_NAME = 'signal.status';

View file

@ -5,19 +5,15 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { isNumber, isString, isEmpty } from 'lodash/fp';
import { isNumber, isEmpty } from 'lodash/fp';
import React from 'react';
import { DefaultDraggable } from '../../../../../common/components/draggables';
import { Bytes, BYTES_FORMAT } from './bytes';
import { Duration, EVENT_DURATION_FIELD_NAME } from '../../../duration';
import {
getOrEmptyTagFromValue,
getEmptyTagValue,
} from '../../../../../common/components/empty_value';
import { getOrEmptyTagFromValue } from '../../../../../common/components/empty_value';
import { FormattedDate } from '../../../../../common/components/formatted_date';
import { FormattedIp } from '../../../../components/formatted_ip';
import { HostDetailsLink } from '../../../../../common/components/links';
import { Port, PORT_NAMES } from '../../../../../network/components/port';
import { TruncatableText } from '../../../../../common/components/truncatable_text';
@ -31,9 +27,12 @@ import {
SIGNAL_RULE_NAME_FIELD_NAME,
REFERENCE_URL_FIELD_NAME,
EVENT_URL_FIELD_NAME,
SIGNAL_STATUS_FIELD_NAME,
GEO_FIELD_TYPE,
} from './constants';
import { RenderRuleName, renderEventModule, renderUrl } from './formatted_field_helpers';
import { RuleStatus } from './rule_status';
import { HostName } from './host_name';
// simple black-list to prevent dragging and dropping fields such as message name
const columnNamesNotDraggable = [MESSAGE_FIELD_NAME];
@ -80,22 +79,7 @@ const FormattedFieldValueComponent: React.FC<{
<Duration contextId={contextId} eventId={eventId} fieldName={fieldName} value={`${value}`} />
);
} else if (fieldName === HOST_NAME_FIELD_NAME) {
const hostname = `${value}`;
return isString(value) && hostname.length > 0 ? (
<DefaultDraggable
field={fieldName}
id={`event-details-value-default-draggable-${contextId}-${eventId}-${fieldName}-${value}`}
tooltipContent={value}
value={value}
>
<HostDetailsLink data-test-subj="host-details-link" hostName={hostname}>
<TruncatableText data-test-subj="draggable-truncatable-content">{value}</TruncatableText>
</HostDetailsLink>
</DefaultDraggable>
) : (
getEmptyTagValue()
);
return <HostName contextId={contextId} eventId={eventId} fieldName={fieldName} value={value} />;
} else if (fieldFormat === BYTES_FORMAT) {
return (
<Bytes contextId={contextId} eventId={eventId} fieldName={fieldName} value={`${value}`} />
@ -113,6 +97,10 @@ const FormattedFieldValueComponent: React.FC<{
);
} else if (fieldName === EVENT_MODULE_FIELD_NAME) {
return renderEventModule({ contextId, eventId, fieldName, linkValue, truncate, value });
} else if (fieldName === SIGNAL_STATUS_FIELD_NAME) {
return (
<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)
) {
@ -142,7 +130,6 @@ const FormattedFieldValueComponent: React.FC<{
} else {
const contentValue = getOrEmptyTagFromValue(value);
const content = truncate ? <TruncatableText>{contentValue}</TruncatableText> : contentValue;
return (
<DefaultDraggable
field={fieldName}

View file

@ -77,6 +77,15 @@ export const RenderRuleName: React.FC<RenderRuleNameProps> = ({
{content}
</LinkAnchor>
</DefaultDraggable>
) : value != null ? (
<DefaultDraggable
field={fieldName}
id={`event-details-value-default-draggable-${contextId}-${eventId}-${fieldName}-${value}-${ruleId}`}
tooltipContent={value}
value={`${value}`}
>
{value}
</DefaultDraggable>
) : (
getEmptyTagValue()
);

View file

@ -0,0 +1,42 @@
/*
* 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 { isString } from 'lodash/fp';
import { DefaultDraggable } from '../../../../../common/components/draggables';
import { getEmptyTagValue } from '../../../../../common/components/empty_value';
import { HostDetailsLink } from '../../../../../common/components/links';
import { TruncatableText } from '../../../../../common/components/truncatable_text';
interface Props {
contextId: string;
eventId: string;
fieldName: string;
value: string | number | undefined | null;
}
const HostNameComponent: React.FC<Props> = ({ fieldName, contextId, eventId, value }) => {
const hostname = `${value}`;
return isString(value) && hostname.length > 0 ? (
<DefaultDraggable
field={fieldName}
id={`event-details-value-default-draggable-${contextId}-${eventId}-${fieldName}-${value}`}
tooltipContent={value}
value={value}
>
<HostDetailsLink data-test-subj="host-details-link" hostName={hostname}>
<TruncatableText data-test-subj="draggable-truncatable-content">{value}</TruncatableText>
</HostDetailsLink>
</DefaultDraggable>
) : (
getEmptyTagValue()
);
};
export const HostName = React.memo(HostNameComponent);
HostName.displayName = 'HostName';

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, { useMemo } from 'react';
import { EuiBadge } from '@elastic/eui';
import { getOr } from 'lodash/fp';
import styled from 'styled-components';
import { DefaultDraggable } from '../../../../../common/components/draggables';
const mapping = {
open: 'primary',
'in-progress': 'warning',
closed: 'default',
};
const StyledEuiBadge = styled(EuiBadge)`
text-transform: capitalize;
`;
interface Props {
contextId: string;
eventId: string;
fieldName: string;
value: string | number | undefined | null;
}
const RuleStatusComponent: React.FC<Props> = ({ contextId, eventId, fieldName, value }) => {
const color = useMemo(() => getOr('default', `${value}`, mapping), [value]);
return (
<DefaultDraggable
field={fieldName}
id={`alert-details-value-default-draggable-${contextId}-${eventId}-${fieldName}-${value}`}
value={`${value}`}
tooltipContent={fieldName}
>
<StyledEuiBadge color={color}>{value}</StyledEuiBadge>
</DefaultDraggable>
);
};
export const RuleStatus = React.memo(RuleStatusComponent);
RuleStatus.displayName = 'RuleStatus';

View file

@ -73,6 +73,13 @@ export const EXPAND = i18n.translate(
}
);
export const EXPAND_EVENT = i18n.translate(
'xpack.securitySolution.timeline.body.actions.expandEventTooltip',
{
defaultMessage: 'Expand event',
}
);
export const COLLAPSE = i18n.translate(
'xpack.securitySolution.timeline.body.actions.collapseAriaLabel',
{
@ -80,13 +87,6 @@ export const COLLAPSE = i18n.translate(
}
);
export const COLLAPSE_EVENT = i18n.translate(
'xpack.securitySolution.timeline.body.actions.collapseEventTooltip',
{
defaultMessage: 'Collapse event',
}
);
export const ACTION_INVESTIGATE_IN_RESOLVER = i18n.translate(
'xpack.securitySolution.timeline.body.actions.investigateInResolverTooltip',
{

View file

@ -10,40 +10,66 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { some } from 'lodash/fp';
import { EuiSpacer } from '@elastic/eui';
import React from 'react';
import React, { useMemo } from 'react';
import deepEqual from 'fast-deep-equal';
import { BrowserFields, DocValueFields } from '../../../common/containers/source';
import {
ExpandableEvent,
ExpandableEventTitle,
HandleOnEventClosed,
} from '../../../timelines/components/timeline/expandable_event';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { useTimelineEventsDetails } from '../../containers/details';
import { timelineSelectors } from '../../store/timeline';
import { timelineDefaults } from '../../store/timeline/defaults';
interface EventDetailsProps {
browserFields: BrowserFields;
docValueFields: DocValueFields[];
timelineId: string;
handleOnEventClosed?: HandleOnEventClosed;
}
const EventDetailsComponent: React.FC<EventDetailsProps> = ({
browserFields,
docValueFields,
timelineId,
handleOnEventClosed,
}) => {
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const expandedEvent = useDeepEqualSelector(
(state) => state.timeline.timelineById[timelineId]?.expandedEvent
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent
);
const [loading, detailsData] = useTimelineEventsDetails({
docValueFields,
indexName: expandedEvent.indexName!,
eventId: expandedEvent.eventId!,
skip: !expandedEvent.eventId,
});
const isAlert = useMemo(
() => some({ category: 'signal', field: 'signal.rule.id' }, detailsData),
[detailsData]
);
return (
<>
<ExpandableEventTitle />
<ExpandableEventTitle
isAlert={isAlert}
loading={loading}
handleOnEventClosed={handleOnEventClosed}
/>
<EuiSpacer size="m" />
<ExpandableEvent
browserFields={browserFields}
docValueFields={docValueFields}
detailsData={detailsData}
event={expandedEvent}
isAlert={isAlert}
loading={loading}
timelineId={timelineId}
/>
</>
@ -55,5 +81,6 @@ export const EventDetails = React.memo(
(prevProps, nextProps) =>
deepEqual(prevProps.browserFields, nextProps.browserFields) &&
deepEqual(prevProps.docValueFields, nextProps.docValueFields) &&
prevProps.timelineId === nextProps.timelineId
prevProps.timelineId === nextProps.timelineId &&
prevProps.handleOnEventClosed === nextProps.handleOnEventClosed
);

View file

@ -5,45 +5,74 @@
*/
import { find } from 'lodash/fp';
import { EuiTextColor, EuiLoadingContent, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import {
EuiButtonIcon,
EuiTextColor,
EuiLoadingContent,
EuiTitle,
EuiSpacer,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import React, { useMemo, useState } from 'react';
import styled from 'styled-components';
import { TimelineExpandedEvent } from '../../../../../common/types/timeline';
import { BrowserFields, DocValueFields } from '../../../../common/containers/source';
import { BrowserFields } from '../../../../common/containers/source';
import {
EventDetails,
EventsViewType,
View,
} from '../../../../common/components/event_details/event_details';
import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useTimelineEventsDetails } from '../../../containers/details';
import { LineClamp } from '../../../../common/components/line_clamp';
import * as i18n from './translations';
export type HandleOnEventClosed = () => void;
interface Props {
browserFields: BrowserFields;
docValueFields: DocValueFields[];
detailsData: TimelineEventsDetailsItem[] | null;
event: TimelineExpandedEvent;
isAlert: boolean;
loading: boolean;
timelineId: string;
}
export const ExpandableEventTitle = React.memo(() => (
<EuiTitle size="s">
<h4>{i18n.EVENT_DETAILS}</h4>
</EuiTitle>
));
interface ExpandableEventTitleProps {
isAlert: boolean;
loading: boolean;
handleOnEventClosed?: HandleOnEventClosed;
}
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
flex: 0;
`;
export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
({ isAlert, loading, handleOnEventClosed }) => (
<StyledEuiFlexGroup justifyContent="spaceBetween" wrap={true}>
<EuiFlexItem grow={false}>
<EuiTitle size="s">
{!loading ? <h4>{isAlert ? i18n.ALERT_DETAILS : i18n.EVENT_DETAILS}</h4> : <></>}
</EuiTitle>
</EuiFlexItem>
{handleOnEventClosed && (
<EuiFlexItem grow={false}>
<EuiButtonIcon iconType="cross" aria-label={i18n.CLOSE} onClick={handleOnEventClosed} />
</EuiFlexItem>
)}
</StyledEuiFlexGroup>
)
);
ExpandableEventTitle.displayName = 'ExpandableEventTitle';
export const ExpandableEvent = React.memo<Props>(
({ browserFields, docValueFields, event, timelineId }) => {
const [view, setView] = useState<View>(EventsViewType.tableView);
const [loading, detailsData] = useTimelineEventsDetails({
docValueFields,
indexName: event.indexName!,
eventId: event.eventId!,
skip: !event.eventId,
});
({ browserFields, event, timelineId, isAlert, loading, detailsData }) => {
const [view, setView] = useState<View>(EventsViewType.summaryView);
const message = useMemo(() => {
if (detailsData) {
@ -68,12 +97,22 @@ export const ExpandableEvent = React.memo<Props>(
return (
<>
<EuiText>{message}</EuiText>
<EuiSpacer size="m" />
{message && (
<>
<EuiDescriptionList data-test-subj="event-message" compressed>
<EuiDescriptionListTitle>{i18n.MESSAGE}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<LineClamp content={message} />
</EuiDescriptionListDescription>
</EuiDescriptionList>
<EuiSpacer size="m" />
</>
)}
<EventDetails
browserFields={browserFields}
data={detailsData!}
id={event.eventId!}
isAlert={isAlert}
onViewSelected={setView}
timelineId={timelineId}
view={view}

View file

@ -6,6 +6,13 @@
import { i18n } from '@kbn/i18n';
export const MESSAGE = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.messageTitle',
{
defaultMessage: 'Message',
}
);
export const COPY_TO_CLIPBOARD = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.copyToClipboardToolTip',
{
@ -13,6 +20,13 @@ export const COPY_TO_CLIPBOARD = i18n.translate(
}
);
export const CLOSE = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel',
{
defaultMessage: 'close',
}
);
export const EVENT = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.eventToolTipTitle',
{
@ -28,8 +42,15 @@ export const EVENT_DETAILS_PLACEHOLDER = i18n.translate(
);
export const EVENT_DETAILS = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.titleLabel',
'xpack.securitySolution.timeline.expandableEvent.eventTitleLabel',
{
defaultMessage: 'Event details',
}
);
export const ALERT_DETAILS = i18n.translate(
'xpack.securitySolution.timeline.expandableEvent.alertTitleLabel',
{
defaultMessage: 'Alert details',
}
);

View file

@ -261,6 +261,7 @@ In other use cases the message field can be used to concatenate different values
}
end="2018-03-24T03:33:52.253Z"
eventType="all"
expandedEvent={Object {}}
filters={Array []}
isLive={false}
itemsPerPage={5}
@ -273,6 +274,7 @@ In other use cases the message field can be used to concatenate different values
}
kqlMode="search"
kqlQueryExpression=""
onEventClosed={[MockFunction]}
showCallOutUnauthorizedMsg={false}
showEventDetails={false}
sort={

View file

@ -94,6 +94,7 @@ describe('Timeline', () => {
columns: defaultHeaders,
dataProviders: mockDataProviders,
end: endDate,
expandedEvent: {},
eventType: 'all',
showEventDetails: false,
filters: [],
@ -103,6 +104,7 @@ describe('Timeline', () => {
itemsPerPageOptions: [5, 10, 20],
kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'],
kqlQueryExpression: '',
onEventClosed: jest.fn(),
showCallOutUnauthorizedMsg: false,
sort,
start: startDate,

View file

@ -13,8 +13,8 @@ import {
EuiFlyoutFooter,
EuiSpacer,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useState, useMemo, useEffect } from 'react';
import { isEmpty, some } from 'lodash/fp';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import styled from 'styled-components';
import { Dispatch } from 'redux';
import { connect, ConnectedProps } from 'react-redux';
@ -32,7 +32,7 @@ import { combineQueries } from '../helpers';
import { TimelineRefetch } from '../refetch_timeline';
import { esQuery, FilterManager } from '../../../../../../../../src/plugins/data/public';
import { useManageTimeline } from '../../manage_timeline';
import { TimelineEventsType } from '../../../../../common/types/timeline';
import { TimelineEventsType, TimelineId } from '../../../../../common/types/timeline';
import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config';
import { SuperDatePicker } from '../../../../common/components/super_date_picker';
import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context';
@ -45,6 +45,8 @@ import { useSourcererScope } from '../../../../common/containers/sourcerer';
import { TimelineModel } from '../../../../timelines/store/timeline/model';
import { EventDetails } from '../event_details';
import { TimelineDatePickerLock } from '../date_picker_lock';
import { activeTimeline } from '../../../containers/active_timeline_context';
import { ToggleExpandedEvent } from '../../../store/timeline/actions';
const TimelineHeaderContainer = styled.div`
margin-top: 6px;
@ -141,6 +143,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
dataProviders,
end,
eventType,
expandedEvent,
filters,
timelineId,
isLive,
@ -148,6 +151,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
itemsPerPageOptions,
kqlMode,
kqlQueryExpression,
onEventClosed,
showCallOutUnauthorizedMsg,
showEventDetails,
start,
@ -156,18 +160,6 @@ export const QueryTabContentComponent: React.FC<Props> = ({
timerangeKind,
updateEventTypeAndIndexesName,
}) => {
const [showEventDetailsColumn, setShowEventDetailsColumn] = useState(false);
useEffect(() => {
// it should changed only once to true and then stay visible till the component umount
setShowEventDetailsColumn((current) => {
if (showEventDetails && !current) {
return true;
}
return current;
});
}, [showEventDetails]);
const {
browserFields,
docValueFields,
@ -247,10 +239,27 @@ export const QueryTabContentComponent: React.FC<Props> = ({
timerangeKind,
});
const handleOnEventClosed = useCallback(() => {
onEventClosed({ timelineId });
if (timelineId === TimelineId.active) {
activeTimeline.toggleExpandedEvent({
eventId: expandedEvent.eventId!,
indexName: expandedEvent.indexName!,
});
}
}, [timelineId, onEventClosed, expandedEvent.eventId, expandedEvent.indexName]);
useEffect(() => {
setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer });
}, [loadingSourcerer, timelineId, isQueryLoading, setIsTimelineLoading]);
useEffect(() => {
if (!events || (expandedEvent.eventId && !some(['_id', expandedEvent.eventId], events))) {
handleOnEventClosed();
}
}, [expandedEvent, handleOnEventClosed, events, combinedQueries]);
return (
<>
<TimelineRefetch
@ -324,7 +333,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
</EventDetailsWidthProvider>
) : null}
</ScrollableFlexItem>
{showEventDetailsColumn && (
{showEventDetails && (
<>
<VerticalRule />
<ScrollableFlexItem grow={1}>
@ -332,6 +341,7 @@ export const QueryTabContentComponent: React.FC<Props> = ({
browserFields={browserFields}
docValueFields={docValueFields}
timelineId={timelineId}
handleOnEventClosed={handleOnEventClosed}
/>
</ScrollableFlexItem>
</>
@ -375,6 +385,7 @@ const makeMapStateToProps = () => {
dataProviders,
eventType: eventType ?? 'raw',
end: input.timerange.to,
expandedEvent,
filters: timelineFilter,
timelineId,
isLive: input.policy.kind === 'interval',
@ -404,6 +415,9 @@ const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({
})
);
},
onEventClosed: (args: ToggleExpandedEvent) => {
dispatch(timelineActions.toggleExpandedEvent(args));
},
});
const connector = connect(makeMapStateToProps, mapDispatchToProps);
@ -420,6 +434,7 @@ const QueryTabContent = connector(
prevProps.itemsPerPage === nextProps.itemsPerPage &&
prevProps.kqlMode === nextProps.kqlMode &&
prevProps.kqlQueryExpression === nextProps.kqlQueryExpression &&
prevProps.onEventClosed === nextProps.onEventClosed &&
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg &&
prevProps.showEventDetails === nextProps.showEventDetails &&
prevProps.status === nextProps.status &&

View file

@ -5,7 +5,7 @@
*/
import deepEqual from 'fast-deep-equal';
import { noop } from 'lodash/fp';
import { isEmpty, noop } from 'lodash/fp';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
@ -72,14 +72,6 @@ export const initSortDefault = [
},
];
function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) {
const ref = useRef<TimelineEventsAllRequestOptions | null>(value);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
export const useTimelineEvents = ({
docValueFields,
endDate,
@ -105,7 +97,7 @@ export const useTimelineEvents = ({
const [timelineRequest, setTimelineRequest] = useState<TimelineEventsAllRequestOptions | null>(
null
);
const prevTimelineRequest = usePreviousRequest(timelineRequest);
const prevTimelineRequest = useRef<TimelineEventsAllRequestOptions | null>(null);
const clearSignalsState = useCallback(() => {
if (id != null && detectionsTimelineIds.some((timelineId) => timelineId === id)) {
@ -159,6 +151,7 @@ export const useTimelineEvents = ({
}
let didCancel = false;
const asyncSearch = async () => {
prevTimelineRequest.current = request;
abortCtrl.current = new AbortController();
setLoading(true);
const searchSubscription$ = data.search
@ -223,6 +216,7 @@ export const useTimelineEvents = ({
abortCtrl.current.abort();
setLoading(false);
prevTimelineRequest.current = activeTimeline.getRequest();
refetch.current = asyncSearch.bind(null, activeTimeline.getRequest());
setTimelineResponse((prevResp) => {
const resp = activeTimeline.getResponse();
@ -331,9 +325,35 @@ export const useTimelineEvents = ({
id !== TimelineId.active ||
timerangeKind === 'absolute' ||
!deepEqual(prevTimelineRequest, timelineRequest)
)
) {
timelineSearch(timelineRequest);
}
}, [id, prevTimelineRequest, timelineRequest, timelineSearch, timerangeKind]);
/*
cleanup timeline events response when the filters were removed completely
to avoid displaying previous query results
*/
useEffect(() => {
if (isEmpty(filterQuery)) {
setTimelineResponse({
id,
inspect: {
dsl: [],
response: [],
},
refetch: refetchGrid,
totalCount: -1,
pageInfo: {
activePage: 0,
querySize: 0,
},
events: [],
loadPage: wrappedLoadPage,
updatedAt: 0,
});
}
}, [filterQuery, id, refetchGrid, wrappedLoadPage]);
return [loading, timelineResponse];
};

View file

@ -35,9 +35,9 @@ export const addNoteToEvent = actionCreator<{ id: string; noteId: string; eventI
'ADD_NOTE_TO_EVENT'
);
interface ToggleExpandedEvent {
export interface ToggleExpandedEvent {
timelineId: string;
event: TimelineExpandedEvent;
event?: TimelineExpandedEvent;
}
export const toggleExpandedEvent = actionCreator<ToggleExpandedEvent>('TOGGLE_EXPANDED_EVENT');

View file

@ -80,12 +80,14 @@ describe('epicLocalStorage', () => {
dataProviders: mockDataProviders,
end: endDate,
eventType: 'all',
expandedEvent: {},
filters: [],
isLive: false,
itemsPerPage: 5,
itemsPerPageOptions: [5, 10, 20],
kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'],
kqlQueryExpression: '',
onEventClosed: jest.fn(),
showCallOutUnauthorizedMsg: false,
showEventDetails: false,
start: startDate,

View file

@ -177,7 +177,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState)
...state,
timelineById: addTimelineNoteToEvent({ id, noteId, eventId, timelineById: state.timelineById }),
}))
.case(toggleExpandedEvent, (state, { timelineId, event }) => ({
.case(toggleExpandedEvent, (state, { timelineId, event = {} }) => ({
...state,
timelineById: {
...state.timelineById,

View file

@ -41,8 +41,6 @@ export const getTimelines = () => timelineByIdSelector;
export const getTimelineByIdSelector = () => createSelector(selectTimeline, (timeline) => timeline);
export const getEventsByIdSelector = () => createSelector(selectTimeline, (timeline) => timeline);
export const getKqlFilterQuerySelector = () =>
createSelector(selectTimeline, (timeline) =>
timeline &&

View file

@ -27,7 +27,7 @@ export const timelineEventsDetails: SecuritySolutionTimelineFactory<TimelineEven
response: IEsSearchResponse<unknown>
): Promise<TimelineEventsDetailsStrategyResponse> => {
const { indexName, eventId, docValueFields = [] } = options;
const fieldsData = cloneDeep(response.rawResponse.hits.hits[0].fields ?? {});
const fieldsData = cloneDeep(response.rawResponse.hits.hits[0]?.fields ?? {});
const hitsData = cloneDeep(response.rawResponse.hits.hits[0] ?? {});
delete hitsData._source;
delete hitsData.fields;

View file

@ -17737,7 +17737,6 @@
"xpack.securitySolution.notes.notesTitle": "メモ",
"xpack.securitySolution.notes.previewMarkdownTitle": "プレビュー(マークダウン)",
"xpack.securitySolution.notes.search.FilterByUserOrNotePlaceholder": "ユーザーまたはメモでフィルター",
"xpack.securitySolution.open.timeline.allActionsTooltip": "すべてのアクション",
"xpack.securitySolution.open.timeline.batchActionsTitle": "一斉アクション",
"xpack.securitySolution.open.timeline.cancelButton": "キャンセル",
"xpack.securitySolution.open.timeline.collapseButton": "縮小",
@ -17945,7 +17944,6 @@
"xpack.securitySolution.timeline.autosave.warning.refresh.title": "タイムラインを更新",
"xpack.securitySolution.timeline.autosave.warning.title": "更新されるまで自動保存は無効です",
"xpack.securitySolution.timeline.body.actions.collapseAriaLabel": "縮小",
"xpack.securitySolution.timeline.body.actions.collapseEventTooltip": "イベントを折りたたむ",
"xpack.securitySolution.timeline.body.actions.expandAriaLabel": "拡張",
"xpack.securitySolution.timeline.body.actions.investigateInResolverTooltip": "イベントを分析します",
"xpack.securitySolution.timeline.body.copyToClipboardButtonLabel": "クリップボードにコピー",

View file

@ -17755,7 +17755,6 @@
"xpack.securitySolution.notes.notesTitle": "备注",
"xpack.securitySolution.notes.previewMarkdownTitle": "预览 (Markdown)",
"xpack.securitySolution.notes.search.FilterByUserOrNotePlaceholder": "按用户或备注筛选",
"xpack.securitySolution.open.timeline.allActionsTooltip": "所有操作",
"xpack.securitySolution.open.timeline.batchActionsTitle": "批处理操作",
"xpack.securitySolution.open.timeline.cancelButton": "取消",
"xpack.securitySolution.open.timeline.collapseButton": "折叠",
@ -17963,7 +17962,6 @@
"xpack.securitySolution.timeline.autosave.warning.refresh.title": "刷新时间线",
"xpack.securitySolution.timeline.autosave.warning.title": "刷新后才会启用自动保存",
"xpack.securitySolution.timeline.body.actions.collapseAriaLabel": "折叠",
"xpack.securitySolution.timeline.body.actions.collapseEventTooltip": "折叠事件",
"xpack.securitySolution.timeline.body.actions.expandAriaLabel": "展开",
"xpack.securitySolution.timeline.body.actions.investigateInResolverTooltip": "分析事件",
"xpack.securitySolution.timeline.body.copyToClipboardButtonLabel": "复制到剪贴板",