Skip to content

Commit

Permalink
[Security Solution] [Feat] Add Bulk Events to Timeline. (#142737)
Browse files Browse the repository at this point in the history
This PR implements functionality to add multiple events to the timeline.
It is implements :

1.  Selected events ( max 4000 ) can be added to the timeline.


## Implementation.

1. At present, it only support adding multiple records in the timeline
as `kql Filters`. #142436 is in
progress to implement `is-one-of` operator in the data provider. Once
that is moved to `main`, we can change value of `prefer` parameter to
send the IDs in `dataProvider` rather than filter.

If you would like to test it with
#142436, please clone :
https://github.com/logeekal/kibana/tree/bulk_actions_add_timeline_with_is_one_of


2. Below is the demonstration how it works. 

https://user-images.githubusercontent.com/7485038/199056731-4287fc61-9d0b-4cf3-ba1f-741f6b66ae97.mov
  • Loading branch information
logeekal committed Nov 9, 2022
1 parent 225156a commit 92d907c
Show file tree
Hide file tree
Showing 45 changed files with 39,230 additions and 112 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,3 +461,5 @@ export const RISKY_HOSTS_DOC_LINK =
'https://www.elastic.co/guide/en/security/current/host-risk-score.html';
export const RISKY_USERS_DOC_LINK =
'https://www.elastic.co/guide/en/security/current/user-risk-score.html';

export const BULK_ADD_TO_TIMELINE_LIMIT = 2000;
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { login, visit } from '../../tasks/login';
import { openActiveTimeline } from '../../tasks/timeline';

import { ALERTS_URL } from '../../urls/navigation';
import { fillAddFilterForm, openAddFilterPopover } from '../../tasks/search_bar';

describe('Alerts timeline', () => {
before(() => {
Expand Down Expand Up @@ -62,6 +63,9 @@ describe('Alerts timeline', () => {
});

it('Add an empty property to default timeline', () => {
// add condition to make sure the field is empty
openAddFilterPopover();
fillAddFilterForm({ key: 'file.name', operator: 'does not exist' });
scrollAlertTableColumnIntoView(ALERT_TABLE_FILE_NAME_HEADER);
addAlertPropertyToTimeline(ALERT_TABLE_FILE_NAME_VALUES, 0);
openActiveTimeline();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { getNewRule } from '../../objects/rule';
import { SELECTED_ALERTS } from '../../screens/alerts';
import { SERVER_SIDE_EVENT_COUNT } from '../../screens/timeline';
import { createCustomRuleEnabled } from '../../tasks/api_calls/rules';
import { cleanKibana } from '../../tasks/common';
import {
bulkInvestigateSelectedEventsInTimeline,
selectAllEvents,
selectFirstPageEvents,
} from '../../tasks/common/event_table';
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver';
import { waitsForEventsToBeLoaded } from '../../tasks/hosts/events';
import { openEvents, openSessions } from '../../tasks/hosts/main';
import { login, visit } from '../../tasks/login';
import { closeTimelineUsingCloseButton } from '../../tasks/security_main';
import { ALERTS_URL, HOSTS_URL } from '../../urls/navigation';

const assertFirstPageEventsAddToTimeline = () => {
selectFirstPageEvents();
cy.get(SELECTED_ALERTS).then((sub) => {
const alertCountText = sub.text();
const alertCount = alertCountText.split(' ')[1];
bulkInvestigateSelectedEventsInTimeline();
cy.get('body').should('contain.text', `${alertCount} event IDs`);
cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount);
});
};

const assertAllEventsAddToTimeline = () => {
selectAllEvents();
cy.get(SELECTED_ALERTS).then((sub) => {
const alertCountText = sub.text(); // Selected 3,654 alerts
const alertCount = alertCountText.split(' ')[1];
bulkInvestigateSelectedEventsInTimeline();
cy.get('body').should('contain.text', `${alertCount} event IDs`);
cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount);
});
};

describe('Bulk Investigate in Timeline', () => {
before(() => {
cleanKibana();
esArchiverLoad('bulk_process');
login();
});

after(() => {
esArchiverUnload('bulk_process');
});

context('Alerts', () => {
before(() => {
createCustomRuleEnabled(getNewRule());
visit(ALERTS_URL);
});
beforeEach(() => {
waitForAlertsToPopulate();
});

afterEach(() => {
closeTimelineUsingCloseButton();
});

it('Adding multiple alerts to the timeline should be successful', () => {
assertFirstPageEventsAddToTimeline();
});

it('When selected all alerts are selected should be successfull', () => {
assertAllEventsAddToTimeline();
});
});

context('Host -> Events Viewer', () => {
before(() => {
visit(HOSTS_URL);
openEvents();
waitsForEventsToBeLoaded();
});

afterEach(() => {
closeTimelineUsingCloseButton();
});

it('Adding multiple alerts to the timeline should be successful', () => {
assertFirstPageEventsAddToTimeline();
});

it('When selected all alerts are selected should be successfull', () => {
assertAllEventsAddToTimeline();
});
});

context('Host -> Sessions Viewer', () => {
before(() => {
visit(HOSTS_URL);
openSessions();
waitsForEventsToBeLoaded();
});

afterEach(() => {
closeTimelineUsingCloseButton();
});

it('Adding multiple alerts to the timeline should be successful', () => {
assertFirstPageEventsAddToTimeline();
});

it('When selected all alerts are selected should be successfull', () => {
assertAllEventsAddToTimeline();
});
});
});
3 changes: 2 additions & 1 deletion x-pack/plugins/security_solution/cypress/objects/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

export interface SearchBarFilter {
key: string;
value: string;
value?: string;
operator?: 'is' | 'is not' | 'is one of' | 'is not one of' | 'exists' | 'does not exist';
}

export const getHostIpFilter = (): SearchBarFilter => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ export const EUI_CHECKBOX = '.euiCheckbox__input';
export const COMBO_BOX_INPUT = '[data-test-subj="comboBoxInput"]';

export const COMBO_BOX_SELECTION = '.euiMark';

export const SELECT_EVENTS_ACTION_ADD_BULK_TO_TIMELINE =
'[data-test-subj="investigate-bulk-in-timeline"]';

export const SELECT_ALL_EVENTS = '[data-test-subj="selectAllAlertsButton"]';
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ export const INSPECT_QUERY =
'[data-test-subj="events-viewer-panel"] [data-test-subj="inspect-icon-button"]';

export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]';

export const EVENT_VIEWER_CHECKBOX =
'[data-test-subj="dataGridHeaderCell-checkbox-control-column"]';
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export const EVENTS_TAB = '[data-test-subj="navigation-events"]';
export const UNCOMMON_PROCESSES_TAB = '[data-test-subj="navigation-uncommonProcesses"]';

export const HOST_OVERVIEW = `[data-test-subj="host-overview"]`;

export const SESSIONS_TAB = `[data-test-subj="navigation-sessions"]`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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 { SELECTED_ALERTS } from '../../screens/alerts';
import {
SELECT_ALL_EVENTS,
SELECT_EVENTS_ACTION_ADD_BULK_TO_TIMELINE,
} from '../../screens/common/controls';
import { EVENT_VIEWER_CHECKBOX } from '../../screens/hosts/events';

export const selectFirstPageEvents = () => {
cy.get(EVENT_VIEWER_CHECKBOX).first().scrollIntoView().click();
};

export const selectAllEvents = () => {
cy.get(EVENT_VIEWER_CHECKBOX).first().scrollIntoView().click();
cy.get(SELECT_ALL_EVENTS).click();
};

export const bulkInvestigateSelectedEventsInTimeline = () => {
cy.get(SELECTED_ALERTS).trigger('click');
cy.get(SELECT_EVENTS_ACTION_ADD_BULK_TO_TIMELINE).click();
};
9 changes: 8 additions & 1 deletion x-pack/plugins/security_solution/cypress/tasks/hosts/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@
* 2.0.
*/

import { ALL_HOSTS_TAB, EVENTS_TAB, UNCOMMON_PROCESSES_TAB } from '../../screens/hosts/main';
import {
ALL_HOSTS_TAB,
EVENTS_TAB,
SESSIONS_TAB,
UNCOMMON_PROCESSES_TAB,
} from '../../screens/hosts/main';

export const openAllHosts = () => cy.get(ALL_HOSTS_TAB).click({ force: true });

export const openEvents = () => cy.get(EVENTS_TAB).click({ force: true });

export const openUncommonProcesses = () => cy.get(UNCOMMON_PROCESSES_TAB).click({ force: true });

export const openSessions = () => cy.get(SESSIONS_TAB).click({ force: true });
16 changes: 11 additions & 5 deletions x-pack/plugins/security_solution/cypress/tasks/search_bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,21 @@ export const openAddFilterPopover = () => {
cy.get(GLOBAL_SEARCH_BAR_ADD_FILTER).click();
};

export const fillAddFilterForm = ({ key, value }: SearchBarFilter) => {
export const fillAddFilterForm = ({ key, value, operator }: SearchBarFilter) => {
cy.get(ADD_FILTER_FORM_FIELD_INPUT).should('exist');
cy.get(ADD_FILTER_FORM_FIELD_INPUT).should('be.visible');
cy.get(ADD_FILTER_FORM_FIELD_INPUT).type(key);
cy.get(ADD_FILTER_FORM_FIELD_INPUT).type(`${key}{downarrow}`);
cy.get(ADD_FILTER_FORM_FIELD_INPUT).click();
cy.get(ADD_FILTER_FORM_FIELD_OPTION(key)).click({ force: true });
cy.get(ADD_FILTER_FORM_OPERATOR_FIELD).click();
cy.get(ADD_FILTER_FORM_OPERATOR_OPTION_IS).click();
cy.get(ADD_FILTER_FORM_FILTER_VALUE_INPUT).type(value);
if (!operator) {
cy.get(ADD_FILTER_FORM_OPERATOR_FIELD).click();
cy.get(ADD_FILTER_FORM_OPERATOR_OPTION_IS).click();
} else {
cy.get(ADD_FILTER_FORM_OPERATOR_FIELD).type(`${operator}{enter}`);
}
if (value) {
cy.get(ADD_FILTER_FORM_FILTER_VALUE_INPUT).type(value);
}
cy.get(ADD_FILTER_FORM_SAVE_BUTTON).click();
cy.get(ADD_FILTER_FORM_SAVE_BUTTON).should('not.exist');
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ jest.mock('../../../timelines/components/timeline/body/control_columns', () => (
getDefaultControlColumn: (props: number) => mockGetDefaultControlColumn(props),
}));

jest.mock(
'../../../detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline',
() => ({
useAddBulkToTimelineAction: jest.fn(),
})
);

jest.mock('../../lib/kibana', () => {
const original = jest.requireActual('../../lib/kibana');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { EuiCheckbox } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import type { EntityType } from '@kbn/timelines-plugin/common';

import type { BulkActionsProp } from '@kbn/timelines-plugin/common/types';
import { dataTableActions } from '../../store/data_table';
import type { TableId } from '../../../../common/types/timeline';
import { RowRendererId } from '../../../../common/types/timeline';
Expand Down Expand Up @@ -41,6 +42,7 @@ import { useLicense } from '../../hooks/use_license';

import { useUiSetting$ } from '../../lib/kibana';
import { defaultAlertsFilters } from '../events_viewer/external_alerts_filter';
import { useAddBulkToTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline';

import {
useGetInitialUrlParamValue,
Expand Down Expand Up @@ -109,6 +111,8 @@ const EventsQueryTabBodyComponent: React.FC<EventsQueryTabBodyComponentProps> =
: c
),
title: i18n.EVENTS_GRAPH_TITLE,
showCheckboxes: true,
selectAll: true,
})
);
}, [dispatch, showExternalAlerts, tGridEnabled, tableId]);
Expand Down Expand Up @@ -149,6 +153,21 @@ const EventsQueryTabBodyComponent: React.FC<EventsQueryTabBodyComponentProps> =
[additionalFilters, showExternalAlerts]
);

const addBulkToTimelineAction = useAddBulkToTimelineAction({
localFilters: composedPageFilters,
tableId,
from: startDate,
to: endDate,
scopeId: SourcererScopeName.default,
});

const bulkActions = useMemo<BulkActionsProp | boolean>(() => {
return {
alertStatusActions: false,
customBulkActions: [addBulkToTimelineAction],
};
}, [addBulkToTimelineAction]);

return (
<>
{!globalFullScreen && (
Expand Down Expand Up @@ -177,6 +196,7 @@ const EventsQueryTabBodyComponent: React.FC<EventsQueryTabBodyComponentProps> =
unit={showExternalAlerts ? i18n.ALERTS_UNIT : i18n.EVENTS_UNIT}
defaultModel={defaultModel}
pageFilters={composedPageFilters}
bulkActions={bulkActions}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const testProps = {
rowRenderers: defaultRowRenderers,
scopeId: SourcererScopeName.default,
start: from,
bulkActions: false,
};
describe('StatefulEventsViewer', () => {
(useTimelineEvents as jest.Mock).mockReturnValue([false, mockEventViewerResponse]);
Expand Down
Loading

0 comments on commit 92d907c

Please sign in to comment.