[Security Solution][Resolver] Resolver query panel load more (#79160)

This commit is contained in:
Kevin Qualters 2020-10-05 21:43:01 -04:00 committed by GitHub
parent 52379a0e77
commit 32d45b6e55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 545 additions and 50 deletions

View file

@ -50,7 +50,7 @@ export function dataAccessLayerFactory(
after?: string
): Promise<ResolverPaginatedEvents> {
return context.services.http.post('/api/endpoint/resolver/events', {
query: { afterEvent: after },
query: { afterEvent: after, limit: 25 },
body: JSON.stringify({
filter: `process.entity_id:"${entityID}" and event.category:"${category}"`,
}),

View file

@ -0,0 +1,110 @@
/*
* 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 { DataAccessLayer } from '../../types';
import { mockTreeWithOneNodeAndTwoPagesOfRelatedEvents } from '../../mocks/resolver_tree';
import {
ResolverRelatedEvents,
ResolverTree,
ResolverEntityIndex,
SafeResolverEvent,
} from '../../../../common/endpoint/types';
import * as eventModel from '../../../../common/endpoint/models/event';
interface Metadata {
/**
* The `_id` of the document being analyzed.
*/
databaseDocumentID: string;
/**
* A record of entityIDs to be used in tests assertions.
*/
entityIDs: {
/**
* The entityID of the node related to the document being analyzed.
*/
origin: 'origin';
};
}
export function oneNodeWithPaginatedEvents(): {
dataAccessLayer: DataAccessLayer;
metadata: Metadata;
} {
const metadata: Metadata = {
databaseDocumentID: '_id',
entityIDs: { origin: 'origin' },
};
const tree = mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({
originID: metadata.entityIDs.origin,
});
return {
metadata,
dataAccessLayer: {
/**
* Fetch related events for an entity ID
*/
async relatedEvents(entityID: string): Promise<ResolverRelatedEvents> {
/**
* Respond with the mocked related events when the origin's related events are fetched.
**/
const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : [];
return {
entityID,
events,
nextEvent: null,
};
},
/**
* If called with an "after" cursor, return the 2nd page, else return the first.
*/
async eventsWithEntityIDAndCategory(
entityID: string,
category: string,
after?: string
): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> {
let events: SafeResolverEvent[] = [];
const eventsOfCategory = tree.relatedEvents.events.filter(
(event) => event.event?.category === category
);
if (after === undefined) {
events = eventsOfCategory.slice(0, 25);
} else {
events = eventsOfCategory.slice(25);
}
return {
events,
nextEvent: typeof after === 'undefined' ? 'firstEventPage2' : null,
};
},
/**
* Any of the origin's related events by event.id
*/
async event(eventID: string): Promise<SafeResolverEvent | null> {
return (
tree.relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null
);
},
/**
* Fetch a ResolverTree for a entityID
*/
async resolverTree(): Promise<ResolverTree> {
return tree;
},
/**
* Get entities matching a document.
*/
async entities(): Promise<ResolverEntityIndex> {
return [{ entity_id: metadata.entityIDs.origin }];
},
},
};
}

View file

@ -8,6 +8,47 @@ import { mockEndpointEvent } from './endpoint_event';
import { ResolverTree, SafeResolverEvent } from '../../../common/endpoint/types';
import * as eventModel from '../../../common/endpoint/models/event';
export function mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({
originID,
}: {
originID: string;
}): ResolverTree {
const originEvent: SafeResolverEvent = mockEndpointEvent({
entityID: originID,
processName: 'c',
parentEntityID: undefined,
timestamp: 1600863932318,
});
const events = [];
// page size is currently 25
const eventsToGenerate = 30;
for (let i = 0; i < eventsToGenerate; i++) {
const newEvent = mockEndpointEvent({
entityID: originID,
eventID: `test-${i}`,
eventType: 'access',
eventCategory: 'registry',
timestamp: 1600863932318,
});
events.push(newEvent);
}
return {
entityID: originID,
children: {
childNodes: [],
nextChild: null,
},
ancestry: {
nextAncestor: null,
ancestors: [],
},
lifecycle: [originEvent],
relatedEvents: { events, nextEvent: null },
relatedAlerts: { alerts: [], nextAlert: null },
stats: { events: { total: eventsToGenerate, byCategory: {} }, totalAlerts: 0 },
};
}
export function mockTreeWith2AncestorsAndNoChildren({
originID,
firstAncestorID,

View file

@ -34,6 +34,28 @@ interface AppRequestedResolverData {
readonly payload: TreeFetcherParameters;
}
interface UserRequestedAdditionalRelatedEvents {
readonly type: 'userRequestedAdditionalRelatedEvents';
}
interface ServerFailedToReturnNodeEventsInCategory {
readonly type: 'serverFailedToReturnNodeEventsInCategory';
readonly payload: {
/**
* The cursor, if any, that can be used to retrieve more events.
*/
cursor: string | null;
/**
* The nodeID that `events` are related to.
*/
nodeID: string;
/**
* The category that `events` have in common.
*/
eventCategory: string;
};
}
interface ServerFailedToReturnResolverData {
readonly type: 'serverFailedToReturnResolverData';
/**
@ -101,4 +123,6 @@ export type DataAction =
| ServerReturnedRelatedEventData
| ServerReturnedNodeEventsInCategory
| AppRequestedResolverData
| UserRequestedAdditionalRelatedEvents
| ServerFailedToReturnNodeEventsInCategory
| AppAbortedResolverDataRequest;

View file

@ -39,6 +39,7 @@ export function updatedWith(
eventCategory: first.eventCategory,
events: [...first.events, ...second.events],
cursor: second.cursor,
lastCursorRequested: null,
};
} else {
return undefined;

View file

@ -19,7 +19,7 @@ const initialState: DataState = {
relatedEvents: new Map(),
resolverComponentInstanceID: undefined,
};
/* eslint-disable complexity */
export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialState, action) => {
if (action.type === 'appReceivedNewExternalProperties') {
const nextState: DataState = {
@ -157,6 +157,32 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS
// the action is stale, ignore it
return state;
}
} else if (action.type === 'userRequestedAdditionalRelatedEvents') {
if (state.nodeEventsInCategory) {
const nextState: DataState = {
...state,
nodeEventsInCategory: {
...state.nodeEventsInCategory,
lastCursorRequested: state.nodeEventsInCategory?.cursor,
},
};
return nextState;
} else {
return state;
}
} else if (action.type === 'serverFailedToReturnNodeEventsInCategory') {
if (state.nodeEventsInCategory) {
const nextState: DataState = {
...state,
nodeEventsInCategory: {
...state.nodeEventsInCategory,
error: true,
},
};
return nextState;
} else {
return state;
}
} else if (action.type === 'appRequestedCurrentRelatedEventData') {
const nextState: DataState = {
...state,

View file

@ -20,7 +20,7 @@ import {
import { isGraphableProcess, isTerminatedProcess } from '../../models/process_event';
import * as indexedProcessTreeModel from '../../models/indexed_process_tree';
import * as eventModel from '../../../../common/endpoint/models/event';
import * as nodeEventsInCategoryModel from './node_events_in_category_model';
import {
ResolverTree,
ResolverNodeStats,
@ -665,3 +665,74 @@ export const panelViewAndParameters = createSelector(
export const nodeEventsInCategory = (state: DataState) => {
return state.nodeEventsInCategory?.events ?? [];
};
export const lastRelatedEventResponseContainsCursor = createSelector(
(state: DataState) => state.nodeEventsInCategory,
panelViewAndParameters,
/* eslint-disable-next-line no-shadow */
function (nodeEventsInCategory, panelViewAndParameters) {
if (
nodeEventsInCategory !== undefined &&
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
nodeEventsInCategory,
panelViewAndParameters
)
) {
return nodeEventsInCategory.cursor !== null;
} else {
return false;
}
}
);
export const hadErrorLoadingNodeEventsInCategory = createSelector(
(state: DataState) => state.nodeEventsInCategory,
panelViewAndParameters,
/* eslint-disable-next-line no-shadow */
function (nodeEventsInCategory, panelViewAndParameters) {
if (
nodeEventsInCategory !== undefined &&
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
nodeEventsInCategory,
panelViewAndParameters
)
) {
return nodeEventsInCategory && nodeEventsInCategory.error === true;
} else {
return false;
}
}
);
export const isLoadingNodeEventsInCategory = createSelector(
(state: DataState) => state.nodeEventsInCategory,
panelViewAndParameters,
/* eslint-disable-next-line no-shadow */
function (nodeEventsInCategory, panelViewAndParameters) {
const { panelView } = panelViewAndParameters;
return panelView === 'nodeEventsInCategory' && nodeEventsInCategory === undefined;
}
);
export const isLoadingMoreNodeEventsInCategory = createSelector(
(state: DataState) => state.nodeEventsInCategory,
panelViewAndParameters,
/* eslint-disable-next-line no-shadow */
function (nodeEventsInCategory, panelViewAndParameters) {
if (
nodeEventsInCategory !== undefined &&
nodeEventsInCategoryModel.isRelevantToPanelViewAndParameters(
nodeEventsInCategory,
panelViewAndParameters
)
) {
return (
nodeEventsInCategory &&
nodeEventsInCategory.lastCursorRequested !== null &&
nodeEventsInCategory.cursor === nodeEventsInCategory.lastCursorRequested
);
} else {
return false;
}
}
);

View file

@ -6,7 +6,7 @@
import { Dispatch, MiddlewareAPI } from 'redux';
import { isEqual } from 'lodash';
import { ResolverPaginatedEvents, ResolverRelatedEvents } from '../../../../common/endpoint/types';
import { ResolverPaginatedEvents } from '../../../../common/endpoint/types';
import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types';
import * as selectors from '../selectors';
@ -25,46 +25,69 @@ export function RelatedEventsFetcher(
const state = api.getState();
const newParams = selectors.panelViewAndParameters(state);
const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state);
const oldParams = last;
// Update this each time before fetching data (or even if we don't fetch data) so that subsequent actions that call this (concurrently) will have up to date info.
last = newParams;
async function fetchEvents({
nodeID,
eventCategory,
cursor,
}: {
nodeID: string;
eventCategory: string;
cursor: string | null;
}) {
let result: ResolverPaginatedEvents | null = null;
try {
if (cursor) {
result = await dataAccessLayer.eventsWithEntityIDAndCategory(
nodeID,
eventCategory,
cursor
);
} else {
result = await dataAccessLayer.eventsWithEntityIDAndCategory(nodeID, eventCategory);
}
} catch (error) {
api.dispatch({
type: 'serverFailedToReturnNodeEventsInCategory',
payload: {
nodeID,
eventCategory,
cursor,
},
});
}
if (result) {
api.dispatch({
type: 'serverReturnedNodeEventsInCategory',
payload: {
events: result.events,
eventCategory,
cursor: result.nextEvent,
nodeID,
},
});
}
}
// If the panel view params have changed and the current panel view is either `nodeEventsInCategory` or `eventDetail`, then fetch the related events for that nodeID.
if (!isEqual(newParams, oldParams)) {
if (newParams.panelView === 'nodeEventsInCategory') {
const nodeID = newParams.panelParameters.nodeID;
const result:
| ResolverPaginatedEvents
| undefined = await dataAccessLayer.eventsWithEntityIDAndCategory(
fetchEvents({
nodeID,
newParams.panelParameters.eventCategory
);
if (result) {
api.dispatch({
type: 'serverReturnedNodeEventsInCategory',
payload: {
events: result.events,
eventCategory: newParams.panelParameters.eventCategory,
cursor: result.nextEvent,
nodeID,
},
});
}
} else if (newParams.panelView === 'eventDetail') {
const nodeID = newParams.panelParameters.nodeID;
const result: ResolverRelatedEvents | undefined = await dataAccessLayer.relatedEvents(
nodeID
);
if (result) {
api.dispatch({
type: 'serverReturnedRelatedEventData',
payload: result,
});
}
eventCategory: newParams.panelParameters.eventCategory,
cursor: null,
});
}
} else if (isLoadingMoreEvents) {
const nodeEventsInCategory = state.data.nodeEventsInCategory;
if (nodeEventsInCategory !== undefined) {
fetchEvents(nodeEventsInCategory);
}
}
};

View file

@ -364,6 +364,37 @@ export const nodeEventsInCategory = composeSelectors(
dataSelectors.nodeEventsInCategory
);
/**
* Flag used to show a Load More Data button in the nodeEventsOfType panel view.
*/
export const lastRelatedEventResponseContainsCursor = composeSelectors(
dataStateSelector,
dataSelectors.lastRelatedEventResponseContainsCursor
);
/**
* Flag to show an error message when loading more related events.
*/
export const hadErrorLoadingNodeEventsInCategory = composeSelectors(
dataStateSelector,
dataSelectors.hadErrorLoadingNodeEventsInCategory
);
/**
* Flag used to show a loading view for the initial loading of related events.
*/
export const isLoadingNodeEventsInCategory = composeSelectors(
dataStateSelector,
dataSelectors.isLoadingNodeEventsInCategory
);
/**
* Flag used to show a loading state for any additional related events.
*/
export const isLoadingMoreNodeEventsInCategory = composeSelectors(
dataStateSelector,
dataSelectors.isLoadingMoreNodeEventsInCategory
);
/**
* Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a
* concern-specific selector. `selector` should return the concern-specific state.

View file

@ -227,6 +227,17 @@ export interface NodeEventsInCategoryState {
* The cursor, if any, that can be used to retrieve more events.
*/
cursor: null | string;
/**
* The cursor, if any, that was last used to fetch additional related events.
*/
lastCursorRequested?: null | string;
/**
* Flag for showing an error message when fetching additional related events.
*/
error?: boolean;
}
/**

View file

@ -0,0 +1,98 @@
/*
* 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 { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history';
import { oneNodeWithPaginatedEvents } from '../../data_access_layer/mocks/one_node_with_paginated_related_events';
import { Simulator } from '../../test_utilities/simulator';
// Extend jest with a custom matcher
import '../../test_utilities/extend_jest';
import { urlSearch } from '../../test_utilities/url_search';
// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances
const resolverComponentInstanceID = 'resolverComponentInstanceID';
describe(`Resolver: when analyzing a tree with only the origin and paginated related events, and when the component instance ID is ${resolverComponentInstanceID}`, () => {
/**
* Get (or lazily create and get) the simulator.
*/
let simulator: () => Simulator;
/** lazily populated by `simulator`. */
let simulatorInstance: Simulator | undefined;
let memoryHistory: HistoryPackageHistoryInterface<never>;
beforeEach(() => {
// create a mock data access layer
const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneNodeWithPaginatedEvents();
memoryHistory = createMemoryHistory();
// create a resolver simulator, using the data access layer and an arbitrary component instance ID
simulator = () => {
if (simulatorInstance) {
return simulatorInstance;
} else {
simulatorInstance = new Simulator({
databaseDocumentID: dataAccessLayerMetadata.databaseDocumentID,
dataAccessLayer,
resolverComponentInstanceID,
history: memoryHistory,
indices: [],
});
return simulatorInstance;
}
};
});
afterEach(() => {
simulatorInstance = undefined;
});
describe(`when the URL query string is showing a resolver with nodeID origin, panel view nodeEventsInCategory, and eventCategory registry`, () => {
beforeEach(() => {
memoryHistory.push({
search: urlSearch(resolverComponentInstanceID, {
panelParameters: { nodeID: 'origin', eventCategory: 'registry' },
panelView: 'nodeEventsInCategory',
}),
});
});
it('should show the load more data button', async () => {
await expect(
simulator().map(() => ({
loadMoreButton: simulator().testSubject('resolver:nodeEventsInCategory:loadMore').length,
visibleEvents: simulator().testSubject(
'resolver:panel:node-events-in-category:event-link'
).length,
}))
).toYieldEqualTo({
loadMoreButton: 1,
visibleEvents: 25,
});
});
describe('when the user clicks the load more button', () => {
beforeEach(async () => {
const loadMore = await simulator().resolve('resolver:nodeEventsInCategory:loadMore');
if (loadMore) {
loadMore.simulate('click', { button: 0 });
}
});
it('should hide the load more button and show all 30 events', async () => {
await expect(
simulator().map(() => ({
loadMoreButton: simulator().testSubject('resolver:nodeEventsInCategory:loadMore')
.length,
visibleEvents: simulator().testSubject(
'resolver:panel:node-events-in-category:event-link'
).length,
}))
).toYieldEqualTo({
loadMoreButton: 0,
visibleEvents: 30,
});
});
});
});
});

View file

@ -4,11 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable react/display-name */
import React, { memo, Fragment } from 'react';
import React, { memo, useCallback, Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui';
import {
EuiSpacer,
EuiText,
EuiButtonEmpty,
EuiHorizontalRule,
EuiFlexItem,
EuiButton,
EuiCallOut,
} from '@elastic/eui';
import { useSelector } from 'react-redux';
import { FormattedMessage } from '@kbn/i18n/react';
import { StyledPanel } from '../styles';
@ -21,6 +27,7 @@ import { ResolverState } from '../../types';
import { PanelLoading } from './panel_loading';
import { DescriptiveName } from './descriptive_name';
import { useLinkProps } from '../use_link_props';
import { useResolverDispatch } from '../use_resolver_dispatch';
import { useFormattedDate } from './use_formatted_date';
/**
@ -44,29 +51,56 @@ export const NodeEventsInCategory = memo(function ({
);
const events = useSelector((state: ResolverState) => selectors.nodeEventsInCategory(state));
const isLoading = useSelector(selectors.isLoadingNodeEventsInCategory);
const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory);
return (
<>
{eventCount === undefined || processEvent === null ? (
{isLoading || processEvent === null ? (
<StyledPanel>
<PanelLoading />
</StyledPanel>
) : (
<StyledPanel data-test-subj="resolver:panel:events-in-category">
<NodeEventsInCategoryBreadcrumbs
nodeName={eventModel.processNameSafeVersion(processEvent)}
eventCategory={eventCategory}
eventCount={eventCount}
nodeID={nodeID}
eventsInCategoryCount={eventsInCategoryCount}
/>
<EuiSpacer size="l" />
<NodeEventList eventCategory={eventCategory} nodeID={nodeID} events={events} />
{hasError ? (
<EuiCallOut
title={i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.nodeEventsByType.errorPrimary',
{
defaultMessage: 'Unable to load events.',
}
)}
color="danger"
iconType="alert"
data-test-subj="resolver:nodeEventsInCategory:error"
>
<p>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.nodeEventsByType.errorSecondary"
defaultMessage="An error occurred when fetching the events."
/>
</p>
</EuiCallOut>
) : (
<>
<NodeEventsInCategoryBreadcrumbs
nodeName={eventModel.processNameSafeVersion(processEvent)}
eventCategory={eventCategory}
eventCount={eventCount}
nodeID={nodeID}
eventsInCategoryCount={eventsInCategoryCount}
/>
<EuiSpacer size="l" />
<NodeEventList eventCategory={eventCategory} nodeID={nodeID} events={events} />
</>
)}
</StyledPanel>
)}
</>
);
});
NodeEventsInCategory.displayName = 'NodeEventsInCategory';
/**
* Rendered for each event in the list.
*/
@ -136,6 +170,14 @@ const NodeEventList = memo(function NodeEventList({
events: SafeResolverEvent[];
nodeID: string;
}) {
const dispatch = useResolverDispatch();
const handleLoadMore = useCallback(() => {
dispatch({
type: 'userRequestedAdditionalRelatedEvents',
});
}, [dispatch]);
const isLoading = useSelector(selectors.isLoadingMoreNodeEventsInCategory);
const hasMore = useSelector(selectors.lastRelatedEventResponseContainsCursor);
return (
<>
{events.map((event, index) => (
@ -144,6 +186,23 @@ const NodeEventList = memo(function NodeEventList({
{index === events.length - 1 ? null : <EuiHorizontalRule margin="m" />}
</Fragment>
))}
{hasMore && (
<EuiFlexItem grow={false}>
<EuiButton
color={'primary'}
size="s"
fill
onClick={handleLoadMore}
isLoading={isLoading}
data-test-subj="resolver:nodeEventsInCategory:loadMore"
>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.nodeEventsByType.loadMore"
defaultMessage="Load More Data"
/>
</EuiButton>
</EuiFlexItem>
)}
</>
);
});
@ -166,7 +225,7 @@ const NodeEventsInCategoryBreadcrumbs = memo(function ({
/**
* The events to list.
*/
eventCount: number;
eventCount: number | undefined;
nodeID: string;
/**
* The count of events in the category that this list is showing.