[Security Solution][Endpoint][Response Actions] Response actions log table view (#134520)

* use hit total for paging

fixes elastic/security-team/issues/3871

* use management default page and pageSize

fixes elastic/security-team/issues/3871

* augment fake data

* add response actions page and component

fixes elastic/security-team/issues/3871

* Use component in responder flyout

fixes elastic/security-team/issues/3871

* review changes

fixes elastic/security-team/issues/3871
fixes elastic/security-team/issues/3896

* removes duration column as per new mocks

- update status texts
- rename command placed at column name

* date filters with local storage

fixes elastic/security-team/issues/4037

* review changes

* fix type in request

query object as a first param is always present in the request

* use 1-based paging on API requests

review changes
refs 751e3145e5

* update schema to handle single agent ids correctly

review change

* remove faker stuff

refs b584ada8f5

* memoized callback

review changes

* update test

refs 898bdc4949

* update tray font styles as per mock

refs elastic/security-team/issues/4218

* show paged ranges on the table

refs elastic/security-team/issues/4218

* Remove local storage

also auto refresh data when enabled

* cleanup

* output section styles

fixes elastic/security-team/issues/4218

* fix paging info

fixes elastic/security-team/issues/4218

* Add tests

fixes elastic/security-team/issues/3896
fixes elastic/security-team/issues/4037
fixes elastic/security-team/issues/4218

* review changes

* update tests

refs 6e36228c4e
This commit is contained in:
Ashokaditya 2022-06-28 18:13:00 +02:00 committed by GitHub
parent 06d7c1c91f
commit 35191f0182
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1572 additions and 89 deletions

View file

@ -105,6 +105,7 @@ export enum SecurityPageName {
networkTls = 'network-tls',
overview = 'overview',
policies = 'policy',
responseActions = 'response_actions',
rules = 'rules',
rulesCreate = 'rules-create',
timelines = 'timelines',
@ -149,6 +150,7 @@ export const EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/event_filters` as const;
export const HOST_ISOLATION_EXCEPTIONS_PATH =
`${MANAGEMENT_PATH}/host_isolation_exceptions` as const;
export const BLOCKLIST_PATH = `${MANAGEMENT_PATH}/blocklist` as const;
export const RESPONSE_ACTIONS_PATH = `${MANAGEMENT_PATH}/response_actions` as const;
export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}` as const;
export const APP_LANDING_PATH = `${APP_PATH}${LANDING_PATH}` as const;
@ -174,6 +176,7 @@ export const APP_EVENT_FILTERS_PATH = `${APP_PATH}${EVENT_FILTERS_PATH}` as cons
export const APP_HOST_ISOLATION_EXCEPTIONS_PATH =
`${APP_PATH}${HOST_ISOLATION_EXCEPTIONS_PATH}` as const;
export const APP_BLOCKLIST_PATH = `${APP_PATH}${BLOCKLIST_PATH}` as const;
export const APP_RESPONSE_ACTIONS_PATH = `${APP_PATH}${RESPONSE_ACTIONS_PATH}` as const;
// cloud logs to exclude from default index pattern
export const EXCLUDE_ELASTIC_CLOUD_INDICES = ['-*elastic-cloud-logs-*'];

View file

@ -24,9 +24,7 @@ import {
export class EndpointActionGenerator extends BaseDataGenerator {
/** Generate a random endpoint Action request (isolate or unisolate) */
generate(overrides: DeepPartial<LogsEndpointAction> = {}): LogsEndpointAction {
const timeStamp = overrides['@timestamp']
? new Date(overrides['@timestamp'])
: new Date(this.randomPastDate());
const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date();
return merge(
{
@ -76,6 +74,14 @@ export class EndpointActionGenerator extends BaseDataGenerator {
): LogsEndpointActionResponse {
const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date();
const startedAtTimes: number[] = [];
[2, 3, 5, 8, 13, 21].forEach((n) => {
startedAtTimes.push(
timeStamp.setMinutes(-this.randomN(n)),
timeStamp.setSeconds(-this.randomN(n))
);
});
return merge(
{
'@timestamp': timeStamp.toISOString(),
@ -90,7 +96,8 @@ export class EndpointActionGenerator extends BaseDataGenerator {
comment: '',
parameters: undefined,
},
started_at: this.randomPastDate(),
// randomly before a few hours/minutes/seconds later
started_at: new Date(startedAtTimes[this.randomN(startedAtTimes.length)]).toISOString(),
},
error: undefined,
},

View file

@ -22,9 +22,7 @@ import {
export class FleetActionGenerator extends BaseDataGenerator {
/** Generate a random endpoint Action (isolate or unisolate) */
generate(overrides: DeepPartial<EndpointAction> = {}): EndpointAction {
const timeStamp = overrides['@timestamp']
? new Date(overrides['@timestamp'])
: new Date(this.randomPastDate());
const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date();
return merge(
{
@ -65,6 +63,14 @@ export class FleetActionGenerator extends BaseDataGenerator {
generateResponse(overrides: DeepPartial<EndpointActionResponse> = {}): EndpointActionResponse {
const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date();
const startedAtTimes: number[] = [];
[2, 3, 5, 8, 13, 21].forEach((n) => {
startedAtTimes.push(
timeStamp.setMinutes(-this.randomN(n)),
timeStamp.setSeconds(-this.randomN(n))
);
});
return merge(
{
action_data: {
@ -74,9 +80,9 @@ export class FleetActionGenerator extends BaseDataGenerator {
},
action_id: this.seededUUIDv4(),
agent_id: this.seededUUIDv4(),
started_at: this.randomPastDate(),
started_at: new Date(startedAtTimes[this.randomN(startedAtTimes.length)]).toISOString(),
completed_at: timeStamp.toISOString(),
error: 'some error happened',
error: undefined,
'@timestamp': timeStamp.toISOString(),
},
overrides

View file

@ -77,35 +77,36 @@ export const indexEndpointAndFleetActionsForHost = async (
)
.catch(wrapErrorAndRejectPromise);
if (fleetActionGenerator.randomFloat() < 0.4) {
const endpointActionsBody = {
EndpointActions: {
...action,
'@timestamp': undefined,
user_id: undefined,
},
agent: {
id: [agentId],
},
'@timestamp': action['@timestamp'],
user: {
id: action.user_id,
},
const endpointActionsBody: LogsEndpointAction & {
EndpointActions: LogsEndpointAction['EndpointActions'] & {
'@timestamp': undefined;
user_id: undefined;
};
} = {
EndpointActions: {
...action,
'@timestamp': undefined,
user_id: undefined,
},
agent: {
id: [agentId],
},
'@timestamp': action['@timestamp'],
user: {
id: action.user_id,
},
};
await Promise.all([
indexFleetActions,
esClient
.index({
index: ENDPOINT_ACTIONS_INDEX,
body: endpointActionsBody,
refresh: 'wait_for',
})
.catch(wrapErrorAndRejectPromise),
]);
} else {
await indexFleetActions;
}
await Promise.all([
indexFleetActions,
esClient
.index({
index: ENDPOINT_ACTIONS_INDEX,
body: endpointActionsBody,
refresh: 'wait_for',
})
.catch(wrapErrorAndRejectPromise),
]);
const randomFloat = fleetActionGenerator.randomFloat();
// Create an action response for the above
@ -114,12 +115,12 @@ export const indexEndpointAndFleetActionsForHost = async (
agent_id: agentId,
action_response: {
endpoint: {
// add ack to 2/5th of fleet response
ack: randomFloat < 0.4 ? true : undefined,
// add ack to 4/5th of fleet response
ack: randomFloat < 0.8 ? true : undefined,
},
},
// error for 3/10th of responses
error: randomFloat < 0.3 ? 'some error happened' : undefined,
// error for 1/10th of responses
error: randomFloat < 0.1 ? 'some error happened' : undefined,
});
const indexFleetResponses = esClient
@ -133,7 +134,8 @@ export const indexEndpointAndFleetActionsForHost = async (
)
.catch(wrapErrorAndRejectPromise);
if (randomFloat < 0.4) {
// 70% has endpoint response
if (randomFloat < 0.7) {
const endpointActionResponseBody = {
EndpointActions: {
...actionResponse,
@ -146,13 +148,13 @@ export const indexEndpointAndFleetActionsForHost = async (
agent: {
id: agentId,
},
// error for 3/10th of responses
// error for 1/10th of responses
error:
randomFloat < 0.3
? undefined
: {
randomFloat < 0.1
? {
message: actionResponse.error,
},
}
: undefined,
'@timestamp': actionResponse['@timestamp'],
};
@ -167,6 +169,7 @@ export const indexEndpointAndFleetActionsForHost = async (
.catch(wrapErrorAndRejectPromise),
]);
} else {
// 30% has only fleet response
await indexFleetResponses;
}
@ -174,24 +177,23 @@ export const indexEndpointAndFleetActionsForHost = async (
response.actionResponses.push(actionResponse);
}
// Add edge cases (maybe)
// Add edge case fleet actions (maybe)
if (fleetActionGenerator.randomFloat() < 0.3) {
const randomFloat = fleetActionGenerator.randomFloat();
// 60% of the time just add either an Isolate -OR- an UnIsolate action
if (randomFloat < 0.6) {
const actionStartedAt = {
'@timestamp': new Date().toISOString(),
};
// 70% of the time just add either an Isolate -OR- an UnIsolate action
if (randomFloat < 0.7) {
let action: EndpointAction;
if (randomFloat < 0.3) {
// add a pending isolation
action = fleetActionGenerator.generateIsolateAction({
'@timestamp': new Date().toISOString(),
});
action = fleetActionGenerator.generateIsolateAction(actionStartedAt);
} else {
// add a pending UN-isolation
action = fleetActionGenerator.generateUnIsolateAction({
'@timestamp': new Date().toISOString(),
});
action = fleetActionGenerator.generateUnIsolateAction(actionStartedAt);
}
action.agents = [agentId];
@ -209,13 +211,9 @@ export const indexEndpointAndFleetActionsForHost = async (
response.actions.push(action);
} else {
// Else (40% of the time) add a pending isolate AND pending un-isolate
const action1 = fleetActionGenerator.generateIsolateAction({
'@timestamp': new Date().toISOString(),
});
const action2 = fleetActionGenerator.generateUnIsolateAction({
'@timestamp': new Date().toISOString(),
});
// Else (30% of the time) add a pending isolate AND pending un-isolate
const action1 = fleetActionGenerator.generateIsolateAction(actionStartedAt);
const action2 = fleetActionGenerator.generateUnIsolateAction(actionStartedAt);
action1.agents = [agentId];
action2.agents = [agentId];

View file

@ -27,10 +27,10 @@ describe('actions schemas', () => {
}).toThrow();
});
it('should not accept an agent ID if not in an array', () => {
it('should accept an agent ID if not in an array', () => {
expect(() => {
EndpointActionListRequestSchema.query.validate({ agentIds: uuid.v4() });
}).toThrow();
}).not.toThrow();
});
it('should accept an agent ID in an array', () => {
@ -68,9 +68,21 @@ describe('actions schemas', () => {
}).not.toThrow();
});
it('should not work without allowed page and pageSize params', () => {
it('should not work with invalid value for `page` query param', () => {
expect(() => {
EndpointActionListRequestSchema.query.validate({ pageSize: 101 });
EndpointActionListRequestSchema.query.validate({ page: -1 });
}).toThrow();
expect(() => {
EndpointActionListRequestSchema.query.validate({ page: 0 });
}).toThrow();
});
it('should not work with invalid value for `pageSize` query param', () => {
expect(() => {
EndpointActionListRequestSchema.query.validate({ pageSize: 100001 });
}).toThrow();
expect(() => {
EndpointActionListRequestSchema.query.validate({ pageSize: 0 });
}).toThrow();
});

View file

@ -6,6 +6,7 @@
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { ENDPOINT_DEFAULT_PAGE_SIZE } from '../constants';
const BaseActionRequestSchema = {
/** A list of endpoint IDs whose hosts will be isolated (Fleet Agent IDs will be retrieved for these) */
@ -70,14 +71,29 @@ export const ActionDetailsRequestSchema = {
export const EndpointActionListRequestSchema = {
query: schema.object({
agentIds: schema.maybe(
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 })
schema.oneOf([
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 }),
schema.string({ minLength: 1 }),
])
),
commands: schema.maybe(
schema.oneOf([
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
schema.string({ minLength: 1 }),
])
),
commands: schema.maybe(schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 })),
page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })),
pageSize: schema.maybe(schema.number({ defaultValue: 10, min: 1, max: 100 })),
pageSize: schema.maybe(
schema.number({ defaultValue: ENDPOINT_DEFAULT_PAGE_SIZE, min: 1, max: 10000 })
),
startDate: schema.maybe(schema.string()), // date ISO strings or moment date
endDate: schema.maybe(schema.string()), // date ISO strings or moment date
userIds: schema.maybe(schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 })),
userIds: schema.maybe(
schema.oneOf([
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
schema.string({ minLength: 1 }),
])
),
}),
};

View file

@ -37,6 +37,7 @@ import {
GETTING_STARTED,
DASHBOARDS,
CREATE_NEW_RULE,
RESPONSE_ACTIONS,
} from '../translations';
import {
OVERVIEW_PATH,
@ -60,6 +61,7 @@ import {
USERS_PATH,
KUBERNETES_PATH,
RULES_CREATE_PATH,
RESPONSE_ACTIONS_PATH,
} from '../../../common/constants';
import { ExperimentalFeatures } from '../../../common/experimental_features';
import { subscribeAppLinks } from '../../common/links';
@ -469,6 +471,11 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
title: BLOCKLIST,
path: BLOCKLIST_PATH,
},
{
id: SecurityPageName.responseActions,
title: RESPONSE_ACTIONS,
path: RESPONSE_ACTIONS_PATH,
},
],
},
];

View file

@ -32,6 +32,7 @@ import {
APP_USERS_PATH,
APP_KUBERNETES_PATH,
APP_LANDING_PATH,
APP_RESPONSE_ACTIONS_PATH,
} from '../../../common/constants';
export const navTabs: SecurityNav = {
@ -168,6 +169,13 @@ export const navTabs: SecurityNav = {
disabled: false,
urlKey: 'administration',
},
[SecurityPageName.responseActions]: {
id: SecurityPageName.responseActions,
name: i18n.RESPONSE_ACTIONS,
href: APP_RESPONSE_ACTIONS_PATH,
disabled: false,
urlKey: 'administration',
},
};
export const securityNavGroup: SecurityNavGroup = {

View file

@ -110,6 +110,13 @@ export const BLOCKLIST = i18n.translate('xpack.securitySolution.navigation.block
defaultMessage: 'Blocklist',
});
export const RESPONSE_ACTIONS = i18n.translate(
'xpack.securitySolution.navigation.responseActions',
{
defaultMessage: 'Response Actions',
}
);
export const CREATE_NEW_RULE = i18n.translate('xpack.securitySolution.navigation.newRuleTitle', {
defaultMessage: 'Create new rule',
});

View file

@ -58,6 +58,7 @@ export const securityNavKeys = [
SecurityPageName.hosts,
SecurityPageName.network,
SecurityPageName.overview,
SecurityPageName.responseActions,
SecurityPageName.rules,
SecurityPageName.timelines,
SecurityPageName.trustedApps,

View file

@ -9,7 +9,7 @@ import { ChromeBreadcrumb } from '@kbn/core/public';
import { AdministrationSubTab } from '../types';
import { ENDPOINTS_TAB, EVENT_FILTERS_TAB, POLICIES_TAB, TRUSTED_APPS_TAB } from './translations';
import { AdministrationRouteSpyState } from '../../common/utils/route/types';
import { HOST_ISOLATION_EXCEPTIONS, BLOCKLIST } from '../../app/translations';
import { HOST_ISOLATION_EXCEPTIONS, BLOCKLIST, RESPONSE_ACTIONS } from '../../app/translations';
const TabNameMappedToI18nKey: Record<AdministrationSubTab, string> = {
[AdministrationSubTab.endpoints]: ENDPOINTS_TAB,
@ -18,6 +18,7 @@ const TabNameMappedToI18nKey: Record<AdministrationSubTab, string> = {
[AdministrationSubTab.eventFilters]: EVENT_FILTERS_TAB,
[AdministrationSubTab.hostIsolationExceptions]: HOST_ISOLATION_EXCEPTIONS,
[AdministrationSubTab.blocklist]: BLOCKLIST,
[AdministrationSubTab.responseActions]: RESPONSE_ACTIONS,
};
export function getTrailingBreadcrumbs(params: AdministrationRouteSpyState): ChromeBreadcrumb[] {

View file

@ -22,6 +22,7 @@ export const MANAGEMENT_ROUTING_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName
export const MANAGEMENT_ROUTING_EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.eventFilters})`;
export const MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.hostIsolationExceptions})`;
export const MANAGEMENT_ROUTING_BLOCKLIST_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.blocklist})`;
export const MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.responseActions})`;
// --[ STORE ]---------------------------------------------------------------------------
/** The SIEM global store namespace where the management state will be mounted */

View file

@ -6,9 +6,11 @@
*/
import React, { memo, useCallback, useState } from 'react';
import { EuiButton, EuiFlyout } from '@elastic/eui';
import { EuiButton, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EndpointResponderExtensionComponentProps } from './types';
import { ResponseActionsList } from '../../pages/response_actions/view/response_actions_list';
import { UX_MESSAGES } from '../../pages/response_actions/translations';
export const ActionLogButton = memo<EndpointResponderExtensionComponentProps>((props) => {
const [showActionLogFlyout, setShowActionLogFlyout] = useState<boolean>(false);
@ -27,7 +29,16 @@ export const ActionLogButton = memo<EndpointResponderExtensionComponentProps>((p
/>
</EuiButton>
{showActionLogFlyout && (
<EuiFlyout onClose={toggleActionLog}>{'TODO: flyout content will go here'}</EuiFlyout>
<EuiFlyout onClose={toggleActionLog} size="l">
<EuiFlyoutHeader>
<EuiTitle size="s">
<h2>{UX_MESSAGES.flyoutTitle(props.meta.endpoint.host.hostname)}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<ResponseActionsList hideHeader agentIds={props.meta.endpoint.agent.id} />
</EuiFlyoutBody>
</EuiFlyout>
)}
</>
);

View file

@ -0,0 +1,93 @@
/*
* 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 {
AppContextTestRender,
createAppRootMockRenderer,
ReactQueryHookRenderer,
} from '../../../common/mock/endpoint';
import { useGetEndpointActionList } from './use_get_endpoint_action_list';
import { ENDPOINTS_ACTION_LIST_ROUTE } from '../../../../common/endpoint/constants';
import { useQuery as _useQuery } from 'react-query';
import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mocks';
const useQueryMock = _useQuery as jest.Mock;
jest.mock('react-query', () => {
const actualReactQueryModule = jest.requireActual('react-query');
return {
...actualReactQueryModule,
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
};
});
describe('useGetEndpointActionList hook', () => {
let renderReactQueryHook: ReactQueryHookRenderer<
Parameters<typeof useGetEndpointActionList>,
ReturnType<typeof useGetEndpointActionList>
>;
let http: AppContextTestRender['coreStart']['http'];
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
beforeEach(() => {
const testContext = createAppRootMockRenderer();
renderReactQueryHook = testContext.renderReactQueryHook as typeof renderReactQueryHook;
http = testContext.coreStart.http;
apiMocks = responseActionsHttpMocks(http);
});
it('should call the proper API', async () => {
await renderReactQueryHook(() =>
useGetEndpointActionList({
agentIds: ['123', '456'],
userIds: ['elastic', 'citsale'],
commands: ['isolate', 'unisolate'],
page: 2,
pageSize: 20,
startDate: 'now-5d',
endDate: 'now',
})
);
expect(apiMocks.responseProvider.actionList).toHaveBeenCalledWith({
path: `${ENDPOINTS_ACTION_LIST_ROUTE}`,
query: {
agentIds: ['123', '456'],
commands: ['isolate', 'unisolate'],
endDate: 'now',
page: 2,
pageSize: 20,
startDate: 'now-5d',
userIds: ['elastic', 'citsale'],
},
});
});
it('should allow custom options to be used', async () => {
await renderReactQueryHook(
() =>
useGetEndpointActionList(
{},
{
queryKey: ['1', '2'],
enabled: false,
}
),
false
);
expect(useQueryMock).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['1', '2'],
enabled: false,
})
);
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 type { UseQueryOptions, UseQueryResult } from 'react-query';
import type { HttpFetchError } from '@kbn/core/public';
import { useQuery } from 'react-query';
import { EndpointActionListRequestQuery } from '../../../../common/endpoint/schema/actions';
import { useHttp } from '../../../common/lib/kibana';
import { ENDPOINTS_ACTION_LIST_ROUTE } from '../../../../common/endpoint/constants';
import type { ActionListApiResponse } from '../../../../common/endpoint/types';
export const useGetEndpointActionList = (
query: EndpointActionListRequestQuery,
options: UseQueryOptions<ActionListApiResponse, HttpFetchError> = {}
): UseQueryResult<ActionListApiResponse, HttpFetchError> => {
const http = useHttp();
return useQuery<ActionListApiResponse, HttpFetchError>({
queryKey: ['get-action-list', query],
...options,
queryFn: async () => {
return http.get<ActionListApiResponse>(ENDPOINTS_ACTION_LIST_ROUTE, {
query: {
agentIds: query.agentIds,
commands: query.commands,
endDate: query.endDate,
page: query.page,
pageSize: query.pageSize,
startDate: query.startDate,
userIds: query.userIds,
},
});
},
});
};

View file

@ -7,3 +7,4 @@
export { useGetEndpointDetails } from './endpoint/use_get_endpoint_details';
export { useWithShowEndpointResponder } from './endpoint/use_with_show_endpoint_responder';
export { useGetEndpointActionList } from './endpoint/use_get_endpoint_action_list';

View file

@ -10,6 +10,7 @@ import { EndpointActionGenerator } from '../../../common/endpoint/data_generator
import {
ACTION_DETAILS_ROUTE,
ACTION_STATUS_ROUTE,
ENDPOINTS_ACTION_LIST_ROUTE,
ISOLATE_HOST_ROUTE,
UNISOLATE_HOST_ROUTE,
} from '../../../common/endpoint/constants';
@ -19,6 +20,7 @@ import {
} from '../../common/mock/endpoint/http_handler_mock_factory';
import {
ActionDetailsApiResponse,
ActionListApiResponse,
HostIsolationResponse,
PendingActionsResponse,
} from '../../../common/endpoint/types';
@ -30,6 +32,8 @@ export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
actionDetails: (options: HttpFetchOptionsWithPath) => ActionDetailsApiResponse;
actionList: (options: HttpFetchOptionsWithPath) => ActionListApiResponse;
agentPendingActionsSummary: (options: HttpFetchOptionsWithPath) => PendingActionsResponse;
}>;
@ -63,6 +67,26 @@ export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHt
return { data: response };
},
},
{
id: 'actionList',
path: ENDPOINTS_ACTION_LIST_ROUTE,
method: 'get',
handler: (): ActionListApiResponse => {
const response = new EndpointActionGenerator('seed').generateActionDetails();
return {
elasticAgentIds: ['agent-a'],
commands: ['isolate'],
page: 0,
pageSize: 10,
startDate: 'now-10d',
endDate: 'now',
data: [response],
userIds: ['elastic'],
total: 1,
};
},
},
{
id: 'agentPendingActionsSummary',
path: ACTION_STATUS_ROUTE,

View file

@ -17,6 +17,7 @@ import {
MANAGEMENT_ROUTING_POLICIES_PATH,
MANAGEMENT_ROUTING_TRUSTED_APPS_PATH,
MANAGEMENT_ROUTING_BLOCKLIST_PATH,
MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH,
} from '../common/constants';
import { NotFoundPage } from '../../app/404';
import { EndpointsContainer } from './endpoint_hosts';
@ -30,6 +31,7 @@ import { useUserPrivileges } from '../../common/components/user_privileges';
import { HostIsolationExceptionsContainer } from './host_isolation_exceptions';
import { BlocklistContainer } from './blocklist';
import { NoPermissions } from '../components/no_permissons';
import { ResponseActionsContainer } from './response_actions';
const EndpointTelemetry = () => (
<TrackApplicationView viewId={SecurityPageName.endpoints}>
@ -62,6 +64,12 @@ const HostIsolationExceptionsTelemetry = () => (
</TrackApplicationView>
);
const ResponseActionsTelemetry = () => (
<TrackApplicationView viewId={SecurityPageName.responseActions}>
<ResponseActionsContainer />
</TrackApplicationView>
);
export const ManagementContainer = memo(() => {
const { loading, canAccessEndpointManagement } = useUserPrivileges().endpointPrivileges;
@ -90,6 +98,7 @@ export const ManagementContainer = memo(() => {
component={HostIsolationExceptionsTelemetry}
/>
<Route path={MANAGEMENT_ROUTING_BLOCKLIST_PATH} component={BlocklistContainer} />
<Route path={MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH} component={ResponseActionsTelemetry} />
<Route path={MANAGEMENT_PATH} exact>
<Redirect to={getEndpointListPath({ name: 'endpointList' })} />
</Route>

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Switch } from 'react-router-dom';
import { Route } from '@kbn/kibana-react-plugin/public';
import React, { memo } from 'react';
import { MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH } from '../../common/constants';
import { NotFoundPage } from '../../../app/404';
import { ResponseActionsList } from './view/response_actions_list';
export const ResponseActionsContainer = memo(() => {
return (
<Switch>
<Route
path={MANAGEMENT_ROUTING_RESPONSE_ACTIONS_PATH}
exact
component={ResponseActionsList}
/>
<Route path="*" component={NotFoundPage} />
</Switch>
);
});
ResponseActionsContainer.displayName = 'ResponseActionsContainer';

View file

@ -0,0 +1,131 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const OUTPUT_MESSAGES = Object.freeze({
hasExpired: (command: string) =>
i18n.translate('xpack.securitySolution.responseActionsList.list.item.hasExpired', {
defaultMessage: `{command} failed: action expired`,
values: { command },
}),
wasSuccessful: (command: string) =>
i18n.translate('xpack.securitySolution.responseActionsList.list.item.wasSuccessful', {
defaultMessage: `{command} completed successfully`,
values: { command },
}),
isPending: (command: string) =>
i18n.translate('xpack.securitySolution.responseActionsList.list.item.isPending', {
defaultMessage: `{command} is pending`,
values: { command },
}),
hasFailed: (command: string) =>
i18n.translate('xpack.securitySolution.responseActionsList.list.item.hasFailed', {
defaultMessage: `{command} failed`,
values: { command },
}),
expandSection: {
placedAt: i18n.translate(
'xpack.securitySolution.responseActionsList.list.item.expandSection.placedAt',
{
defaultMessage: 'Command placed',
}
),
input: i18n.translate(
'xpack.securitySolution.responseActionsList.list.item.expandSection.input',
{
defaultMessage: 'Input',
}
),
output: i18n.translate(
'xpack.securitySolution.responseActionsList.list.item.expandSection.output',
{
defaultMessage: 'Output',
}
),
startedAt: i18n.translate(
'xpack.securitySolution.responseActionsList.list.item.expandSection.startedAt',
{
defaultMessage: 'Execution started on',
}
),
parameters: i18n.translate(
'xpack.securitySolution.responseActionsList.list.item.expandSection.parameters',
{
defaultMessage: 'Parameters',
}
),
completedAt: i18n.translate(
'xpack.securitySolution.responseActionsList.list.item.expandSection.completedAt',
{
defaultMessage: 'Execution completed',
}
),
},
});
export const TABLE_COLUMN_NAMES = Object.freeze({
time: i18n.translate('xpack.securitySolution.responseActionsList.list.time', {
defaultMessage: 'Time',
}),
command: i18n.translate('xpack.securitySolution.responseActionsList.list.command', {
defaultMessage: 'Command/action',
}),
user: i18n.translate('xpack.securitySolution.responseActionsList.list.user', {
defaultMessage: 'User',
}),
host: i18n.translate('xpack.securitySolution.responseActionsList.list.host', {
defaultMessage: 'Host',
}),
comments: i18n.translate('xpack.securitySolution.responseActionsList.list.comments', {
defaultMessage: 'Comments',
}),
status: i18n.translate('xpack.securitySolution.responseActionsList.list.status', {
defaultMessage: 'Status',
}),
});
export const UX_MESSAGES = Object.freeze({
flyoutTitle: (hostname: string) =>
i18n.translate('xpack.securitySolution.responseActionsList.flyout.title', {
defaultMessage: `Action log - {hostname} `,
values: { hostname },
}),
pageTitle: i18n.translate('xpack.securitySolution.responseActionsList.list.title', {
defaultMessage: 'Response actions',
}),
fetchError: i18n.translate('xpack.securitySolution.responseActionsList.list.errorMessage', {
defaultMessage: 'Error while retrieving response actions',
}),
badge: {
completed: i18n.translate(
'xpack.securitySolution.responseActionsList.list.item.badge.completed',
{
defaultMessage: 'Completed',
}
),
failed: i18n.translate('xpack.securitySolution.responseActionsList.list.item.badge.failed', {
defaultMessage: 'Failed',
}),
pending: i18n.translate('xpack.securitySolution.responseActionsList.list.item.badge.pending', {
defaultMessage: 'Pending',
}),
},
screenReaderExpand: i18n.translate(
'xpack.securitySolution.responseActionsList.list.screenReader.expand',
{
defaultMessage: 'Expand rows',
}
),
recordsLabel: (totalItemCount: number) =>
i18n.translate('xpack.securitySolution.responseActionsList.list.recordRangeLabel', {
defaultMessage: '{records, plural, one {response action} other {response actions}}',
values: {
records: totalItemCount,
},
}),
});

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 React, { memo, useState, useMemo } from 'react';
import dateMath from '@kbn/datemath';
import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import type { EuiSuperDatePickerRecentRange } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { DurationRange, OnRefreshChangeProps } from '@elastic/eui/src/components/date_picker/types';
import { useUiSetting$ } from '../../../../../common/lib/kibana';
import { DEFAULT_TIMEPICKER_QUICK_RANGES } from '../../../../../../common/constants';
export interface DateRangePickerValues {
autoRefreshOptions: {
enabled: boolean;
duration: number;
};
startDate?: string;
endDate?: string;
recentlyUsedDateRanges: EuiSuperDatePickerRecentRange[];
}
interface Range {
from: string;
to: string;
display: string;
}
const DatePickerWrapper = euiStyled.div`
width: ${(props) => props.theme.eui.fractions.single.percentage};
`;
const StickyFlexItem = euiStyled(EuiFlexItem).attrs({ grow: false })`
background: ${(props) => `${props.theme.eui.euiHeaderBackgroundColor}`};
position: sticky;
top: 0;
z-index: 1;
padding: ${(props) => `${props.theme.eui.euiSizeL}`};
padding-left: ${(props) => `${props.theme.eui.euiSizeM}`};
`;
export const ActionListDateRangePicker = memo(
({
dateRangePickerState,
isDataLoading,
onRefresh,
onRefreshChange,
onTimeChange,
}: {
dateRangePickerState: DateRangePickerValues;
isDataLoading: boolean;
onRefresh: () => void;
onRefreshChange: (evt: OnRefreshChangeProps) => void;
onTimeChange: ({ start, end }: DurationRange) => void;
}) => {
const { uiSettings } = useKibana().services;
const [quickRanges] = useUiSetting$<Range[]>(DEFAULT_TIMEPICKER_QUICK_RANGES);
const [dateFormat] = useState(() => uiSettings?.get('dateFormat'));
const commonlyUsedRanges = !quickRanges.length
? []
: quickRanges.map(({ from, to, display }) => ({
start: from,
end: to,
label: display,
}));
const end = useMemo(
() =>
dateRangePickerState.endDate
? dateMath.parse(dateRangePickerState.endDate)?.toISOString()
: undefined,
[dateRangePickerState]
);
const start = useMemo(
() =>
dateRangePickerState.startDate
? dateMath.parse(dateRangePickerState.startDate)?.toISOString()
: undefined,
[dateRangePickerState]
);
return (
<StickyFlexItem>
<EuiFlexGroup justifyContent="flexStart" responsive>
<DatePickerWrapper data-test-subj="actionListSuperDatePicker">
<EuiFlexItem>
<EuiSuperDatePicker
updateButtonProps={{ iconOnly: true, fill: false }}
isLoading={isDataLoading}
dateFormat={dateFormat}
commonlyUsedRanges={commonlyUsedRanges}
end={end}
isPaused={!dateRangePickerState.autoRefreshOptions.enabled}
onTimeChange={onTimeChange}
onRefreshChange={onRefreshChange}
refreshInterval={dateRangePickerState.autoRefreshOptions.duration}
onRefresh={onRefresh}
recentlyUsedRanges={dateRangePickerState.recentlyUsedDateRanges}
start={start}
/>
</EuiFlexItem>
</DatePickerWrapper>
</EuiFlexGroup>
</StickyFlexItem>
);
}
);
ActionListDateRangePicker.displayName = 'ActionListDateRangePicker';

View file

@ -0,0 +1,325 @@
/*
* 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 uuid from 'uuid';
import React from 'react';
import * as reactTestingLibrary from '@testing-library/react';
import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint';
import { ResponseActionsList } from './response_actions_list';
import { ActionDetails, ActionListApiResponse } from '../../../../../common/endpoint/types';
import { useKibana, useUiSetting$ } from '../../../../common/lib/kibana';
import { createUseUiSetting$Mock } from '../../../../common/lib/kibana/kibana_react.mock';
import { DEFAULT_TIMEPICKER_QUICK_RANGES, MANAGEMENT_PATH } from '../../../../../common/constants';
import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator';
import userEvent from '@testing-library/user-event';
let mockUseGetEndpointActionList: {
isFetched?: boolean;
isFetching?: boolean;
error?: null;
data?: ActionListApiResponse;
refetch: () => unknown;
};
jest.mock('../../../hooks/endpoint/use_get_endpoint_action_list', () => {
const original = jest.requireActual('../../../hooks/endpoint/use_get_endpoint_action_list');
return {
...original,
useGetEndpointActionList: () => mockUseGetEndpointActionList,
};
});
const mockUseUiSetting$ = useUiSetting$ as jest.Mock;
const timepickerRanges = [
{
from: 'now/d',
to: 'now/d',
display: 'Today',
},
{
from: 'now/w',
to: 'now/w',
display: 'This week',
},
{
from: 'now-15m',
to: 'now',
display: 'Last 15 minutes',
},
{
from: 'now-30m',
to: 'now',
display: 'Last 30 minutes',
},
{
from: 'now-1h',
to: 'now',
display: 'Last 1 hour',
},
{
from: 'now-24h',
to: 'now',
display: 'Last 24 hours',
},
{
from: 'now-7d',
to: 'now',
display: 'Last 7 days',
},
{
from: 'now-30d',
to: 'now',
display: 'Last 30 days',
},
{
from: 'now-90d',
to: 'now',
display: 'Last 90 days',
},
{
from: 'now-1y',
to: 'now',
display: 'Last 1 year',
},
];
jest.mock('../../../../common/lib/kibana');
describe('Response Actions List', () => {
const testPrefix = 'response-actions-list';
let render: (
props: React.ComponentProps<typeof ResponseActionsList>
) => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let history: AppContextTestRender['history'];
let mockedContext: AppContextTestRender;
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
({ history } = mockedContext);
render = (props: React.ComponentProps<typeof ResponseActionsList>) =>
(renderResult = mockedContext.render(<ResponseActionsList {...props} />));
reactTestingLibrary.act(() => {
history.push(`${MANAGEMENT_PATH}/response_actions`);
});
(useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices });
mockUseUiSetting$.mockImplementation((key, defaultValue) => {
const useUiSetting$Mock = createUseUiSetting$Mock();
return key === DEFAULT_TIMEPICKER_QUICK_RANGES
? [timepickerRanges, jest.fn()]
: useUiSetting$Mock(key, defaultValue);
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('', () => {
const refetchFunction = jest.fn();
const baseMockedActionList = {
isFetched: true,
isFetching: false,
error: null,
refetch: refetchFunction,
};
beforeEach(async () => {
mockUseGetEndpointActionList = {
...baseMockedActionList,
data: await getActionListMock({ actionCount: 13 }),
};
});
afterEach(() => {
mockUseGetEndpointActionList = {
...baseMockedActionList,
};
});
describe('Table View', () => {
it('should show date filters', () => {
render({});
expect(renderResult.getByTestId('actionListSuperDatePicker')).toBeTruthy();
});
it('should show empty state when there is no data', async () => {
mockUseGetEndpointActionList = {
...baseMockedActionList,
data: await getActionListMock({ actionCount: 0 }),
};
render({});
expect(renderResult.getByTestId(`${testPrefix}-empty-prompt`)).toBeTruthy();
});
it('should show table when there is data', async () => {
render({});
expect(renderResult.getByTestId(`${testPrefix}-table-view`)).toBeTruthy();
expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
'Showing 1-10 of 13 response actions'
);
});
it('should show expected column names on the table', async () => {
render({});
expect(
Array.from(
renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th')
)
.slice(0, 6)
.map((col) => col.textContent)
).toEqual(['Time', 'Command/action', 'User', 'Host', 'Comments', 'Status']);
});
it('should paginate table when there is data', async () => {
render({});
expect(renderResult.getByTestId(`${testPrefix}-table-view`)).toBeTruthy();
expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
'Showing 1-10 of 13 response actions'
);
const page2 = renderResult.getByTestId('pagination-button-1');
userEvent.click(page2);
expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
'Showing 11-13 of 13 response actions'
);
});
it('should show 1-1 record label when only 1 record', async () => {
mockUseGetEndpointActionList = {
...baseMockedActionList,
data: await getActionListMock({ actionCount: 1 }),
};
render({});
expect(renderResult.getByTestId(`${testPrefix}-endpointListTableTotal`)).toHaveTextContent(
'Showing 1-1 of 1 response action'
);
});
it('should expand each row to show details', async () => {
render({});
const expandButtons = renderResult.getAllByTestId(`${testPrefix}-expand-button`);
expandButtons.map((button) => userEvent.click(button));
const trays = renderResult.getAllByTestId(`${testPrefix}-output-section`);
expect(trays).toBeTruthy();
expect(trays.length).toEqual(13);
expandButtons.map((button) => userEvent.click(button));
const noTrays = renderResult.queryAllByTestId(`${testPrefix}-output-section`);
expect(noTrays).toEqual([]);
});
it('should refresh data when autoRefresh is toggled on', async () => {
render({});
const quickMenu = renderResult.getByTestId('superDatePickerToggleQuickMenuButton');
userEvent.click(quickMenu);
const toggle = renderResult.getByTestId('superDatePickerToggleRefreshButton');
const intervalInput = renderResult.getByTestId('superDatePickerRefreshIntervalInput');
userEvent.click(toggle);
reactTestingLibrary.fireEvent.change(intervalInput, { target: { value: 1 } });
await new Promise((r) => setTimeout(r, 3000));
expect(refetchFunction).toHaveBeenCalledTimes(3);
});
});
describe('Without agentIds filter', () => {
it('should show a host column', async () => {
render({});
expect(renderResult.getByTestId(`tableHeaderCell_agents_3`)).toBeTruthy();
});
});
describe('With agentIds filter', () => {
it('should NOT show a host column when a single agentId', async () => {
const agentIds = uuid.v4();
mockUseGetEndpointActionList = {
...baseMockedActionList,
data: await getActionListMock({ actionCount: 2, agentIds: [agentIds] }),
};
render({ agentIds });
expect(
Array.from(
renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th')
)
.slice(0, 5)
.map((col) => col.textContent)
).toEqual(['Time', 'Command/action', 'User', 'Comments', 'Status']);
});
it('should show a host column when multiple agentIds', async () => {
const agentIds = [uuid.v4(), uuid.v4()];
mockUseGetEndpointActionList = {
...baseMockedActionList,
data: await getActionListMock({ actionCount: 2, agentIds }),
};
render({ agentIds });
expect(
Array.from(
renderResult.getByTestId(`${testPrefix}-table-view`).querySelectorAll('thead th')
)
.slice(0, 6)
.map((col) => col.textContent)
).toEqual(['Time', 'Command/action', 'User', 'Host', 'Comments', 'Status']);
});
});
});
});
// mock API response
const getActionListMock = async ({
agentIds: _agentIds,
commands,
actionCount = 0,
endDate,
page = 1,
pageSize = 10,
startDate,
userIds,
}: {
agentIds?: string[];
commands?: string[];
actionCount?: number;
endDate?: string;
page?: number;
pageSize?: number;
startDate?: string;
userIds?: string[];
}): Promise<ActionListApiResponse> => {
const endpointActionGenerator = new EndpointActionGenerator('seed');
const agentIds = _agentIds ?? [uuid.v4()];
const data: ActionDetails[] = agentIds.map((id) => {
const actionIds = Array(actionCount)
.fill(1)
.map(() => uuid.v4());
const actionDetails: ActionDetails[] = actionIds.map((actionId) => {
return endpointActionGenerator.generateActionDetails({
agents: [id],
id: actionId,
});
});
return actionDetails;
})[0];
return {
page,
pageSize,
startDate,
endDate,
elasticAgentIds: agentIds,
commands,
data,
userIds,
total: data.length ?? 0,
};
};

View file

@ -0,0 +1,632 @@
/*
* 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 dateMath from '@kbn/datemath';
import {
CriteriaWithPagination,
EuiAvatar,
EuiBadge,
EuiBasicTable,
EuiButtonIcon,
EuiDescriptionList,
EuiEmptyPrompt,
EuiFacetButton,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiScreenReaderOnly,
EuiI18nNumber,
EuiText,
EuiCodeBlock,
EuiToolTip,
RIGHT_ALIGNMENT,
EuiFlexGrid,
} from '@elastic/eui';
import { euiStyled, css } from '@kbn/kibana-react-plugin/common';
import type {
DurationRange,
OnRefreshChangeProps,
} from '@elastic/eui/src/components/date_picker/types';
import type { HorizontalAlignment } from '@elastic/eui';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { getEmptyValue } from '../../../../common/components/empty_value';
import { FormattedDate } from '../../../../common/components/formatted_date';
import { ActionDetails } from '../../../../../common/endpoint/types';
import type { EndpointActionListRequestQuery } from '../../../../../common/endpoint/schema/actions';
import { AdministrationListPage } from '../../../components/administration_list_page';
import { ManagementEmptyStateWrapper } from '../../../components/management_empty_state_wrapper';
import { useGetEndpointActionList } from '../../../hooks';
import { OUTPUT_MESSAGES, TABLE_COLUMN_NAMES, UX_MESSAGES } from '../translations';
import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
import { ActionListDateRangePicker } from './components/action_list_date_range_picker';
import type { DateRangePickerValues } from './components/action_list_date_range_picker';
const emptyValue = getEmptyValue();
const defaultDateRangeOptions = Object.freeze({
autoRefreshOptions: {
enabled: false,
duration: 10000,
},
startDate: 'now-1d',
endDate: 'now',
recentlyUsedDateRanges: [],
});
const getCommand = (
command: ActionDetails['command']
): Exclude<ActionDetails['command'], 'unisolate'> | 'release' =>
command === 'unisolate' ? 'release' : command;
// Truncated usernames
const StyledFacetButton = euiStyled(EuiFacetButton)`
.euiText {
margin-top: 0.38rem;
overflow-y: visible !important;
}
`;
const customDescriptionListCss = css`
dt,
dd {
color: ${(props) => props.theme.eui.euiColorDarkShade} !important;
font-size: ${(props) => props.theme.eui.euiFontSizeXS} !important;
}
dt {
font-weight: ${(props) => props.theme.eui.euiFontWeightSemiBold};
}
`;
const StyledDescriptionList = euiStyled(EuiDescriptionList).attrs({ compressed: true })`
${customDescriptionListCss}
`;
// output section styles
const topSpacingCss = css`
${(props) => `${props.theme.eui.euiCodeBlockPaddingModifiers.paddingMedium} 0`}
`;
const dashedBorderCss = css`
${(props) => `1px dashed ${props.theme.eui.euiColorDisabled}`};
`;
const StyledDescriptionListOutput = euiStyled(EuiDescriptionList).attrs({ compressed: true })`
${customDescriptionListCss}
dd {
margin: ${topSpacingCss};
padding: ${topSpacingCss};
border-top: ${dashedBorderCss};
border-bottom: ${dashedBorderCss};
}
`;
// code block styles
const StyledEuiCodeBlock = euiStyled(EuiCodeBlock).attrs({
transparentBackground: true,
paddingSize: 'none',
})`
code {
color: ${(props) => props.theme.eui.euiColorDarkShade} !important;
}
`;
export const ResponseActionsList = memo<
Pick<EndpointActionListRequestQuery, 'agentIds' | 'commands' | 'userIds'> & {
hideHeader?: boolean;
hideHostNameColumn?: boolean;
}
>(({ agentIds, commands, userIds, hideHeader = false }) => {
const getTestId = useTestIdGenerator('response-actions-list');
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{
[k: ActionDetails['id']]: React.ReactNode;
}>({});
const [queryParams, setQueryParams] = useState<EndpointActionListRequestQuery>({
page: 1,
pageSize: 10,
agentIds,
commands,
userIds,
});
// date range picker settings
const [dateRangePickerState, setDateRangePickerState] =
useState<DateRangePickerValues>(defaultDateRangeOptions);
// initial fetch of list data
const {
error,
data: actionList,
isFetching,
isFetched,
refetch: reFetchEndpointActionList,
} = useGetEndpointActionList({
...queryParams,
startDate: dateRangePickerState.startDate,
endDate: dateRangePickerState.endDate,
});
const updateActionListDateRanges = useCallback(
({ start, end }) => {
setDateRangePickerState((prevState) => ({
...prevState,
startDate: dateMath.parse(start)?.toISOString(),
endDate: dateMath.parse(end)?.toISOString(),
}));
},
[setDateRangePickerState]
);
const updateActionListRecentlyUsedDateRanges = useCallback(
(recentlyUsedDateRanges) => {
setDateRangePickerState((prevState) => ({
...prevState,
recentlyUsedDateRanges,
}));
},
[setDateRangePickerState]
);
// update refresh timer
const onRefreshChange = useCallback(
(evt: OnRefreshChangeProps) => {
setDateRangePickerState((prevState) => ({
...prevState,
autoRefreshOptions: { enabled: !evt.isPaused, duration: evt.refreshInterval },
}));
},
[setDateRangePickerState]
);
// auto refresh data
const onRefresh = useCallback(() => {
if (dateRangePickerState.autoRefreshOptions.enabled) {
reFetchEndpointActionList();
}
}, [dateRangePickerState.autoRefreshOptions.enabled, reFetchEndpointActionList]);
const onTimeChange = useCallback(
({ start: newStart, end: newEnd }: DurationRange) => {
const newRecentlyUsedDateRanges = [
{ start: newStart, end: newEnd },
...dateRangePickerState.recentlyUsedDateRanges
.filter(
(recentlyUsedRange: DurationRange) =>
!(recentlyUsedRange.start === newStart && recentlyUsedRange.end === newEnd)
)
.slice(0, 9),
];
// update date ranges
updateActionListDateRanges({ start: newStart, end: newEnd });
// update recently used date ranges
updateActionListRecentlyUsedDateRanges(newRecentlyUsedDateRanges);
},
[
dateRangePickerState.recentlyUsedDateRanges,
updateActionListDateRanges,
updateActionListRecentlyUsedDateRanges,
]
);
// total actions
const totalItemCount = useMemo(() => actionList?.total ?? 0, [actionList]);
// expanded tray contents
const toggleDetails = useCallback(
(item: ActionDetails) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[item.id]) {
delete itemIdToExpandedRowMapValues[item.id];
} else {
const {
startedAt,
completedAt,
isCompleted,
wasSuccessful,
isExpired,
command: _command,
parameters,
} = item;
const command = getCommand(_command);
const descriptionListLeft = [
{
title: OUTPUT_MESSAGES.expandSection.placedAt,
description: `${startedAt}`,
},
{
title: OUTPUT_MESSAGES.expandSection.input,
description: `${command}`,
},
];
const descriptionListCenter = [
{
title: OUTPUT_MESSAGES.expandSection.startedAt,
description: `${startedAt}`,
},
{
title: OUTPUT_MESSAGES.expandSection.parameters,
description: parameters ? parameters : emptyValue,
},
];
const descriptionListRight = [
{
title: OUTPUT_MESSAGES.expandSection.completedAt,
description: `${completedAt ?? emptyValue}`,
},
];
const outputList = [
{
title: OUTPUT_MESSAGES.expandSection.output,
description: (
// codeblock for output
<StyledEuiCodeBlock>
{isExpired
? OUTPUT_MESSAGES.hasExpired(command)
: isCompleted
? wasSuccessful
? OUTPUT_MESSAGES.wasSuccessful(command)
: OUTPUT_MESSAGES.hasFailed(command)
: OUTPUT_MESSAGES.isPending(command)}
</StyledEuiCodeBlock>
),
},
];
itemIdToExpandedRowMapValues[item.id] = (
<>
<EuiFlexGroup
data-test-subj={getTestId('output-section')}
direction="column"
style={{ maxHeight: 270, overflowY: 'auto' }}
className="eui-yScrollWithShadows"
>
<EuiFlexItem grow={false}>
<EuiFlexGrid columns={3}>
{[descriptionListLeft, descriptionListCenter, descriptionListRight].map(
(_list, i) => {
const list = _list.map((l) => {
const isParameters = l.title === OUTPUT_MESSAGES.expandSection.parameters;
return {
title: l.title,
description: isParameters ? (
// codeblock for parameters
<StyledEuiCodeBlock>{l.description}</StyledEuiCodeBlock>
) : (
l.description
),
};
});
return (
<EuiFlexItem key={i}>
<StyledDescriptionList listItems={list} />
</EuiFlexItem>
);
}
)}
</EuiFlexGrid>
</EuiFlexItem>
<EuiFlexItem>
<StyledDescriptionListOutput listItems={outputList} />
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
},
[getTestId, itemIdToExpandedRowMap]
);
// memoized callback for toggleDetails
const onClickCallback = useCallback(
(data: ActionDetails) => () => toggleDetails(data),
[toggleDetails]
);
// table column
const responseActionListColumns = useMemo(() => {
const columns = [
{
field: 'startedAt',
name: TABLE_COLUMN_NAMES.time,
width: '15%',
truncateText: true,
render: (startedAt: ActionDetails['startedAt']) => {
return (
<FormattedDate
fieldName={TABLE_COLUMN_NAMES.time}
value={startedAt}
className="eui-textTruncate"
/>
);
},
},
{
field: 'command',
name: TABLE_COLUMN_NAMES.command,
width: '10%',
truncateText: true,
render: (_command: ActionDetails['command']) => {
const command = getCommand(_command);
return (
<EuiToolTip content={command} anchorClassName="eui-textTruncate">
<FormattedMessage
id="xpack.securitySolution.responseActionsList.list.item.command"
defaultMessage="{command}"
values={{ command }}
/>
</EuiToolTip>
);
},
},
{
field: 'createdBy',
name: TABLE_COLUMN_NAMES.user,
width: '14%',
truncateText: true,
render: (userId: ActionDetails['createdBy']) => {
return (
<StyledFacetButton
icon={
<EuiAvatar
name={userId}
data-test-subj={getTestId('column-user-avatar')}
size="s"
/>
}
>
<EuiToolTip content={userId} anchorClassName="eui-textTruncate">
<EuiText
size="s"
className="eui-textTruncate eui-fullWidth"
data-test-subj={getTestId('column-user-name')}
>
{userId}
</EuiText>
</EuiToolTip>
</StyledFacetButton>
);
},
},
// conditional hostname column
{
field: 'agents',
name: TABLE_COLUMN_NAMES.host,
width: '20%',
truncateText: true,
render: (agents: ActionDetails['agents']) => {
// TODO: compute host names later with hostMetadata? (using agent Ids for now)
const hostname = agents?.[0] ?? '';
return (
<EuiToolTip content={hostname} anchorClassName="eui-textTruncate">
<EuiText
size="s"
className="eui-textTruncate eui-fullWidth"
data-test-subj={getTestId('column-hostname')}
>
{hostname}
</EuiText>
</EuiToolTip>
);
},
},
{
field: 'comment',
name: TABLE_COLUMN_NAMES.comments,
width: '30%',
truncateText: true,
render: (comment: ActionDetails['comment']) => {
return (
<EuiToolTip content={comment} anchorClassName="eui-textTruncate">
<EuiText
size="s"
className="eui-textTruncate eui-fullWidth"
data-test-subj={getTestId('column-comments')}
>
{comment ?? emptyValue}
</EuiText>
</EuiToolTip>
);
},
},
{
field: 'isCompleted',
name: TABLE_COLUMN_NAMES.status,
width: '10%',
render: (isCompleted: ActionDetails['isCompleted'], data: ActionDetails) => {
const status = data.isExpired
? UX_MESSAGES.badge.failed
: isCompleted
? data.wasSuccessful
? UX_MESSAGES.badge.completed
: UX_MESSAGES.badge.failed
: UX_MESSAGES.badge.pending;
return (
<EuiToolTip content={status} anchorClassName="eui-textTruncate">
<EuiBadge
data-test-subj={getTestId('column-status')}
color={
data.isExpired
? 'danger'
: isCompleted
? data.wasSuccessful
? 'success'
: 'danger'
: 'warning'
}
>
<FormattedMessage
id="xpack.securitySolution.responseActionsList.list.item.status"
defaultMessage="{status}"
values={{ status }}
/>
</EuiBadge>
</EuiToolTip>
);
},
},
{
field: '',
align: RIGHT_ALIGNMENT as HorizontalAlignment,
width: '40px',
isExpander: true,
name: (
<EuiScreenReaderOnly>
<span>{UX_MESSAGES.screenReaderExpand}</span>
</EuiScreenReaderOnly>
),
render: (data: ActionDetails) => {
return (
<EuiButtonIcon
data-test-subj={getTestId('expand-button')}
onClick={onClickCallback(data)}
aria-label={itemIdToExpandedRowMap[data.id] ? 'Collapse' : 'Expand'}
iconType={itemIdToExpandedRowMap[data.id] ? 'arrowUp' : 'arrowDown'}
/>
);
},
},
];
// filter out the host column
if (typeof agentIds === 'string') {
return columns.filter((column) => column.field !== 'agents');
}
return columns;
}, [agentIds, getTestId, itemIdToExpandedRowMap, onClickCallback]);
// table pagination
const tablePagination = useMemo(() => {
return {
// this controls the table UI page
// to match 0-based table paging
pageIndex: (queryParams.page || 1) - 1,
pageSize: queryParams.pageSize || 10,
totalItemCount,
pageSizeOptions: MANAGEMENT_PAGE_SIZE_OPTIONS as number[],
};
}, [queryParams, totalItemCount]);
// handle onChange
const handleTableOnChange = useCallback(
({ page: _page }: CriteriaWithPagination<ActionDetails>) => {
// table paging is 0 based
const { index, size } = _page;
setQueryParams((prevState) => ({
...prevState,
// adjust the page to conform to
// 1-based API page
page: index + 1,
pageSize: size,
}));
reFetchEndpointActionList();
},
[reFetchEndpointActionList, setQueryParams]
);
// compute record ranges
const pagedResultsCount = useMemo(() => {
const page = queryParams.page ?? 1;
const perPage = queryParams?.pageSize ?? 10;
const totalPages = Math.ceil(totalItemCount / perPage);
const fromCount = perPage * page - perPage + 1;
const toCount =
page === totalPages || totalPages === 1 ? totalItemCount : fromCount + perPage - 1;
return { fromCount, toCount };
}, [queryParams.page, queryParams.pageSize, totalItemCount]);
// create range label to display
const recordRangeLabel = useMemo(
() => (
<EuiText color="subdued" size="xs" data-test-subj={getTestId('endpointListTableTotal')}>
<FormattedMessage
id="xpack.securitySolution.responseActionsList.list.recordRange"
defaultMessage="Showing {range} of {total} {recordsLabel}"
values={{
range: (
<strong>
<EuiI18nNumber value={pagedResultsCount.fromCount} />
{'-'}
<EuiI18nNumber value={pagedResultsCount.toCount} />
</strong>
),
total: <EuiI18nNumber value={totalItemCount} />,
recordsLabel: <strong>{UX_MESSAGES.recordsLabel(totalItemCount)}</strong>,
}}
/>
</EuiText>
),
[getTestId, pagedResultsCount.fromCount, pagedResultsCount.toCount, totalItemCount]
);
return (
<AdministrationListPage
data-test-subj="responseActionsPage"
title={hideHeader ? undefined : UX_MESSAGES.pageTitle}
>
<ActionListDateRangePicker
dateRangePickerState={dateRangePickerState}
isDataLoading={isFetching}
onRefresh={onRefresh}
onRefreshChange={onRefreshChange}
onTimeChange={onTimeChange}
/>
{isFetched && !totalItemCount ? (
<ManagementEmptyStateWrapper>
<EuiFlexItem data-test-subj={getTestId('empty-prompt')}>
<EuiEmptyPrompt
iconType="editorUnorderedList"
titleSize="s"
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.responseActionsList.empty.title"
defaultMessage="No response actions log"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.securitySolution.responseActionsList.empty.body"
defaultMessage="Try a different set of filters"
/>
</p>
}
data-test-subj="responseActions-empty"
/>
</EuiFlexItem>
</ManagementEmptyStateWrapper>
) : (
<>
{recordRangeLabel}
<EuiHorizontalRule margin="xs" />
<EuiBasicTable
data-test-subj={getTestId('table-view')}
items={actionList?.data || []}
columns={responseActionListColumns}
itemId="id"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable={true}
pagination={tablePagination}
onChange={handleTableOnChange}
loading={isFetching}
error={error !== null ? UX_MESSAGES.fetchError : undefined}
/>
</>
)}
</AdministrationListPage>
);
});
ResponseActionsList.displayName = 'ResponseActionsList';

View file

@ -31,6 +31,7 @@ export enum AdministrationSubTab {
eventFilters = 'event_filters',
hostIsolationExceptions = 'host_isolation_exceptions',
blocklist = 'blocklist',
responseActions = 'response_actions',
}
/**

View file

@ -18,6 +18,9 @@ import type { SecuritySolutionRequestHandlerContext } from '../../../types';
import type { EndpointAppContext } from '../../types';
import { errorHandler } from '../error_handler';
const formatStringIds = (value: string | string[] | undefined): undefined | string[] =>
typeof value === 'string' ? [value] : value;
export const actionListHandler = (
endpointContext: EndpointAppContext
): RequestHandler<
@ -36,14 +39,14 @@ export const actionListHandler = (
try {
const body = await getActionList({
commands,
commands: formatStringIds(commands),
esClient,
elasticAgentIds,
elasticAgentIds: formatStringIds(elasticAgentIds),
page,
pageSize,
startDate,
endDate,
userIds,
userIds: formatStringIds(userIds),
logger,
});
return res.ok({

View file

@ -44,7 +44,7 @@ describe('When using `getActionList()', () => {
it('should return expected output', async () => {
const doc = actionRequests.hits.hits[0]._source;
await expect(getActionList({ esClient, logger, page: 1, pageSize: 10 })).resolves.toEqual({
page: 0,
page: 1,
pageSize: 10,
commands: undefined,
userIds: undefined,
@ -153,6 +153,7 @@ describe('When using `getActionList()', () => {
it('should return an empty array if no actions are found', async () => {
actionRequests.hits.hits = [];
(actionRequests.hits.total as estypes.SearchTotalHits).value = 0;
(actionResponses.hits.total as estypes.SearchTotalHits).value = 0;
actionRequests = endpointActionGenerator.toEsSearchResponse([]);
@ -162,8 +163,8 @@ describe('When using `getActionList()', () => {
data: [],
elasticAgentIds: undefined,
endDate: undefined,
page: 0,
pageSize: undefined,
page: 1,
pageSize: 10,
startDate: undefined,
total: 0,
userIds: undefined,

View file

@ -6,6 +6,8 @@
*/
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
import { ENDPOINT_DEFAULT_PAGE_SIZE } from '../../../../common/endpoint/constants';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
import type { ActionDetails, ActionListApiResponse } from '../../../../common/endpoint/types';
@ -42,12 +44,12 @@ export const getActionList = async ({
esClient: ElasticsearchClient;
logger: Logger;
}): Promise<ActionListApiResponse> => {
const size = pageSize ?? 10;
const page = (_page ?? 1) - 1;
const size = pageSize ?? ENDPOINT_DEFAULT_PAGE_SIZE;
const page = _page ?? 1;
// # of hits to skip
const from = page * size;
const from = (page - 1) * size;
const data = await getActionDetailsList({
const { actionDetails, totalRecords } = await getActionDetailsList({
commands,
elasticAgentIds,
esClient,
@ -61,14 +63,14 @@ export const getActionList = async ({
return {
page,
pageSize,
pageSize: size,
startDate,
endDate,
elasticAgentIds,
userIds,
commands,
data,
total: data.length,
data: actionDetails,
total: totalRecords,
};
};
@ -88,7 +90,10 @@ const getActionDetailsList = async ({
size,
startDate,
userIds,
}: GetActionDetailsListParam): Promise<ActionDetails[]> => {
}: GetActionDetailsListParam): Promise<{
actionDetails: ActionDetails[];
totalRecords: number;
}> => {
let actionRequests;
let actionReqIds;
let actionResponses;
@ -119,10 +124,11 @@ const getActionDetailsList = async ({
}
// return empty details array
if (!actionRequests?.body?.hits?.hits) return [];
if (!actionRequests?.body?.hits?.hits) return { actionDetails: [], totalRecords: 0 };
// format endpoint actions into { type, item } structure
const formattedActionRequests = formatEndpointActionResults(actionRequests?.body?.hits?.hits);
const totalRecords = (actionRequests?.body?.hits?.total as unknown as SearchTotalHits).value;
// normalized actions with a flat structure to access relevant values
const normalizedActionRequests: Array<ReturnType<typeof mapToNormalizedActionRequest>> =
@ -182,5 +188,5 @@ const getActionDetailsList = async ({
};
});
return actionDetails;
return { actionDetails, totalRecords };
};