Session view alerts loading improvements, and other polish / bug fixes. (#131773)

* sort and cursor plumbing for alertsclient

* process events route will now grab alerts for the page of events being requested. range / cursor support added to alerts client.

* handling of missing event.action in some edge case process events

* fixed to fake session leader overwriting original event it was based on

* deduping added for children, alerts. fix to alerts route

* fake process creation cleaned up, will now try and create fake parents from widened event context. this mitigates the number of potentially orphaned processes in the tree

* fixed infinite loop regression :)

* tests fixed

* tweaks to inline alert details and test fixes

* type fix

* added test for new "sort" property in AlertsClient.find

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* pagination added to alerts tab

* test fixes

* addressed awp team comments

* || -> ??

* e2e tests added for sort and search_after options

* fixed test / type check

* fixed import issue

* restored whitespace

Co-authored-by: mitodrummer <karlgodard@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Karl Godard 2022-05-16 09:48:50 -07:00 committed by GitHub
parent 6edf3dc80b
commit dc9f0f9388
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1002 additions and 557 deletions

View file

@ -97,6 +97,7 @@ interface SingleSearchAfterAndAudit {
track_total_hits?: boolean | undefined;
size?: number | undefined;
operation: WriteOperations.Update | ReadOperations.Find | ReadOperations.Get;
sort?: estypes.SortOptions[] | undefined;
lastSortIds?: Array<string | number> | undefined;
}
@ -224,6 +225,7 @@ export class AlertsClient {
size,
index,
operation,
sort,
lastSortIds = [],
}: SingleSearchAfterAndAudit) {
try {
@ -243,7 +245,7 @@ export class AlertsClient {
_source,
track_total_hits: trackTotalHits,
size,
sort: [
sort: sort || [
{
'@timestamp': {
order: 'asc',
@ -605,6 +607,8 @@ export class AlertsClient {
track_total_hits: trackTotalHits,
size,
index,
sort,
search_after: searchAfter,
}: {
query?: object | undefined;
aggs?: object | undefined;
@ -612,6 +616,8 @@ export class AlertsClient {
track_total_hits?: boolean | undefined;
_source?: string[] | undefined;
size?: number | undefined;
sort?: estypes.SortOptions[] | undefined;
search_after?: Array<string | number> | undefined;
}) {
try {
// first search for the alert by id, then use the alert info to check if user has access to it
@ -623,6 +629,8 @@ export class AlertsClient {
size,
index,
operation: ReadOperations.Find,
sort,
lastSortIds: searchAfter,
});
if (alertsSearchResponse == null) {

View file

@ -198,6 +198,136 @@ describe('find()', () => {
`);
});
test('allows custom sort', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockResponseOnce({
took: 5,
timed_out: false,
_shards: {
total: 1,
successful: 1,
failed: 0,
skipped: 0,
},
hits: {
total: 1,
max_score: 999,
hits: [
{
// @ts-expect-error incorrect fields
found: true,
_type: 'alert',
_index: '.alerts-observability.apm.alerts',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 1,
_seq_no: 362,
_primary_term: 2,
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
message: 'hello world 1',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_WORKFLOW_STATUS]: 'open',
[SPACE_IDS]: ['test_default_space_id'],
},
},
],
},
});
const result = await alertsClient.find({
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
index: '.alerts-observability.apm.alerts',
sort: [
{
'@timestamp': 'desc',
},
],
});
expect(result).toMatchInlineSnapshot(`
Object {
"_shards": Object {
"failed": 0,
"skipped": 0,
"successful": 1,
"total": 1,
},
"hits": Object {
"hits": Array [
Object {
"_id": "NoxgpHkBqbdrfX07MqXV",
"_index": ".alerts-observability.apm.alerts",
"_primary_term": 2,
"_seq_no": 362,
"_source": Object {
"kibana.alert.rule.consumer": "apm",
"kibana.alert.rule.rule_type_id": "apm.error_rate",
"kibana.alert.workflow_status": "open",
"kibana.space_ids": Array [
"test_default_space_id",
],
"message": "hello world 1",
},
"_type": "alert",
"_version": 1,
"found": true,
},
],
"max_score": 999,
"total": 1,
},
"timed_out": false,
"took": 5,
}
`);
expect(esClientMock.search).toHaveBeenCalledTimes(1);
expect(esClientMock.search.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"body": Object {
"_source": undefined,
"aggs": undefined,
"fields": Array [
"kibana.alert.rule.rule_type_id",
"kibana.alert.rule.consumer",
"kibana.alert.workflow_status",
"kibana.space_ids",
],
"query": Object {
"bool": Object {
"filter": Array [
Object {},
Object {
"term": Object {
"kibana.space_ids": "test_default_space_id",
},
},
],
"must": Array [
Object {
"match": Object {
"kibana.alert.workflow_status": "open",
},
},
],
"must_not": Array [],
"should": Array [],
},
},
"size": undefined,
"sort": Array [
Object {
"@timestamp": "desc",
},
],
"track_total_hits": undefined,
},
"ignore_unavailable": true,
"index": ".alerts-observability.apm.alerts",
"seq_no_primary_term": true,
},
]
`);
});
test('logs successful event in audit logger', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
esClientMock.search.mockResponseOnce({

View file

@ -9,6 +9,7 @@ import { IRouter } from '@kbn/core/server';
import * as t from 'io-ts';
import { transformError } from '@kbn/securitysolution-es-utils';
import { PositiveInteger } from '@kbn/securitysolution-io-ts-types';
import { SortOptions } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { RacRequestHandlerContext } from '../types';
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
@ -30,6 +31,8 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
t.record(t.string, metricsAggsSchemas),
t.undefined,
]),
sort: t.union([t.array(t.object), t.undefined]),
search_after: t.union([t.array(t.number), t.array(t.string), t.undefined]),
size: t.union([PositiveInteger, t.undefined]),
track_total_hits: t.union([t.boolean, t.undefined]),
_source: t.union([t.array(t.string), t.undefined]),
@ -44,11 +47,11 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
async (context, request, response) => {
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { query, aggs, _source, track_total_hits, size, index } = request.body;
const { query, aggs, _source, track_total_hits, size, index, sort, search_after } =
request.body;
const racContext = await context.rac;
const alertsClient = await racContext.getAlertsClient();
const alerts = await alertsClient.find({
query,
aggs,
@ -56,6 +59,8 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
track_total_hits,
size,
index,
sort: sort as SortOptions[],
search_after,
});
if (alerts == null) {
return response.notFound({

View file

@ -14,34 +14,16 @@ export const PREVIEW_ALERTS_INDEX = '.preview.alerts-security.alerts-default';
export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id';
export const ALERT_UUID_PROPERTY = 'kibana.alert.uuid';
export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ HH:mm:ss.SSS';
export const ALERT_ORIGINAL_TIME_PROPERTY = 'kibana.alert.original_time';
export const ALERT_STATUS = {
OPEN: 'open',
ACKNOWLEDGED: 'acknowledged',
CLOSED: 'closed',
};
// We fetch a large number of events per page to mitigate a few design caveats in session viewer
// 1. Due to the hierarchical nature of the data (e.g we are rendering a time ordered pid tree) there are common scenarios where there
// are few top level processes, but many nested children. For example, a build script is run on a remote host via ssh. If for example our page
// size is 10 and the build script has 500 nested children, the user would see a load more button that they could continously click without seeing
// anychange since the next 10 events would be for processes nested under a top level process that might not be expanded. That being said, it's quite
// possible there are build scripts with many thousands of events, in which case this initial large page will have the same issue. A technique used
// in previous incarnations of session view included auto expanding the node which is receiving the new page of events so as to not confuse the user.
// We may need to include this trick as part of this implementation as well.
// 2. The plain text search that comes with Session view is currently limited in that it only searches through data that has been loaded into the browser.
// The large page size allows the user to get a broader set of results per page. That being said, this feature is kind of flawed since sessions could be many thousands
// if not 100s of thousands of events, and to be required to page through these sessions to find more search matches is not a great experience. Future iterations of the
// search functionality will instead use a separate ES backend search to avoid this.
// 3. Fewer round trips to the backend!
export const PROCESS_EVENTS_PER_PAGE = 1000;
// As an initial approach, we won't be implementing pagination for alerts.
// Instead we will load this fixed amount of alerts as a maximum for a session.
// This could cause an edge case, where a noisy rule that alerts on every process event
// causes a session to only list and highlight up to 1000 alerts, even though there could
// be far greater than this amount. UX should be added to let the end user know this is
// happening and to revise their rule to be more specific.
export const ALERTS_PER_PAGE = 501;
export const PROCESS_EVENTS_PER_PAGE = 200;
export const ALERTS_PER_PROCESS_EVENTS_PAGE = 600;
export const ALERTS_PER_PAGE = 100;
// when showing the count of alerts in details panel tab, if the number
// exceeds ALERT_COUNT_THRESHOLD we put a + next to it, e.g 500+

View file

@ -1263,6 +1263,7 @@ export const childProcessMock: Process = {
orphans: [],
addEvent: (_) => undefined,
addAlert: (_) => undefined,
addChild: (_) => undefined,
clearSearch: () => undefined,
getChildren: () => [],
hasOutput: () => false,
@ -1348,6 +1349,7 @@ export const processMock: Process = {
orphans: [],
addEvent: (_) => undefined,
addAlert: (_) => undefined,
addChild: (_) => undefined,
clearSearch: () => undefined,
getChildren: () => [],
hasOutput: () => false,
@ -1553,6 +1555,7 @@ export const mockProcessMap = mockEvents.reduce(
orphans: [],
addEvent: (_) => undefined,
addAlert: (_) => undefined,
addChild: (_) => undefined,
clearSearch: () => undefined,
getChildren: () => [],
hasOutput: () => false,

View file

@ -170,6 +170,7 @@ export interface Process {
searchMatched: string | null; // either false, or set to searchQuery
addEvent(event: ProcessEvent): void;
addAlert(alert: ProcessEvent): void;
addChild(child: Process): void;
clearSearch(): void;
hasOutput(): boolean;
hasAlerts(): boolean;

View file

@ -4,10 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import React from 'react';
import { EuiIcon, EuiText, EuiAccordion, EuiNotificationBadge } from '@elastic/eui';
import { ProcessEvent } from '../../../common/types/process_tree';
import { ALERT_COUNT_THRESHOLD } from '../../../common/constants';
import { dataOrDash } from '../../utils/data_or_dash';
import { useStyles } from '../detail_panel_alert_list_item/styles';
import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item';
@ -31,10 +30,7 @@ export const DetailPanelAlertGroupItem = ({
onShowAlertDetails,
}: DetailPanelAlertsGroupItemDeps) => {
const styles = useStyles();
const alertsCount = useMemo(() => {
return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length;
}, [alerts]);
const alertsCount = alerts.length.toLocaleString();
if (!alerts[0].kibana) {
return null;

View file

@ -31,24 +31,26 @@ describe('DetailPanelAlertTab component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let mockedContext: AppContextTestRender;
let mockOnJumpToEvent = jest.fn((process) => process);
let mockShowAlertDetails = jest.fn((alertId) => alertId);
const props = {
alerts: mockAlerts,
onJumpToEvent: jest.fn((process) => process),
onShowAlertDetails: jest.fn((alertId) => alertId),
isFetchingAlerts: false,
hasNextPageAlerts: false,
fetchNextPageAlerts: jest.fn(() => true),
};
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
mockOnJumpToEvent = jest.fn((process) => process);
mockShowAlertDetails = jest.fn((alertId) => alertId);
props.onJumpToEvent.mockReset();
props.onShowAlertDetails.mockReset();
props.fetchNextPageAlerts.mockReset();
});
describe('When DetailPanelAlertTab is mounted', () => {
it('renders a list of alerts for the session (defaulting to list view mode)', async () => {
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={mockAlerts}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length);
expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeFalsy();
@ -61,13 +63,7 @@ describe('DetailPanelAlertTab component', () => {
});
it('renders a list of alerts grouped by rule when group-view clicked', async () => {
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={mockAlerts}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP));
@ -82,13 +78,10 @@ describe('DetailPanelAlertTab component', () => {
});
it('renders a sticky investigated alert (outside of main list) if one is set', async () => {
const investigatedAlertId = mockAlerts[0].kibana?.alert?.uuid;
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={mockAlerts}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
investigatedAlertId={mockAlerts[0].kibana?.alert?.uuid}
/>
<DetailPanelAlertTab {...props} investigatedAlertId={investigatedAlertId} />
);
expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy();
@ -99,13 +92,10 @@ describe('DetailPanelAlertTab component', () => {
});
it('investigated alert should be collapsible', async () => {
const investigatedAlertId = mockAlerts[0].kibana?.alert?.uuid;
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={mockAlerts}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
investigatedAlertId={mockAlerts[0].kibana?.alert?.uuid}
/>
<DetailPanelAlertTab {...props} investigatedAlertId={investigatedAlertId} />
);
expect(
@ -132,13 +122,7 @@ describe('DetailPanelAlertTab component', () => {
});
it('non investigated alert should NOT be collapsible', async () => {
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={mockAlerts}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
expect(
renderResult
@ -164,13 +148,7 @@ describe('DetailPanelAlertTab component', () => {
});
it('grouped alerts should be expandable/collapsible (default to collapsed)', async () => {
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={mockAlerts}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP));
@ -198,13 +176,7 @@ describe('DetailPanelAlertTab component', () => {
});
it('each alert list item should show a timestamp and process arguments', async () => {
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={mockAlerts}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TIMESTAMP_TEST_ID)[0]).toHaveTextContent(
mockAlerts[0]['@timestamp']!
@ -216,13 +188,7 @@ describe('DetailPanelAlertTab component', () => {
});
it('each alert group should show a rule title and alert count', async () => {
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={mockAlerts}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} />);
fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP));
@ -233,15 +199,18 @@ describe('DetailPanelAlertTab component', () => {
});
it('renders an empty state when there are no alerts', async () => {
renderResult = mockedContext.render(
<DetailPanelAlertTab
alerts={[]}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<DetailPanelAlertTab {...props} alerts={[]} />);
expect(renderResult.queryByTestId(ALERTS_TAB_EMPTY_STATE_TEST_ID)).toBeTruthy();
});
it('renders a load more button when there are more pages of alerts', async () => {
renderResult = mockedContext.render(
<DetailPanelAlertTab {...props} hasNextPageAlerts={true} />
);
expect(renderResult.queryByTestId('alerts-details-load-more')).toBeTruthy();
renderResult.queryByTestId('alerts-details-load-more')?.click();
expect(props.fetchNextPageAlerts.mock.calls.length).toEqual(1);
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState, useMemo } from 'react';
import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule } from '@elastic/eui';
import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule, EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { groupBy } from 'lodash';
@ -20,6 +20,9 @@ export const VIEW_MODE_TOGGLE = 'sessionView:detailPanelAlertsViewMode';
interface DetailPanelAlertTabDeps {
alerts: ProcessEvent[];
isFetchingAlerts: boolean;
hasNextPageAlerts?: boolean;
fetchNextPageAlerts: () => void;
onJumpToEvent: (event: ProcessEvent) => void;
onShowAlertDetails: (alertId: string) => void;
investigatedAlertId?: string;
@ -33,6 +36,9 @@ const VIEW_MODE_GROUP = 'groupView';
*/
export const DetailPanelAlertTab = ({
alerts,
isFetchingAlerts,
hasNextPageAlerts,
fetchNextPageAlerts,
onJumpToEvent,
onShowAlertDetails,
investigatedAlertId,
@ -147,6 +153,21 @@ export const DetailPanelAlertTab = ({
);
}
})}
{hasNextPageAlerts && (
<EuiButton
color="primary"
isLoading={isFetchingAlerts}
onClick={fetchNextPageAlerts}
css={styles.loadMoreBtn}
data-test-subj="alerts-details-load-more"
>
<FormattedMessage
id="xpack.sessionView.alertsLoadMoreButton"
defaultMessage="Load more alerts"
/>
</EuiButton>
)}
</div>
);
};

View file

@ -30,10 +30,16 @@ export const useStyles = () => {
margin: size.base,
};
const loadMoreBtn: CSSObject = {
margin: size.m,
width: `calc(100% - ${size.m} * 2)`,
};
return {
container,
stickyItem,
viewMode,
loadMoreBtn,
};
}, [euiTheme]);

View file

@ -141,7 +141,14 @@ export const getDetailPanelProcess = (process: Process | null): DetailPanelProce
}
});
if (!processData.executable.length) {
processData.executable = DEFAULT_PROCESS_DATA.executable;
// if there were no forks, execs (due to bad data), check if we at least have an executable for some event
const executable = process.getDetails().process?.executable;
if (executable) {
processData.executable.push([executable]);
} else {
processData.executable = DEFAULT_PROCESS_DATA.executable;
}
}
processData.entryLeader = getDetailPanelProcessLeader(details?.process?.entry_leader);

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import uuid from 'uuid';
import { sortProcesses } from '../../../common/utils/sort_processes';
import {
AlertStatusEventEntityIdMap,
@ -11,9 +12,43 @@ import {
Process,
ProcessEvent,
ProcessMap,
ProcessFields,
} from '../../../common/types/process_tree';
import { ProcessImpl } from './hooks';
// Creates an instance of Process, from a nested leader process fieldset
// This is used to ensure we always have a record for a session leader, as well as
// a parent record for potentially orphaned processes
export function inferProcessFromLeaderInfo(sourceEvent?: ProcessEvent, leader?: ProcessFields) {
const entityId = leader?.entity_id || uuid.v4();
const process = new ProcessImpl(entityId);
if (sourceEvent && leader) {
const event = {
...sourceEvent,
process: {
...sourceEvent.process,
...leader,
},
user: leader.user,
group: leader.group,
event: {
...sourceEvent.event,
id: `fake-${entityId}`,
},
};
// won't be accurate, so removing
if (sourceEvent.process?.parent === leader) {
delete event.process?.parent;
}
process.addEvent(event);
}
return process;
}
// if given event is an alert, and it exist in updatedAlertsStatus, update the alert's status
// with the updated status value in updatedAlertsStatus Map
export const updateAlertEventStatus = (
@ -87,28 +122,35 @@ export const buildProcessTree = (
events.forEach((event) => {
const { entity_id: id, parent } = event.process ?? {};
const process = processMap[id ?? ''];
const parentProcess = processMap[parent?.entity_id ?? ''];
let parentProcess = processMap[parent?.entity_id ?? ''];
// if either entity_id or parent does not exist, return
// if session leader, or process already has a parent, return
if (!id || !parent || process.id === sessionEntityId || process.parent) {
// if process already has a parent, return
if (!id || !parent || process.parent || id === sessionEntityId) {
return;
}
if (!parentProcess) {
// infer a fake process for the parent, incase we don't end up loading any parent events (due to filtering or jumpToCursor pagination)
const parentFields = event?.process?.parent;
if (parentFields?.entity_id && !processMap[parentFields.entity_id]) {
parentProcess = inferProcessFromLeaderInfo(event, parentFields);
processMap[parentProcess.id] = parentProcess;
if (!orphans.includes(parentProcess)) {
orphans.push(parentProcess);
}
} else {
if (!orphans.includes(process)) {
orphans.push(process);
}
}
}
if (parentProcess) {
process.parent = parentProcess; // handy for recursive operations (like auto expand)
if (backwardDirection) {
parentProcess.children.unshift(process);
} else {
parentProcess.children.push(process);
}
} else if (!orphans?.includes(process)) {
// if no parent process, process is probably orphaned
if (backwardDirection) {
orphans?.unshift(process);
} else {
orphans?.push(process);
}
parentProcess.addChild(process);
}
});
@ -120,13 +162,16 @@ export const buildProcessTree = (
if (parentProcessId) {
const parentProcess = processMap[parentProcessId];
process.parent = parentProcess; // handy for recursive operations (like auto expand)
if (parentProcess !== undefined) {
parentProcess.children.push(process);
if (parentProcess) {
process.parent = parentProcess;
parentProcess.addChild(process);
return;
}
} else {
newOrphans.push(process);
}
newOrphans.push(process);
});
return newOrphans;
@ -158,7 +203,7 @@ export const searchProcessTree = (
}
const event = process.getDetails();
const { working_directory: workingDirectory, args } = event.process || {};
const { working_directory: workingDirectory, args } = event.process ?? {};
// TODO: the text we search is the same as what we render.
// in future we may support KQL searches to match against any property

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import memoizeOne from 'memoize-one';
import { useState, useEffect } from 'react';
import { sortedUniqBy } from 'lodash';
import { useState, useEffect, useMemo } from 'react';
import {
AlertStatusEventEntityIdMap,
EventAction,
@ -16,18 +17,17 @@ import {
ProcessEventsPage,
} from '../../../common/types/process_tree';
import {
inferProcessFromLeaderInfo,
updateAlertEventStatus,
processNewEvents,
searchProcessTree,
autoExpandProcessTree,
updateProcessMap,
} from './helpers';
import { sortProcesses } from '../../../common/utils/sort_processes';
interface UseProcessTreeDeps {
sessionEntityId: string;
data: ProcessEventsPage[];
alerts: ProcessEvent[];
searchQuery?: string;
updatedAlertsStatus: AlertStatusEventEntityIdMap;
verboseMode: boolean;
@ -55,8 +55,6 @@ export class ProcessImpl implements Process {
}
addEvent(newEvent: ProcessEvent) {
// rather than push new events on the array, we return a new one
// this helps the below memoizeOne functions to behave correctly.
const exists = this.events.find((event) => {
return event.event?.id === newEvent.event?.id;
});
@ -67,7 +65,17 @@ export class ProcessImpl implements Process {
}
addAlert(alert: ProcessEvent) {
this.alerts = this.alerts.concat(alert);
const exists = this.alerts.find((event) => {
return event.event?.id === alert.event?.id;
});
if (!exists) {
this.alerts = this.alerts.concat(alert);
}
}
addChild(newChild: Process) {
this.children = this.children.concat(newChild);
}
clearSearch() {
@ -75,11 +83,17 @@ export class ProcessImpl implements Process {
}
getChildren(verboseMode: boolean) {
let children = this.children;
return this.getChildrenMemo(this.children, this.orphans, verboseMode);
}
getChildrenMemo = memoizeOne((children: Process[], orphans: Process[], verboseMode: boolean) => {
if (children.length === 0 && orphans.length === 0) {
return [];
}
// if there are orphans, we just render them inline with the other child processes (currently only session leader does this)
if (this.orphans.length) {
children = [...children, ...this.orphans];
if (orphans.length) {
children = [...children, ...orphans];
}
// When verboseMode is false, we filter out noise via a few techniques.
// This option is driven by the "verbose mode" toggle in SessionView/index.tsx
@ -102,8 +116,8 @@ export class ProcessImpl implements Process {
});
}
return children.sort(sortProcesses);
}
return sortedUniqBy(children.sort(sortProcesses), (child) => child.id);
});
isVerbose() {
const {
@ -235,6 +249,12 @@ export class ProcessImpl implements Process {
return actionsToFind.includes(processEvent.event?.action);
});
// there are some anomalous processes which are omitting event.action
// we return whatever we have regardless so we at least render something in process tree
if (filtered.length === 0 && events.length > 0) {
return events[events.length - 1];
}
// because events is already ordered by @timestamp we take the last event
// which could be a fork (w no exec or exit), most recent exec event (there can be multiple), or end event.
// If a process has an 'end' event will always be returned (since it is last and includes details like exit_code and end time)
@ -259,29 +279,17 @@ export class ProcessImpl implements Process {
export const useProcessTree = ({
sessionEntityId,
data,
alerts,
searchQuery,
updatedAlertsStatus,
verboseMode,
jumpToEntityId,
}: UseProcessTreeDeps) => {
// initialize map, as well as a placeholder for session leader process
// we add a fake session leader event, sourced from wide event data.
// this is because we might not always have a session leader event
// especially if we are paging in reverse from deep within a large session
const fakeLeaderEvent = data[0].events?.find?.((event) => event.event?.kind === EventKind.event);
const sessionLeaderProcess = new ProcessImpl(sessionEntityId);
const firstEvent = data[0]?.events?.[0];
const sessionLeaderProcess = useMemo(() => {
const entryLeader = firstEvent?.process?.entry_leader;
if (fakeLeaderEvent) {
fakeLeaderEvent.user = fakeLeaderEvent?.process?.entry_leader?.user;
fakeLeaderEvent.group = fakeLeaderEvent?.process?.entry_leader?.group;
fakeLeaderEvent.process = {
...fakeLeaderEvent.process,
...fakeLeaderEvent.process?.entry_leader,
parent: fakeLeaderEvent.process?.parent,
};
sessionLeaderProcess.events.push(fakeLeaderEvent);
}
return inferProcessFromLeaderInfo(firstEvent, entryLeader);
}, [firstEvent]);
const initializedProcessMap: ProcessMap = {
[sessionEntityId]: sessionLeaderProcess,
@ -289,7 +297,6 @@ export const useProcessTree = ({
const [processMap, setProcessMap] = useState(initializedProcessMap);
const [processedPages, setProcessedPages] = useState<ProcessEventsPage[]>([]);
const [alertsProcessed, setAlertsProcessed] = useState(false);
const [searchResults, setSearchResults] = useState<Process[]>([]);
const [orphans, setOrphans] = useState<Process[]>([]);
@ -327,16 +334,6 @@ export const useProcessTree = ({
}
}, [data, processMap, orphans, processedPages, sessionEntityId, jumpToEntityId]);
useEffect(() => {
// currently we are loading a single page of alerts, with no pagination
// so we only need to add these alert events to processMap once.
if (!alertsProcessed) {
const updatedProcessMap = updateProcessMap(processMap, alerts);
setProcessMap({ ...updatedProcessMap });
setAlertsProcessed(true);
}
}, [processMap, alerts, alertsProcessed]);
useEffect(() => {
setSearchResults(searchProcessTree(processMap, searchQuery, verboseMode));
}, [searchQuery, processMap, verboseMode]);

View file

@ -8,7 +8,6 @@
import React from 'react';
import {
mockData,
mockAlerts,
nullMockData,
deepNullMockData,
} from '../../../common/mocks/constants/session_view_process.mock';
@ -24,7 +23,6 @@ describe('ProcessTree component', () => {
const props: ProcessTreeDeps = {
sessionEntityId: sessionLeader.process!.entity_id!,
data: mockData,
alerts: mockAlerts,
isFetching: false,
fetchNextPage: jest.fn(),
hasNextPage: false,

View file

@ -14,7 +14,6 @@ import {
AlertStatusEventEntityIdMap,
Process,
ProcessEventsPage,
ProcessEvent,
} from '../../../common/types/process_tree';
import { useScroll } from '../../hooks/use_scroll';
import { useStyles } from './styles';
@ -41,7 +40,6 @@ export interface ProcessTreeDeps {
sessionEntityId: string;
data: ProcessEventsPage[];
alerts: ProcessEvent[];
jumpToEntityId?: string;
investigatedAlertId?: string;
@ -69,7 +67,6 @@ export interface ProcessTreeDeps {
export const ProcessTree = ({
sessionEntityId,
data,
alerts,
jumpToEntityId,
investigatedAlertId,
isFetching,
@ -93,7 +90,6 @@ export const ProcessTree = ({
const { sessionLeader, processMap, searchResults } = useProcessTree({
sessionEntityId,
data,
alerts,
searchQuery,
updatedAlertsStatus,
verboseMode,

View file

@ -6,93 +6,171 @@ Object {
"baseElement": <body>
<div>
<div
class="euiText euiText--small css-vxlbjf-EuiText"
css="[object Object]"
data-id="6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
data-test-subj="sessionView:sessionViewAlertDetail-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
>
<button
aria-label="expand"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="sessionView:sessionViewAlertDetailExpand-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="expand"
/>
</button>
<span
color="danger"
data-euiicon-type="alert"
/>
<div
class="euiText euiText--small css-lps5t-EuiText"
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap"
data-test-subj="sessionView:sessionViewAlertDetail-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
>
cmd test alert
</div>
<span
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
title="open"
>
<span
class="euiBadge__content"
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<button
aria-label="expand"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="sessionView:sessionViewAlertDetailExpand-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="expand"
/>
</button>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
class="euiBadge__text"
color="danger"
data-euiicon-type="alert"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText euiText--small css-slhc61-EuiText"
>
open
cmd test alert
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
title="open"
>
<span
class="euiBadge__content"
>
<span
class="euiBadge__text"
>
open
</span>
</span>
</span>
</span>
</span>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
style="background-color: rgb(211, 218, 230); color: rgb(0, 0, 0);"
title="exec"
>
<span
class="euiBadge__content"
>
<span
class="euiBadge__text"
>
exec
</span>
</span>
</span>
</div>
</div>
</div>
</div>
</body>,
"container": <div>
<div
class="euiText euiText--small css-vxlbjf-EuiText"
css="[object Object]"
data-id="6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
data-test-subj="sessionView:sessionViewAlertDetail-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
>
<button
aria-label="expand"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="sessionView:sessionViewAlertDetailExpand-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="expand"
/>
</button>
<span
color="danger"
data-euiicon-type="alert"
/>
<div
class="euiText euiText--small css-lps5t-EuiText"
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap"
data-test-subj="sessionView:sessionViewAlertDetail-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
>
cmd test alert
</div>
<span
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
title="open"
>
<span
class="euiBadge__content"
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<button
aria-label="expand"
class="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall"
data-test-subj="sessionView:sessionViewAlertDetailExpand-6bb22512e0e588d1a2449b61f164b216e366fba2de39e65d002ae734d71a6c38"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="expand"
/>
</button>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
class="euiBadge__text"
color="danger"
data-euiicon-type="alert"
/>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<div
class="euiText euiText--small css-slhc61-EuiText"
>
open
cmd test alert
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
style="background-color: rgb(121, 170, 217); color: rgb(0, 0, 0);"
title="open"
>
<span
class="euiBadge__content"
>
<span
class="euiBadge__text"
>
open
</span>
</span>
</span>
</span>
</span>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<span
class="euiBadge euiBadge--iconLeft css-1r0v2j9-EuiInnerText"
style="background-color: rgb(211, 218, 230); color: rgb(0, 0, 0);"
title="exec"
>
<span
class="euiBadge__content"
>
<span
class="euiBadge__text"
>
exec
</span>
</span>
</span>
</div>
</div>
</div>
</div>,
"debug": [Function],

View file

@ -40,6 +40,7 @@ describe('ProcessTreeAlerts component', () => {
expect(renderResult.queryByTestId(TEST_ID)).toBeTruthy();
expect(renderResult.queryByText(ALERT_RULE_NAME!)).toBeTruthy();
expect(renderResult.queryByText(ALERT_STATUS!)).toBeTruthy();
expect(renderResult).toMatchSnapshot();
});

View file

@ -6,7 +6,7 @@
*/
import React, { useEffect, useCallback } from 'react';
import { EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui';
import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree';
import { dataOrDash } from '../../utils/data_or_dash';
import { getBadgeColorFromAlertStatus } from './helpers';
@ -31,6 +31,7 @@ export const ProcessTreeAlert = ({
}: ProcessTreeAlertDeps) => {
const styles = useStyles({ isInvestigated, isSelected });
const { event } = alert;
const { uuid, rule, workflow_status: status } = alert.kibana?.alert || {};
useEffect(() => {
@ -58,27 +59,39 @@ export const ProcessTreeAlert = ({
const { name } = rule;
return (
<EuiText
key={uuid}
size="s"
css={styles.alert}
data-id={uuid}
data-test-subj={`sessionView:sessionViewAlertDetail-${uuid}`}
onClick={handleClick}
>
<EuiButtonIcon
iconType="expand"
aria-label="expand"
data-test-subj={`sessionView:sessionViewAlertDetailExpand-${uuid}`}
onClick={handleExpandClick}
/>
<EuiIcon type="alert" color="danger" />
<EuiText css={styles.alertName} size="s">
{dataOrDash(name)}
</EuiText>
<EuiBadge color={getBadgeColorFromAlertStatus(status)} css={styles.alertStatus}>
{dataOrDash(status)}
</EuiBadge>
</EuiText>
<div key={uuid} css={styles.alert} data-id={uuid}>
<EuiFlexGroup
alignItems="center"
gutterSize="s"
wrap
onClick={handleClick}
data-test-subj={`sessionView:sessionViewAlertDetail-${uuid}`}
>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="expand"
aria-label="expand"
data-test-subj={`sessionView:sessionViewAlertDetailExpand-${uuid}`}
onClick={handleExpandClick}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type="alert" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText css={styles.alertName} size="s">
{dataOrDash(name)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color={getBadgeColorFromAlertStatus(status)} css={styles.alertStatus}>
{dataOrDash(status)}
</EuiBadge>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge css={styles.actionBadge}>{event?.action}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};

View file

@ -43,11 +43,7 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => {
const alert: CSSObject = {
fontFamily: font.family,
display: 'flex',
gap: size.s,
alignItems: 'center',
padding: `0 ${size.base}`,
boxSizing: 'content-box',
padding: `0 ${size.m}`,
cursor: 'pointer',
'&:not(:last-child)': {
marginBottom: size.s,
@ -58,11 +54,15 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => {
},
'&& button': {
flexShrink: 0,
marginRight: size.s,
marginRight: size.xs,
'&:hover, &:focus, &:focus-within': {
backgroundColor: transparentize(euiVars.buttonsBackgroundNormalDefaultPrimary, 0.2),
},
},
'&& .euiFlexItem': {
marginTop: size.xxs,
marginBottom: size.xxs,
},
};
const alertStatus: CSSObject = {
@ -70,14 +70,18 @@ export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => {
};
const alertName: CSSObject = {
padding: `${size.xs} 0`,
color: colors.title,
};
const actionBadge: CSSObject = {
textTransform: 'capitalize',
};
return {
alert,
alertStatus,
alertName,
actionBadge,
};
}, [euiTheme, isInvestigated, isSelected, euiVars]);

View file

@ -202,17 +202,7 @@ export function ProcessTreeNode({
}
}, [hasExec, process.parent]);
const children = useMemo(() => {
if (searchResults) {
// noop
// Only used to break cache on this memo when search changes. We need this ref
// to avoid complaints from the useEffect dependency eslint rule.
// This fixes an issue when verbose mode is OFF and there are matching results on
// hidden processes.
}
return process.getChildren(verboseMode);
}, [process, verboseMode, searchResults]);
const children = process.getChildren(verboseMode);
if (!processDetails?.process) {
return null;
@ -229,7 +219,7 @@ export function ProcessTreeNode({
user,
} = processDetails.process;
const shouldRenderChildren = childrenExpanded && children?.length > 0;
const shouldRenderChildren = isSessionLeader || (childrenExpanded && children?.length > 0);
const childrenTreeDepth = depth + 1;
const showUserEscalation = !isSessionLeader && !!user?.name && user.name !== parent?.user?.name;

View file

@ -18,6 +18,7 @@ import {
ALERTS_ROUTE,
PROCESS_EVENTS_ROUTE,
PROCESS_EVENTS_PER_PAGE,
ALERTS_PER_PAGE,
ALERT_STATUS_ROUTE,
QUERY_KEY_PROCESS_EVENTS,
QUERY_KEY_ALERTS,
@ -58,7 +59,7 @@ export const useFetchSessionViewProcessEvents = (
},
{
getNextPageParam: (lastPage) => {
if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) {
if (lastPage.events.length >= PROCESS_EVENTS_PER_PAGE) {
return {
cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'],
forward: true,
@ -95,27 +96,45 @@ export const useFetchSessionViewProcessEvents = (
return query;
};
export const useFetchSessionViewAlerts = (sessionEntityId: string) => {
export const useFetchSessionViewAlerts = (
sessionEntityId: string,
investigatedAlertId?: string
) => {
const { http } = useKibana<CoreStart>().services;
const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId];
const query = useQuery(
const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId, investigatedAlertId];
const query = useInfiniteQuery(
cachingKeys,
async () => {
async ({ pageParam = {} }) => {
const { cursor } = pageParam;
const res = await http.get<ProcessEventResults>(ALERTS_ROUTE, {
query: {
sessionEntityId,
investigatedAlertId,
cursor,
},
});
const events = res.events?.map((event: any) => event._source as ProcessEvent) ?? [];
return events;
return {
events,
cursor,
total: res.total,
};
},
{
getNextPageParam: (lastPage) => {
if (lastPage.events.length >= ALERTS_PER_PAGE) {
return {
cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'],
};
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
cacheTime: 0,
}
);

View file

@ -118,12 +118,34 @@ export const SessionView = ({
hasPreviousPage,
} = useFetchSessionViewProcessEvents(sessionEntityId, currentJumpToCursor);
const alertsQuery = useFetchSessionViewAlerts(sessionEntityId);
const { data: alerts, error: alertsError, isFetching: alertsFetching } = alertsQuery;
const {
data: alertsData,
fetchNextPage: fetchNextPageAlerts,
isFetching: isFetchingAlerts,
hasNextPage: hasNextPageAlerts,
error: alertsError,
} = useFetchSessionViewAlerts(sessionEntityId, investigatedAlertId);
const alerts = useMemo(() => {
let events: ProcessEvent[] = [];
if (alertsData) {
alertsData.pages.forEach((page) => {
events = events.concat(page.events);
});
}
return events;
}, [alertsData]);
const alertsCount = useMemo(() => {
return alertsData?.pages?.[0].total || 0;
}, [alertsData]);
const hasData = alerts && data && data.pages?.[0].events.length > 0;
const hasError = error || alertsError;
const renderIsLoading = (isFetching || alertsFetching) && !(data && alerts);
const dataLoaded = data && data.pages?.length > (jumpToCursor ? 1 : 0);
const renderIsLoading = isFetching && !dataLoaded;
const hasData = dataLoaded && data.pages[0].events.length > 0;
const { data: newUpdatedAlertsStatus } = useFetchAlertStatus(
updatedAlertsStatus,
fetchAlertStatus[0] ?? ''
@ -276,7 +298,6 @@ export const SessionView = ({
key={sessionEntityId + currentJumpToCursor}
sessionEntityId={sessionEntityId}
data={data.pages}
alerts={alerts}
searchQuery={searchQuery}
selectedProcess={selectedProcess}
onProcessSelected={onProcessSelected}
@ -307,6 +328,10 @@ export const SessionView = ({
>
<SessionViewDetailPanel
alerts={alerts}
alertsCount={alertsCount}
isFetchingAlerts={isFetchingAlerts}
hasNextPageAlerts={hasNextPageAlerts}
fetchNextPageAlerts={fetchNextPageAlerts}
investigatedAlertId={investigatedAlertId}
selectedProcess={selectedProcess}
onJumpToEvent={onJumpToEvent}

View file

@ -17,47 +17,40 @@ describe('SessionView component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let mockedContext: AppContextTestRender;
let mockOnJumpToEvent = jest.fn((process) => process);
let mockShowAlertDetails = jest.fn((alertId) => alertId);
const props = {
alerts: [],
alertsCount: 0,
selectedProcess: sessionViewBasicProcessMock,
isFetchingAlerts: false,
hasNextPageAlerts: false,
fetchNextPageAlerts: jest.fn(() => true),
onJumpToEvent: jest.fn((process) => process),
onShowAlertDetails: jest.fn((alertId) => alertId),
};
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
mockOnJumpToEvent = jest.fn((process) => process);
mockShowAlertDetails = jest.fn((alertId) => alertId);
props.onJumpToEvent.mockReset();
props.onShowAlertDetails.mockReset();
props.fetchNextPageAlerts.mockReset();
});
describe('When SessionViewDetailPanel is mounted', () => {
it('shows process detail by default', async () => {
renderResult = mockedContext.render(
<SessionViewDetailPanel
selectedProcess={sessionViewBasicProcessMock}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<SessionViewDetailPanel {...props} />);
expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible();
});
it('should should default state with selectedProcess null', async () => {
renderResult = mockedContext.render(
<SessionViewDetailPanel
selectedProcess={null}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
<SessionViewDetailPanel {...props} selectedProcess={null} />
);
expect(renderResult.queryAllByText('entity_id').length).toBe(5);
});
it('can switch tabs to show host details', async () => {
renderResult = mockedContext.render(
<SessionViewDetailPanel
selectedProcess={sessionViewBasicProcessMock}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<SessionViewDetailPanel {...props} />);
renderResult.queryByText('Metadata')?.click();
expect(renderResult.queryByText('hostname')).toBeVisible();
expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2);
@ -65,27 +58,13 @@ describe('SessionView component', () => {
it('can switch tabs to show alert details', async () => {
renderResult = mockedContext.render(
<SessionViewDetailPanel
alerts={mockAlerts}
selectedProcess={sessionViewBasicProcessMock}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
<SessionViewDetailPanel {...props} alerts={mockAlerts} alertsCount={mockAlerts.length} />
);
renderResult.queryByText('Alerts')?.click();
expect(renderResult.queryByText('List view')).toBeVisible();
});
it('alert tab disabled when no alerts', async () => {
renderResult = mockedContext.render(
<SessionViewDetailPanel
alerts={[]}
selectedProcess={sessionViewBasicProcessMock}
onJumpToEvent={mockOnJumpToEvent}
onShowAlertDetails={mockShowAlertDetails}
/>
);
renderResult = mockedContext.render(<SessionViewDetailPanel {...props} />);
renderResult.queryByText('Alerts')?.click();
expect(renderResult.queryByText('List view')).toBeFalsy();
});

View file

@ -19,6 +19,10 @@ import { ALERT_COUNT_THRESHOLD } from '../../../common/constants';
interface SessionViewDetailPanelDeps {
selectedProcess: Process | null;
alerts?: ProcessEvent[];
alertsCount: number;
isFetchingAlerts: boolean;
hasNextPageAlerts?: boolean;
fetchNextPageAlerts: () => void;
investigatedAlertId?: string;
onJumpToEvent: (event: ProcessEvent) => void;
onShowAlertDetails: (alertId: string) => void;
@ -29,6 +33,10 @@ interface SessionViewDetailPanelDeps {
*/
export const SessionViewDetailPanel = ({
alerts,
alertsCount,
isFetchingAlerts,
hasNextPageAlerts,
fetchNextPageAlerts,
selectedProcess,
investigatedAlertId,
onJumpToEvent,
@ -36,13 +44,9 @@ export const SessionViewDetailPanel = ({
}: SessionViewDetailPanelDeps) => {
const [selectedTabId, setSelectedTabId] = useState('process');
const alertsCount = useMemo(() => {
if (!alerts) {
return 0;
}
return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length;
}, [alerts]);
const alertsCountStr = useMemo(() => {
return alertsCount >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alertsCount + '';
}, [alertsCount]);
const tabs: EuiTabProps[] = useMemo(() => {
const hasAlerts = !!alerts?.length;
@ -75,12 +79,15 @@ export const SessionViewDetailPanel = ({
}),
append: hasAlerts && (
<EuiNotificationBadge className="eui-alignCenter" size="m">
{alertsCount}
{alertsCountStr}
</EuiNotificationBadge>
),
content: alerts && (
<DetailPanelAlertTab
alerts={alerts}
isFetchingAlerts={isFetchingAlerts}
hasNextPageAlerts={hasNextPageAlerts}
fetchNextPageAlerts={fetchNextPageAlerts}
onJumpToEvent={onJumpToEvent}
onShowAlertDetails={onShowAlertDetails}
investigatedAlertId={investigatedAlertId}
@ -90,8 +97,11 @@ export const SessionViewDetailPanel = ({
];
}, [
alerts,
alertsCountStr,
fetchNextPageAlerts,
hasNextPageAlerts,
isFetchingAlerts,
selectedProcess,
alertsCount,
onJumpToEvent,
onShowAlertDetails,
investigatedAlertId,

View file

@ -13,21 +13,7 @@ import {
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { searchAlertByUuid } from './alert_status_route';
import { mockAlerts } from '../../common/mocks/constants/session_view_process.mock';
import {
AlertsClient,
ConstructorOptions,
} from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { AlertingAuthorizationEntity } from '@kbn/alerting-plugin/server';
import { ruleDataServiceMock } from '@kbn/rule-registry-plugin/server/rule_data_plugin_service/rule_data_plugin_service.mock';
const alertingAuthMock = alertingAuthorizationMock.create();
const auditLogger = auditLoggerMock.create();
const DEFAULT_SPACE = 'test_default_space_id';
import { getAlertsClientMockInstance, resetAlertingAuthMock } from './alerts_client_mock.test';
const getEmptyResponse = async () => {
return {
@ -65,57 +51,16 @@ const getResponse = async () => {
};
};
const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse());
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
logger: loggingSystemMock.create().get(),
authorization: alertingAuthMock,
auditLogger,
ruleDataService: ruleDataServiceMock.create(),
esClient: esClientMock,
};
describe('alert_status_route.ts', () => {
beforeEach(() => {
jest.resetAllMocks();
alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE);
// @ts-expect-error
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
Promise.resolve({ filter: [] })
);
// @ts-expect-error
alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => {
const authorizedRuleTypes = new Set();
authorizedRuleTypes.add({ producer: 'apm' });
return Promise.resolve({ authorizedRuleTypes });
});
alertingAuthMock.ensureAuthorized.mockImplementation(
// @ts-expect-error
async ({
ruleTypeId,
consumer,
operation,
entity,
}: {
ruleTypeId: string;
consumer: string;
operation: string;
entity: typeof AlertingAuthorizationEntity.Alert;
}) => {
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
return Promise.resolve();
}
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
}
);
resetAlertingAuthMock();
});
describe('searchAlertByUuid(client, alertUuid)', () => {
it('should return an empty events array for a non existant alert uuid', async () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse());
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
const alertsClient = getAlertsClientMockInstance(esClient);
const body = await searchAlertByUuid(alertsClient, mockAlerts[0].kibana!.alert!.uuid!);
expect(body.events.length).toBe(0);
@ -123,7 +68,7 @@ describe('alert_status_route.ts', () => {
it('returns results for a particular alert uuid', async () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse());
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
const alertsClient = getAlertsClientMockInstance(esClient);
const body = await searchAlertByUuid(alertsClient, mockAlerts[0].kibana!.alert!.uuid!);
expect(body.events.length).toBe(1);

View file

@ -0,0 +1,113 @@
/*
* 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 {
AlertsClient,
ConstructorOptions,
} from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { AlertingAuthorizationEntity } from '@kbn/alerting-plugin/server';
import { ruleDataServiceMock } from '@kbn/rule-registry-plugin/server/rule_data_plugin_service/rule_data_plugin_service.mock';
import type { ElasticsearchClient } from '@kbn/core/server';
import {
ALERT_RULE_CONSUMER,
ALERT_RULE_TYPE_ID,
SPACE_IDS,
ALERT_WORKFLOW_STATUS,
} from '@kbn/rule-data-utils';
import { mockAlerts } from '../../common/mocks/constants/session_view_process.mock';
export const alertingAuthMock = alertingAuthorizationMock.create();
const auditLogger = auditLoggerMock.create();
const DEFAULT_SPACE = 'test_default_space_id';
const getResponse = async () => {
return {
hits: {
total: mockAlerts.length,
hits: mockAlerts.map((event) => {
return {
found: true,
_type: 'alert',
_index: '.alerts-security',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 1,
_seq_no: 362,
_primary_term: 2,
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
message: 'hello world 1',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_WORKFLOW_STATUS]: 'open',
[SPACE_IDS]: ['test_default_space_id'],
...event,
},
};
}),
},
};
};
const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse());
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
logger: loggingSystemMock.create().get(),
authorization: alertingAuthMock,
auditLogger,
ruleDataService: ruleDataServiceMock.create(),
esClient: esClientMock,
};
export function getAlertsClientMockInstance(esClient?: ElasticsearchClient) {
esClient = esClient || elasticsearchServiceMock.createElasticsearchClient(getResponse());
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
return alertsClient;
}
export function resetAlertingAuthMock() {
alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE);
// @ts-expect-error
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
Promise.resolve({ filter: [] })
);
// @ts-expect-error
alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => {
const authorizedRuleTypes = new Set();
authorizedRuleTypes.add({ producer: 'apm' });
return Promise.resolve({ authorizedRuleTypes });
});
alertingAuthMock.ensureAuthorized.mockImplementation(
// @ts-expect-error
async ({
ruleTypeId,
consumer,
operation,
entity,
}: {
ruleTypeId: string;
consumer: string;
operation: string;
entity: typeof AlertingAuthorizationEntity.Alert;
}) => {
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
return Promise.resolve();
}
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
}
);
}
// this is only here because the above imports complain if they aren't declared as part of a test file.
describe('alerts_route_mock.test.ts', () => {
it('does nothing', () => undefined);
});

View file

@ -4,30 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
ALERT_RULE_CONSUMER,
ALERT_RULE_TYPE_ID,
SPACE_IDS,
ALERT_WORKFLOW_STATUS,
} from '@kbn/rule-data-utils';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { doSearch } from './alerts_route';
import { mockEvents } from '../../common/mocks/constants/session_view_process.mock';
import {
AlertsClient,
ConstructorOptions,
} from '@kbn/rule-registry-plugin/server/alert_data_client/alerts_client';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { AlertingAuthorizationEntity } from '@kbn/alerting-plugin/server';
import { ruleDataServiceMock } from '@kbn/rule-registry-plugin/server/rule_data_plugin_service/rule_data_plugin_service.mock';
const alertingAuthMock = alertingAuthorizationMock.create();
const auditLogger = auditLoggerMock.create();
const DEFAULT_SPACE = 'test_default_space_id';
import { searchAlerts } from './alerts_route';
import { mockAlerts } from '../../common/mocks/constants/session_view_process.mock';
import { getAlertsClientMockInstance, resetAlertingAuthMock } from './alerts_client_mock.test';
const getEmptyResponse = async () => {
return {
@ -38,96 +18,46 @@ const getEmptyResponse = async () => {
};
};
const getResponse = async () => {
return {
hits: {
total: mockEvents.length,
hits: mockEvents.map((event) => {
return {
found: true,
_type: 'alert',
_index: '.alerts-security',
_id: 'NoxgpHkBqbdrfX07MqXV',
_version: 1,
_seq_no: 362,
_primary_term: 2,
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
message: 'hello world 1',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_WORKFLOW_STATUS]: 'open',
[SPACE_IDS]: ['test_default_space_id'],
...event,
},
};
}),
},
};
};
const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse());
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
logger: loggingSystemMock.create().get(),
authorization: alertingAuthMock,
auditLogger,
ruleDataService: ruleDataServiceMock.create(),
esClient: esClientMock,
};
describe('alerts_route.ts', () => {
beforeEach(() => {
jest.resetAllMocks();
alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE);
// @ts-expect-error
alertingAuthMock.getAuthorizationFilter.mockImplementation(async () =>
Promise.resolve({ filter: [] })
);
// @ts-expect-error
alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => {
const authorizedRuleTypes = new Set();
authorizedRuleTypes.add({ producer: 'apm' });
return Promise.resolve({ authorizedRuleTypes });
});
alertingAuthMock.ensureAuthorized.mockImplementation(
// @ts-expect-error
async ({
ruleTypeId,
consumer,
operation,
entity,
}: {
ruleTypeId: string;
consumer: string;
operation: string;
entity: typeof AlertingAuthorizationEntity.Alert;
}) => {
if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') {
return Promise.resolve();
}
return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`));
}
);
resetAlertingAuthMock();
});
describe('doSearch(client, sessionEntityId)', () => {
describe('searchAlerts(client, sessionEntityId)', () => {
it('should return an empty events array for a non existant entity_id', async () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse());
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
const body = await doSearch(alertsClient, 'asdf');
const alertsClient = getAlertsClientMockInstance(esClient);
const body = await searchAlerts(alertsClient, 'asdf', 100);
expect(body.events.length).toBe(0);
});
it('returns results for a particular session entity_id', async () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse());
const alertsClient = new AlertsClient({ ...alertsClientParams, esClient });
const alertsClient = getAlertsClientMockInstance();
const body = await doSearch(alertsClient, 'asdf');
const body = await searchAlerts(alertsClient, 'asdf', 100);
expect(body.events.length).toBe(mockEvents.length);
expect(body.events.length).toBe(mockAlerts.length);
});
it('takes an investigatedAlertId', async () => {
const alertsClient = getAlertsClientMockInstance();
const body = await searchAlerts(alertsClient, 'asdf', 100, mockAlerts[0].kibana?.alert?.uuid);
expect(body.events.length).toBe(mockAlerts.length + 1);
});
it('takes a range', async () => {
const alertsClient = getAlertsClientMockInstance();
const start = '2021-11-23T15:25:04.210Z';
const end = '2021-20-23T15:25:04.210Z';
const body = await searchAlerts(alertsClient, 'asdf', 100, undefined, [start, end]);
expect(body.events.length).toBe(mockAlerts.length);
});
});
});

View file

@ -14,8 +14,11 @@ import {
ALERTS_ROUTE,
ALERTS_PER_PAGE,
ENTRY_SESSION_ENTITY_ID_PROPERTY,
ALERT_UUID_PROPERTY,
ALERT_ORIGINAL_TIME_PROPERTY,
PREVIEW_ALERTS_INDEX,
} from '../../common/constants';
import { expandDottedObject } from '../../common/utils/expand_dotted_object';
export const registerAlertsRoute = (
@ -28,20 +31,37 @@ export const registerAlertsRoute = (
validate: {
query: schema.object({
sessionEntityId: schema.string(),
investigatedAlertId: schema.maybe(schema.string()),
cursor: schema.maybe(schema.string()),
range: schema.maybe(schema.arrayOf(schema.string())),
}),
},
},
async (_context, request, response) => {
const client = await ruleRegistry.getRacClientWithRequest(request);
const { sessionEntityId } = request.query;
const body = await doSearch(client, sessionEntityId);
const { sessionEntityId, investigatedAlertId, range, cursor } = request.query;
const body = await searchAlerts(
client,
sessionEntityId,
ALERTS_PER_PAGE,
investigatedAlertId,
range,
cursor
);
return response.ok({ body });
}
);
};
export const doSearch = async (client: AlertsClient, sessionEntityId: string) => {
export const searchAlerts = async (
client: AlertsClient,
sessionEntityId: string,
size: number,
investigatedAlertId?: string,
range?: string[],
cursor?: string
) => {
const indices = (await client.getAuthorizedAlertsIndices(['siem']))?.filter(
(index) => index !== PREVIEW_ALERTS_INDEX
);
@ -52,15 +72,49 @@ export const doSearch = async (client: AlertsClient, sessionEntityId: string) =>
const results = await client.find({
query: {
match: {
[ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId,
bool: {
must: [
{
term: {
[ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId,
},
},
range && {
range: {
[ALERT_ORIGINAL_TIME_PROPERTY]: {
gte: range[0],
lte: range[1],
},
},
},
].filter((item) => !!item),
},
},
track_total_hits: false,
size: ALERTS_PER_PAGE,
track_total_hits: true,
size,
index: indices.join(','),
sort: [{ '@timestamp': 'asc' }],
search_after: cursor ? [cursor] : undefined,
});
// if an alert is being investigated, fetch it on it's own, as it's not guaranteed to come back in the above request.
// we only need to do this for the first page of alerts.
if (!cursor && investigatedAlertId) {
const investigatedAlertSearch = await client.find({
query: {
match: {
[ALERT_UUID_PROPERTY]: investigatedAlertId,
},
},
size: 1,
index: indices.join(','),
});
if (investigatedAlertSearch.hits.hits.length > 0) {
results.hits.hits.unshift(investigatedAlertSearch.hits.hits[0]);
}
}
const events = results.hits.hits.map((hit: any) => {
// the alert indexes flattens many properties. this util unflattens them as session view expects structured json.
hit._source = expandDottedObject(hit._source);
@ -68,5 +122,8 @@ export const doSearch = async (client: AlertsClient, sessionEntityId: string) =>
return hit;
});
return { events };
const total =
typeof results.hits.total === 'number' ? results.hits.total : results.hits.total?.value;
return { total, events };
};

View file

@ -9,11 +9,9 @@ import { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/serve
import { registerProcessEventsRoute } from './process_events_route';
import { registerAlertsRoute } from './alerts_route';
import { registerAlertStatusRoute } from './alert_status_route';
import { sessionEntryLeadersRoute } from './session_entry_leaders_route';
export const registerRoutes = (router: IRouter, ruleRegistry: RuleRegistryPluginStartContract) => {
registerProcessEventsRoute(router);
sessionEntryLeadersRoute(router);
registerProcessEventsRoute(router, ruleRegistry);
registerAlertsRoute(router, ruleRegistry);
registerAlertStatusRoute(router, ruleRegistry);
};

View file

@ -5,8 +5,10 @@
* 2.0.
*/
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { doSearch } from './process_events_route';
import { mockEvents } from '../../common/mocks/constants/session_view_process.mock';
import { fetchEventsAndScopedAlerts } from './process_events_route';
import { mockEvents, mockAlerts } from '../../common/mocks/constants/session_view_process.mock';
import { getAlertsClientMockInstance, resetAlertingAuthMock } from './alerts_client_mock.test';
import { EventKind, ProcessEvent } from '../../common/types/process_tree';
const getEmptyResponse = async () => {
return {
@ -29,11 +31,18 @@ const getResponse = async () => {
};
describe('process_events_route.ts', () => {
describe('doSearch(client, entityId, cursor, forward)', () => {
beforeEach(() => {
jest.resetAllMocks();
resetAlertingAuthMock();
});
describe('fetchEventsAndScopedAlerts(client, entityId, cursor, forward)', () => {
it('should return an empty events array for a non existant entity_id', async () => {
const client = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse());
const alertsClient = getAlertsClientMockInstance(client);
const body = await doSearch(client, 'asdf', undefined);
const body = await fetchEventsAndScopedAlerts(client, alertsClient, 'asdf', undefined);
expect(body.events.length).toBe(0);
expect(body.total).toBe(0);
@ -41,17 +50,34 @@ describe('process_events_route.ts', () => {
it('returns results for a particular session entity_id', async () => {
const client = elasticsearchServiceMock.createElasticsearchClient(getResponse());
const alertsClient = getAlertsClientMockInstance();
const body = await doSearch(client, 'mockId', undefined);
const body = await fetchEventsAndScopedAlerts(client, alertsClient, 'mockId', undefined);
expect(body.events.length).toBe(mockEvents.length);
expect(body.total).toBe(body.events.length);
expect(body.events.length).toBe(mockEvents.length + mockAlerts.length);
const eventsOnly = body.events.filter(
(event) => (event._source as ProcessEvent)?.event?.kind === EventKind.event
);
const alertsOnly = body.events.filter(
(event) => (event._source as ProcessEvent)?.event?.kind === EventKind.signal
);
expect(eventsOnly.length).toBe(mockEvents.length);
expect(alertsOnly.length).toBe(mockAlerts.length);
expect(body.total).toBe(mockEvents.length);
});
it('returns hits in reverse order when paginating backwards', async () => {
const client = elasticsearchServiceMock.createElasticsearchClient(getResponse());
const alertsClient = getAlertsClientMockInstance();
const body = await doSearch(client, 'mockId', undefined, false);
const body = await fetchEventsAndScopedAlerts(
client,
alertsClient,
'mockId',
undefined,
false
);
expect(body.events[0]._source).toEqual(mockEvents[mockEvents.length - 1]);
});

View file

@ -5,16 +5,27 @@
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import _ from 'lodash';
import type { ElasticsearchClient } from '@kbn/core/server';
import { IRouter } from '@kbn/core/server';
import type {
AlertsClient,
RuleRegistryPluginStartContract,
} from '@kbn/rule-registry-plugin/server';
import {
ALERTS_PER_PROCESS_EVENTS_PAGE,
PROCESS_EVENTS_ROUTE,
PROCESS_EVENTS_PER_PAGE,
PROCESS_EVENTS_INDEX,
ENTRY_SESSION_ENTITY_ID_PROPERTY,
} from '../../common/constants';
import { ProcessEvent } from '../../common/types/process_tree';
import { searchAlerts } from './alerts_route';
export const registerProcessEventsRoute = (router: IRouter) => {
export const registerProcessEventsRoute = (
router: IRouter,
ruleRegistry: RuleRegistryPluginStartContract
) => {
router.get(
{
path: PROCESS_EVENTS_ROUTE,
@ -28,20 +39,30 @@ export const registerProcessEventsRoute = (router: IRouter) => {
},
async (context, request, response) => {
const client = (await context.core).elasticsearch.client.asCurrentUser;
const alertsClient = await ruleRegistry.getRacClientWithRequest(request);
const { sessionEntityId, cursor, forward = true } = request.query;
const body = await doSearch(client, sessionEntityId, cursor, forward);
const body = await fetchEventsAndScopedAlerts(
client,
alertsClient,
sessionEntityId,
cursor,
forward
);
return response.ok({ body });
}
);
};
export const doSearch = async (
export const fetchEventsAndScopedAlerts = async (
client: ElasticsearchClient,
alertsClient: AlertsClient,
sessionEntityId: string,
cursor: string | undefined,
forward = true
) => {
const cursorMillis = cursor && new Date(cursor).getTime() + (forward ? -1 : 1);
const search = await client.search({
index: [PROCESS_EVENTS_INDEX],
body: {
@ -52,11 +73,11 @@ export const doSearch = async (
},
size: PROCESS_EVENTS_PER_PAGE,
sort: [{ '@timestamp': forward ? 'asc' : 'desc' }],
search_after: cursor ? [cursor] : undefined,
search_after: cursorMillis ? [cursorMillis] : undefined,
},
});
const events = search.hits.hits;
let events = search.hits.hits;
if (!forward) {
events.reverse();
@ -65,6 +86,28 @@ export const doSearch = async (
const total =
typeof search.hits.total === 'number' ? search.hits.total : search.hits.total?.value;
if (events.length > 0) {
// go grab any alerts which happened in this page of events.
const firstEvent = _.first(events)?._source as ProcessEvent;
const lastEvent = _.last(events)?._source as ProcessEvent;
let range;
if (firstEvent?.['@timestamp'] && lastEvent?.['@timestamp']) {
range = [firstEvent['@timestamp'], lastEvent['@timestamp']];
}
const alertsBody = await searchAlerts(
alertsClient,
sessionEntityId,
ALERTS_PER_PROCESS_EVENTS_PAGE,
undefined,
range
);
events = [...events, ...alertsBody.events];
}
return {
total,
events,

View file

@ -1,37 +0,0 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { IRouter } from '@kbn/core/server';
import { SESSION_ENTRY_LEADERS_ROUTE, PROCESS_EVENTS_INDEX } from '../../common/constants';
export const sessionEntryLeadersRoute = (router: IRouter) => {
router.get(
{
path: SESSION_ENTRY_LEADERS_ROUTE,
validate: {
query: schema.object({
id: schema.string(),
}),
},
},
async (context, request, response) => {
const client = (await context.core).elasticsearch.client.asCurrentUser;
const { id } = request.query;
const result = await client.get({
index: PROCESS_EVENTS_INDEX,
id,
});
return response.ok({
body: {
session_entry_leader: result?._source,
},
});
}
);
};

View file

@ -39,7 +39,7 @@
"id": "space2alert",
"source": {
"event.kind" : "signal",
"@timestamp": "2020-12-16T15:16:18.570Z",
"@timestamp": "2020-12-16T15:16:19.570Z",
"kibana.alert.rule.rule_type_id": "apm.error_rate",
"message": "hello world 1",
"kibana.alert.rule.consumer": "apm",
@ -56,7 +56,7 @@
"id": "020202",
"source": {
"event.kind" : "signal",
"@timestamp": "2020-12-16T15:16:18.570Z",
"@timestamp": "2020-12-16T15:16:20.570Z",
"kibana.alert.rule.rule_type_id": "siem.queryRule",
"message": "hello world security",
"kibana.alert.rule.consumer": "siem",
@ -73,7 +73,7 @@
"id": "020204",
"source": {
"event.kind" : "signal",
"@timestamp": "2020-12-16T15:16:18.570Z",
"@timestamp": "2020-12-16T15:16:21.570Z",
"kibana.alert.rule.rule_type_id": "siem.queryRule",
"message": "hello world security",
"kibana.alert.rule.consumer": "siem",
@ -89,7 +89,7 @@
"index": ".alerts-security.alerts",
"id": "space1securityalert",
"source": {
"@timestamp": "2020-12-16T15:16:18.570Z",
"@timestamp": "2020-12-16T15:16:22.570Z",
"kibana.alert.rule.rule_type_id": "siem.queryRule",
"message": "hello world security",
"kibana.alert.rule.consumer": "siem",
@ -105,7 +105,7 @@
"index": ".alerts-security.alerts",
"id": "space2securityalert",
"source": {
"@timestamp": "2020-12-16T15:16:18.570Z",
"@timestamp": "2020-12-16T15:16:23.570Z",
"kibana.alert.rule.rule_type_id": "siem.queryRule",
"message": "hello world security",
"kibana.alert.rule.consumer": "siem",

View file

@ -158,6 +158,93 @@ export default ({ getService }: FtrProviderContext) => {
expect(found.body.hits.total.value).to.be.above(0);
});
it(`${superUser.username} should allow a custom sort and return alerts which match query in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => {
const found = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
.auth(superUser.username, superUser.password)
.set('kbn-xsrf', 'true')
.send({
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
sort: [
{
'@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that.
},
],
index: SECURITY_SOLUTION_ALERT_INDEX,
});
expect(found.statusCode).to.eql(200);
expect(found.body.hits.total.value).to.be.above(0);
let lastSort = Infinity;
found.body.hits.hits.forEach((hit: any) => {
expect(hit.sort).to.be.above(0);
if (hit.sort > lastSort) {
throw new Error('sort by timestamp desc failed.');
}
lastSort = hit.sort;
});
});
it(`${superUser.username} should handle an invalid custom sort`, async () => {
const found = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
.auth(superUser.username, superUser.password)
.set('kbn-xsrf', 'true')
.send({
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
sort: [
{
asdf: 'invalid',
},
],
index: SECURITY_SOLUTION_ALERT_INDEX,
});
expect(found.statusCode).to.eql(404);
});
it(`${superUser.username} should allow a custom sort (using search_after) and return alerts which match query in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => {
const firstSearch = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
.auth(superUser.username, superUser.password)
.set('kbn-xsrf', 'true')
.send({
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
sort: [
{
'@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that.
},
],
index: SECURITY_SOLUTION_ALERT_INDEX,
});
// grab second to last event cursor
const hits = firstSearch.body.hits.hits;
const cursor = hits[hits.length - 2].sort[0];
const secondSearch = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)
.auth(superUser.username, superUser.password)
.set('kbn-xsrf', 'true')
.send({
query: { match: { [ALERT_WORKFLOW_STATUS]: 'open' } },
sort: [
{
'@timestamp': 'desc', // the default in alerts_client.ts is timestamp ascending, so we are testing the reverse of that.
},
],
search_after: [cursor],
index: SECURITY_SOLUTION_ALERT_INDEX,
});
expect(secondSearch.body.hits.hits.length).equal(1);
// there should only be one result, as we are searching after the second to last record of the first search
expect(secondSearch.body.hits.hits[0].sort).to.be.below(cursor); // below since we are paging backwards in time
});
it(`${superUser.username} should allow cardinality aggs in ${SPACE1}/${SECURITY_SOLUTION_ALERT_INDEX}`, async () => {
const found = await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}/find`)