[Security Solution][Endpoint] Display better success and failure messages for kill suspend process actions (#137353) (#137462)

* add map with endpoint action response code and associated i18n message

* Add component to handle getting a failure message from a completed action

* Add component to handle getting a failure message from a completed action

* Correct type definition for ActionResponseOutput

* New ActionSuccess component + use it in kill/suspend process

* Change default failure message

* add some jsdocs to the endpoint codes

(cherry picked from commit cb4d6aa8d5)

Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2022-07-28 11:18:37 -04:00 committed by GitHub
parent 3d6d405054
commit 10460c84c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 304 additions and 71 deletions

View file

@ -18,9 +18,7 @@ export type ISOLATION_ACTIONS = 'isolate' | 'unisolate';
/** The output provided by some of the Endpoint responses */
export interface ActionResponseOutput<TOutputContent extends object = object> {
type: 'json' | 'text';
content: {
entries: TOutputContent[];
};
content: TOutputContent;
}
export interface ProcessesEntry {
@ -30,6 +28,24 @@ export interface ProcessesEntry {
user: string;
}
export interface GetProcessesActionOutputContent {
entries: ProcessesEntry[];
}
export interface SuspendProcessActionOutputContent {
code: string;
command?: string;
pid?: number;
entity_id?: string;
}
export interface KillProcessActionOutputContent {
code: string;
command?: string;
pid?: number;
entity_id?: string;
}
export const RESPONSE_ACTION_COMMANDS = [
'isolate',
'unisolate',
@ -275,9 +291,7 @@ export interface ActionDetails<TOutputContent extends object = object> {
startedAt: string;
/** The date when the action was completed (a response by the endpoint (not fleet) was received) */
completedAt: string | undefined;
/**
* The output data from an action
*/
/** The output data from an action stored in an object where the key is the agent id */
outputs?: Record<string, ActionResponseOutput<TOutputContent>>;
/** user that created the action */
createdBy: string;

View file

@ -7,10 +7,19 @@
export { Console } from './console';
export { ConsoleManager, useConsoleManager } from './components/console_manager';
export type { CommandDefinition, Command, ConsoleProps } from './types';
export type {
CommandDefinition,
Command,
ConsoleProps,
CommandExecutionComponentProps,
} from './types';
export type {
ConsoleRegistrationInterface,
ManagedConsoleExtensionComponentProps,
RegisteredConsoleClient,
ConsoleManagerClient,
} from './components/console_manager/types';
export type {
CommandExecutionResultProps,
CommandExecutionResultComponent,
} from './components/command_execution_result';

View file

@ -0,0 +1,71 @@
/*
* 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, useMemo } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { endpointActionResponseCodes } from '../endpoint_responder/endpoint_action_response_codes';
import type { ActionDetails, MaybeImmutable } from '../../../../common/endpoint/types';
interface EndpointActionFailureMessageProps {
action: MaybeImmutable<ActionDetails<{ code?: string }>>;
}
export const EndpointActionFailureMessage = memo<EndpointActionFailureMessageProps>(
({ action }) => {
return useMemo(() => {
if (!action.isCompleted || action.wasSuccessful) {
return null;
}
const errors: string[] = [];
// Determine if each endpoint returned a response code and if so,
// see if we have a localized message for it
if (action.outputs) {
for (const agent of action.agents) {
const endpointAgentOutput = action.outputs[agent];
if (
endpointAgentOutput &&
endpointAgentOutput.type === 'json' &&
endpointAgentOutput.content.code &&
endpointActionResponseCodes[endpointAgentOutput.content.code]
) {
errors.push(endpointActionResponseCodes[endpointAgentOutput.content.code]);
}
}
}
if (!errors.length) {
if (action.errors) {
errors.push(...action.errors);
} else {
errors.push(
i18n.translate('xpack.securitySolution.endpointActionFailureMessage.unknownFailure', {
defaultMessage: 'Action failed',
})
);
}
}
return (
<>
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.actionError.errorMessage"
defaultMessage="The following { errorCount, plural, =1 {error was} other {errors were}} encountered:"
values={{ errorCount: errors.length }}
/>
<EuiSpacer size="s" />
<div>{errors.join(' | ')}</div>
</>
);
}, [action]);
}
);
EndpointActionFailureMessage.displayName = 'EndpointActionFailureMessage';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { EndpointActionFailureMessage } from './endpoint_action_failure_message';

View file

@ -6,25 +6,19 @@
*/
import React, { memo } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { EndpointActionFailureMessage } from '../endpoint_action_failure_message';
import type { CommandExecutionResultComponent } from '../console/components/command_execution_result';
import type { ImmutableArray } from '../../../../common/endpoint/types';
import type { ActionDetails, MaybeImmutable } from '../../../../common/endpoint/types';
export const ActionError = memo<{
errors: ImmutableArray<string>;
title?: string;
action: MaybeImmutable<ActionDetails>;
ResultComponent: CommandExecutionResultComponent;
title?: string;
dataTestSubj?: string;
}>(({ title, dataTestSubj, errors, ResultComponent }) => {
}>(({ title, dataTestSubj, action, ResultComponent }) => {
return (
<ResultComponent showAs="failure" title={title} data-test-subj={dataTestSubj}>
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.actionError.errorMessage"
defaultMessage="The following errors were encountered:"
/>
<EuiSpacer size="s" />
<div>{errors.join(' | ')}</div>
<EndpointActionFailureMessage action={action} />
</ResultComponent>
);
});

View file

@ -0,0 +1,37 @@
/*
* 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, useMemo } from 'react';
import { endpointActionResponseCodes } from './endpoint_action_response_codes';
import type { ActionDetails, MaybeImmutable } from '../../../../common/endpoint/types';
import type { CommandExecutionResultComponent, CommandExecutionResultProps } from '../console';
export interface ActionSuccessProps extends CommandExecutionResultProps {
action: MaybeImmutable<ActionDetails<{ code?: string }>>;
ResultComponent: CommandExecutionResultComponent;
}
/**
* Display generic success message for all actions
*/
export const ActionSuccess = memo<ActionSuccessProps>(
({ action, ResultComponent, title: _title, ...props }) => {
const title = useMemo(() => {
if (_title) {
return _title;
}
const firstAgentId = action.agents[0];
const actionOutputCode = action.outputs?.[firstAgentId]?.content?.code;
return actionOutputCode ? endpointActionResponseCodes[actionOutputCode] : undefined;
}, [_title, action.agents, action.outputs]);
return <ResultComponent {...props} title={title} />;
}
);
ActionSuccess.displayName = 'ActionSuccess';

View file

@ -0,0 +1,69 @@
/*
* 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';
const CODES = Object.freeze({
// -----------------------------------------------------------------
// SUSPEND-PROCESS CODES
// -----------------------------------------------------------------
/**
* Code will be used whenever you provide an entity_id or pid that isn't found.
* suspend_process will always be an error because the process was not found to be suspended
*/
'ra_suspend-process_error_not-found': i18n.translate(
'xpack.securitySolution.endpointActionResponseCodes.suspendProcess.notFoundError',
{ defaultMessage: 'The provided process was not found' }
),
/**
* Code will be used when the provided process can not be killed (for stability reasons).
* Example: This occurs if you try to kill Endpoint Security
*/
'ra_suspend-process_error_not-permitted': i18n.translate(
'xpack.securitySolution.endpointActionResponseCodes.suspendProcess.notPermittedSuccess',
{ defaultMessage: 'The provided process cannot be suspended' }
),
// -----------------------------------------------------------------
// KILL-PROCESS CODES
// -----------------------------------------------------------------
/**
* Code will be used whenever you provide an entity_id that isn't found. Since entity_id is
* unique, we can guarantee that it was legitimately not found and not just that the process
* was already killed.
*/
'ra_kill-process_error_not-found': i18n.translate(
'xpack.securitySolution.endpointActionResponseCodes.killProcess.notFoundError',
{ defaultMessage: 'The provided process was not found' }
),
/**
* Code will be used whenever you provide a pid that isn't found. Since pid is reused, we aren't
* sure if the process was already killed or just wasn't found. In either case, a process with
* that pid will no longer be running.
*/
'ra_kill-process_success_no-action': i18n.translate(
'xpack.securitySolution.endpointActionResponseCodes.killProcess.noActionSuccess',
{ defaultMessage: 'Action completed. The provided process was not found or already killed' }
),
/**
* Code will be used when the provided process can not be killed (for stability reasons).
* Example: This occurs if you try to kill Endpoint Security
*/
'ra_kill-process_error_not-permitted': i18n.translate(
'xpack.securitySolution.endpointActionResponseCodes.killProcess.notPermittedSuccess',
{ defaultMessage: 'The provided process cannot be killed' }
),
});
/**
* A map of possible code's that can be returned from the endpoint for response actions
*/
export const endpointActionResponseCodes: Readonly<Record<string | keyof typeof CODES, string>> =
CODES;

View file

@ -11,7 +11,10 @@ import { EuiBasicTable } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { FormattedMessage } from '@kbn/i18n-react';
import type { ActionDetails, ProcessesEntry } from '../../../../common/endpoint/types';
import type {
ActionDetails,
GetProcessesActionOutputContent,
} from '../../../../common/endpoint/types';
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
import type { EndpointCommandDefinitionMeta } from './types';
import type { CommandExecutionComponentProps } from '../console/types';
@ -46,7 +49,7 @@ export const GetProcessesActionResult = memo<
{
actionId?: string;
actionRequestSent?: boolean;
completedActionDetails?: ActionDetails<ProcessesEntry>;
completedActionDetails?: ActionDetails<GetProcessesActionOutputContent>;
apiError?: IHttpFetchError;
},
EndpointCommandDefinitionMeta
@ -66,10 +69,13 @@ export const GetProcessesActionResult = memo<
error: processesActionRequestError,
} = useSendGetEndpointProcessesRequest();
const { data: actionDetails } = useGetActionDetails<ProcessesEntry>(actionId ?? '-', {
enabled: Boolean(actionId) && isPending,
refetchInterval: isPending ? 3000 : false,
});
const { data: actionDetails } = useGetActionDetails<GetProcessesActionOutputContent>(
actionId ?? '-',
{
enabled: Boolean(actionId) && isPending,
refetchInterval: isPending ? 3000 : false,
}
);
// Send get processes request if not yet done
useEffect(() => {
@ -201,7 +207,7 @@ export const GetProcessesActionResult = memo<
{ defaultMessage: 'Get processes action failed' }
)}
dataTestSubj={'getProcessesErrorCallout'}
errors={completedActionDetails?.errors}
action={completedActionDetails}
ResultComponent={ResultComponent}
/>
);

View file

@ -38,7 +38,7 @@ export const IsolateActionResult = memo<ActionRequestComponentProps>(
return (
<ActionError
dataTestSubj={'isolateErrorCallout'}
errors={completedActionDetails?.errors}
action={completedActionDetails}
ResultComponent={ResultComponent}
/>
);

View file

@ -8,7 +8,11 @@
import React, { memo, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionDetails } from '../../../../common/endpoint/types';
import { ActionSuccess } from './action_success';
import type {
ActionDetails,
KillProcessActionOutputContent,
} from '../../../../common/endpoint/types';
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
import type { EndpointCommandDefinitionMeta } from './types';
import { useSendKillProcessRequest } from '../../hooks/endpoint/use_send_kill_process_endpoint_request';
@ -23,7 +27,7 @@ export const KillProcessActionResult = memo<
{
actionId?: string;
actionRequestSent?: boolean;
completedActionDetails?: ActionDetails;
completedActionDetails?: ActionDetails<KillProcessActionOutputContent>;
apiError?: IHttpFetchError;
},
EndpointCommandDefinitionMeta
@ -37,10 +41,13 @@ export const KillProcessActionResult = memo<
const { mutate, data, isSuccess, error } = useSendKillProcessRequest();
const { data: actionDetails } = useGetActionDetails(actionId ?? '-', {
enabled: Boolean(actionId) && isPending,
refetchInterval: isPending ? ACTION_DETAILS_REFRESH_INTERVAL : false,
});
const { data: actionDetails } = useGetActionDetails<KillProcessActionOutputContent>(
actionId ?? '-',
{
enabled: Boolean(actionId) && isPending,
refetchInterval: isPending ? ACTION_DETAILS_REFRESH_INTERVAL : false,
}
);
// Send Kill request if not yet done
useEffect(() => {
@ -86,11 +93,6 @@ export const KillProcessActionResult = memo<
}
}, [actionDetails?.data, setStatus, setStore, isPending]);
// Show nothing if still pending
if (isPending) {
return <ResultComponent showAs="pending" />;
}
// Show API errors if perform action fails
if (isError && apiError) {
return (
@ -104,18 +106,29 @@ export const KillProcessActionResult = memo<
);
}
// Show nothing if still pending
if (isPending || !completedActionDetails) {
return <ResultComponent showAs="pending" />;
}
// Show errors
if (completedActionDetails?.errors) {
return (
<ActionError
dataTestSubj={'killProcessErrorCallout'}
errors={completedActionDetails?.errors}
action={completedActionDetails}
ResultComponent={ResultComponent}
/>
);
}
// Show Success
return <ResultComponent showAs="success" data-test-subj="killProcessSuccessCallout" />;
return (
<ActionSuccess
action={completedActionDetails}
ResultComponent={ResultComponent}
data-test-subj="killProcessSuccessCallout"
/>
);
});
KillProcessActionResult.displayName = 'KillProcessActionResult';

View file

@ -39,7 +39,7 @@ export const ReleaseActionResult = memo<ActionRequestComponentProps>(
return (
<ActionError
dataTestSubj={'releaseErrorCallout'}
errors={completedActionDetails?.errors}
action={completedActionDetails}
ResultComponent={ResultComponent}
/>
);

View file

@ -8,7 +8,11 @@
import React, { memo, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionDetails } from '../../../../common/endpoint/types';
import { ActionSuccess } from './action_success';
import type {
ActionDetails,
SuspendProcessActionOutputContent,
} from '../../../../common/endpoint/types';
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
import type { EndpointCommandDefinitionMeta } from './types';
import { useSendSuspendProcessRequest } from '../../hooks/endpoint/use_send_suspend_process_endpoint_request';
@ -23,7 +27,7 @@ export const SuspendProcessActionResult = memo<
{
actionId?: string;
actionRequestSent?: boolean;
completedActionDetails?: ActionDetails;
completedActionDetails?: ActionDetails<SuspendProcessActionOutputContent>;
apiError?: IHttpFetchError;
},
EndpointCommandDefinitionMeta
@ -37,10 +41,13 @@ export const SuspendProcessActionResult = memo<
const { mutate, data, isSuccess, error } = useSendSuspendProcessRequest();
const { data: actionDetails } = useGetActionDetails(actionId ?? '-', {
enabled: Boolean(actionId) && isPending,
refetchInterval: isPending ? ACTION_DETAILS_REFRESH_INTERVAL : false,
});
const { data: actionDetails } = useGetActionDetails<SuspendProcessActionOutputContent>(
actionId ?? '-',
{
enabled: Boolean(actionId) && isPending,
refetchInterval: isPending ? ACTION_DETAILS_REFRESH_INTERVAL : false,
}
);
// Send Suspend request if not yet done
useEffect(() => {
@ -100,7 +107,7 @@ export const SuspendProcessActionResult = memo<
}
// Show nothing if still pending
if (isPending) {
if (isPending || !completedActionDetails) {
return <ResultComponent showAs="pending" />;
}
@ -109,13 +116,19 @@ export const SuspendProcessActionResult = memo<
return (
<ActionError
dataTestSubj={'suspendProcessErrorCallout'}
errors={completedActionDetails?.errors}
action={completedActionDetails}
ResultComponent={ResultComponent}
/>
);
}
// Show Success
return <ResultComponent data-test-subj="suspendProcessSuccessCallout" />;
return (
<ActionSuccess
action={completedActionDetails}
ResultComponent={ResultComponent}
data-test-subj="suspendProcessSuccessCallout"
/>
);
});
SuspendProcessActionResult.displayName = 'SuspendProcessActionResult';

View file

@ -11,19 +11,19 @@ import { useQuery } from 'react-query';
import { useHttp } from '../../../common/lib/kibana';
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants';
import type { ActionDetailsApiResponse, ProcessesEntry } from '../../../../common/endpoint/types';
import type { ActionDetailsApiResponse } from '../../../../common/endpoint/types';
export const useGetActionDetails = <TOutputType extends object = object>(
actionId: string,
options: UseQueryOptions<ActionDetailsApiResponse<ProcessesEntry>, IHttpFetchError> = {}
): UseQueryResult<ActionDetailsApiResponse<ProcessesEntry>, IHttpFetchError> => {
options: UseQueryOptions<ActionDetailsApiResponse<TOutputType>, IHttpFetchError> = {}
): UseQueryResult<ActionDetailsApiResponse<TOutputType>, IHttpFetchError> => {
const http = useHttp();
return useQuery<ActionDetailsApiResponse<ProcessesEntry>, IHttpFetchError>({
return useQuery<ActionDetailsApiResponse<TOutputType>, IHttpFetchError>({
queryKey: ['get-action-details', actionId],
...options,
queryFn: () => {
return http.get<ActionDetailsApiResponse<ProcessesEntry>>(
return http.get<ActionDetailsApiResponse<TOutputType>>(
resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: actionId.trim() || 'undefined' })
);
},

View file

@ -11,7 +11,7 @@ import type { IHttpFetchError } from '@kbn/core-http-browser';
import type {
ProcessesRequestBody,
ResponseActionApiResponse,
ProcessesEntry,
GetProcessesActionOutputContent,
} from '../../../../common/endpoint/types/actions';
import { GET_PROCESSES_ROUTE } from '../../../../common/endpoint/constants';
import { KibanaServices } from '../../../common/lib/kibana';
@ -22,25 +22,24 @@ import { KibanaServices } from '../../../common/lib/kibana';
*/
export const useSendGetEndpointProcessesRequest = (
customOptions?: UseMutationOptions<
ResponseActionApiResponse<ProcessesEntry>,
ResponseActionApiResponse<GetProcessesActionOutputContent>,
IHttpFetchError,
ProcessesRequestBody
>
): UseMutationResult<
ResponseActionApiResponse<ProcessesEntry>,
ResponseActionApiResponse<GetProcessesActionOutputContent>,
IHttpFetchError,
ProcessesRequestBody
> => {
return useMutation<
ResponseActionApiResponse<ProcessesEntry>,
ResponseActionApiResponse<GetProcessesActionOutputContent>,
IHttpFetchError,
ProcessesRequestBody
>((getRunningProcessesData: ProcessesRequestBody) => {
return KibanaServices.get().http.post<ResponseActionApiResponse<ProcessesEntry>>(
GET_PROCESSES_ROUTE,
{
body: JSON.stringify(getRunningProcessesData),
}
);
return KibanaServices.get().http.post<
ResponseActionApiResponse<GetProcessesActionOutputContent>
>(GET_PROCESSES_ROUTE, {
body: JSON.stringify(getRunningProcessesData),
});
}, customOptions);
};

View file

@ -24,8 +24,8 @@ import type {
ActionListApiResponse,
ResponseActionApiResponse,
PendingActionsResponse,
ProcessesEntry,
ActionDetails,
GetProcessesActionOutputContent,
} from '../../../common/endpoint/types';
export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
@ -43,7 +43,7 @@ export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
agentPendingActionsSummary: (options: HttpFetchOptionsWithPath) => PendingActionsResponse;
processes: () => ActionDetailsApiResponse<ProcessesEntry>;
processes: () => ActionDetailsApiResponse<GetProcessesActionOutputContent>;
}>;
export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHttpMocksInterface>([
@ -134,7 +134,7 @@ export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHt
id: 'processes',
path: GET_PROCESSES_ROUTE,
method: 'post',
handler: (): ActionDetailsApiResponse<ProcessesEntry> => {
handler: (): ActionDetailsApiResponse<GetProcessesActionOutputContent> => {
const generator = new EndpointActionGenerator('seed');
const response = generator.generateActionDetails({
outputs: {
@ -145,7 +145,7 @@ export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHt
},
},
},
}) as ActionDetails<ProcessesEntry>;
}) as ActionDetails<GetProcessesActionOutputContent>;
return { data: response };
},

View file

@ -21,7 +21,7 @@ import type {
EndpointActionResponse,
LogsEndpointActionResponse,
ActionResponseOutput,
ProcessesEntry,
GetProcessesActionOutputContent,
} from '../../../common/endpoint/types';
import type { EndpointActionListRequestQuery } from '../../../common/endpoint/schema/actions';
import { EndpointActionGenerator } from '../../../common/endpoint/data_generators/endpoint_action_generator';
@ -158,6 +158,6 @@ const getOutputDataIfNeeded = (
entries: endpointActionGenerator.randomResponseActionProcesses(100),
},
},
} as { output: ActionResponseOutput<ProcessesEntry> })
} as { output: ActionResponseOutput<GetProcessesActionOutputContent> })
: {};
};