[Security Solution][Endpoint][Response Actions]Show file is truncated message if execute response metadata has that … (#154181)

## Summary

Informs the user that the `execute` response full output file is
truncated in case one of the compressed file exceeds size limit.

<img width="1457" alt="Screenshot 2023-04-03 at 13 35 03"
src="https://user-images.githubusercontent.com/1849116/229497936-2e50dbcd-6d9d-4684-aae0-06a0189f53b6.png">


**action parameters with a long command (no changes in this PR except
adding some space after semicolon)**
<img width="969" alt="Screenshot 2023-04-03 at 13 40 29"
src="https://user-images.githubusercontent.com/1849116/229499048-740f250c-38db-4efe-8ee6-8bc80ca6e604.png">

<img width="1951" alt="Screenshot 2023-04-03 at 13 40 12"
src="https://user-images.githubusercontent.com/1849116/229499034-189700c9-b67b-44c0-8867-f274e7a79f82.png">


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))

(https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Ashokaditya 2023-04-04 08:45:43 +02:00 committed by GitHub
parent 06f7a3529c
commit 8b68ce8558
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 240 additions and 114 deletions

View file

@ -232,6 +232,7 @@ export class EndpointActionGenerator extends BaseDataGenerator {
[details.agents[0]]: this.generateExecuteActionResponseOutput({
content: {
output_file_id: getFileDownloadId(details, details.agents[0]),
...overrides.outputs?.[details.agents[0]].content,
},
}),
};
@ -310,11 +311,13 @@ export class EndpointActionGenerator extends BaseDataGenerator {
content: {
stdout: this.randomChoice([
this.randomString(1280),
this.randomString(3580),
`-rw-r--r-- 1 elastic staff 458 Jan 26 09:10 doc.txt\
-rw-r--r-- 1 elastic staff 298 Feb 2 09:10 readme.md`,
]),
stderr: this.randomChoice([
this.randomString(1280),
this.randomString(3580),
`error line 1\
error line 2\
error line 3 that is quite very long and will be truncated, and should not be visible in the UI\
@ -326,6 +329,8 @@ export class EndpointActionGenerator extends BaseDataGenerator {
shell: 'bash',
cwd: '/some/path',
output_file_id: 'some-output-file-id',
output_file_stdout_truncated: this.randomChoice([true, false]),
output_file_stderr_truncated: this.randomChoice([true, false]),
},
},
overrides

View file

@ -77,6 +77,11 @@ export interface ResponseActionExecuteOutputContent {
/* The current working directory used when the command was executed */
cwd: string;
output_file_id: string;
/** Informs whether the stdout/stderr files are
* truncated due to size limitations (50 Mb each)
* */
output_file_stdout_truncated: boolean;
output_file_stderr_truncated: boolean;
}
export const ActivityLogItemTypes = {

View file

@ -7,94 +7,96 @@
import type { AppContextTestRender } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
import type {
ActionDetails,
ResponseActionExecuteOutputContent,
ResponseActionsExecuteParameters,
ActionDetails,
} from '../../../../common/endpoint/types';
import React from 'react';
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
import {
ExecuteActionHostResponseOutput,
type ExecuteActionHostResponseOutputProps,
} from './execute_action_host_response_output';
ExecuteActionHostResponse,
type ExecuteActionHostResponseProps,
} from './execute_action_host_response';
import { getEmptyValue } from '@kbn/cases-plugin/public/components/empty_value';
describe('When using the `ExecuteActionHostResponseOutput` component', () => {
describe('When using the `ExecuteActionHostResponse` component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<AppContextTestRender['render']>;
let renderProps: ExecuteActionHostResponseOutputProps;
let renderProps: ExecuteActionHostResponseProps;
const action = new EndpointActionGenerator('seed').generateActionDetails<
ResponseActionExecuteOutputContent,
ResponseActionsExecuteParameters
>({ command: 'execute', agents: ['agent-a'] });
beforeEach(() => {
const appTestContext = createAppRootMockRenderer();
renderProps = {
action: new EndpointActionGenerator('seed').generateActionDetails<
ResponseActionExecuteOutputContent,
ResponseActionsExecuteParameters
>({ command: 'execute' }),
action,
canAccessFileDownloadLink: true,
'data-test-subj': 'test',
};
render = () => {
renderResult = appTestContext.render(<ExecuteActionHostResponseOutput {...renderProps} />);
renderResult = appTestContext.render(<ExecuteActionHostResponse {...renderProps} />);
return renderResult;
};
});
it('should show execute output and execute errors', async () => {
render();
expect(renderResult.getByTestId('test')).toBeTruthy();
expect(renderResult.getByTestId('test-executeResponseOutput')).toBeTruthy();
});
it('should show execute output as `open`', async () => {
it('should show execute output accordion as `open`', async () => {
render();
const accordionOutputButton = Array.from(
renderResult.getByTestId('test').querySelectorAll('.euiAccordion')
renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion')
)[0];
expect(accordionOutputButton.className).toContain('isOpen');
});
it('should show `-` when no output content', async () => {
it('should show `-` in output accordion when no output content', async () => {
(renderProps.action as ActionDetails).outputs = {
'agent-a': {
type: 'json',
content: {
...(renderProps.action as ActionDetails)?.outputs?.['agent-a'].content,
stdout: undefined,
...(renderProps.action as ActionDetails).outputs?.[action.agents[0]].content,
stdout: '',
},
},
};
render();
const accordionOutputButton = Array.from(
renderResult.getByTestId('test').querySelectorAll('.euiAccordion')
renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion')
)[0];
expect(accordionOutputButton.textContent).toContain(
`Execution output (truncated)${getEmptyValue()}`
);
});
it('should show `-` when no error content', async () => {
it('should show `-` in error accordion when no error content', async () => {
(renderProps.action as ActionDetails).outputs = {
'agent-a': {
type: 'json',
content: {
...(renderProps.action as ActionDetails)?.outputs?.['agent-a'].content,
stderr: undefined,
...(renderProps.action as ActionDetails).outputs?.[action.agents[0]].content,
stderr: '',
},
},
};
render();
const accordionErrorButton = Array.from(
renderResult.getByTestId('test').querySelectorAll('.euiAccordion')
renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion')
)[1];
expect(accordionErrorButton.textContent).toContain(
`Execution error (truncated)${getEmptyValue()}`
);
});
it('should show nothing when no output in action details', () => {
(renderProps.action as ActionDetails).outputs = {};
it('should not show execute output accordions when no output in action details', () => {
(renderProps.action as ActionDetails).outputs = undefined;
render();
expect(renderResult.queryByTestId('test')).toBeNull();
expect(renderResult.queryByTestId('test-executeResponseOutput')).toBeNull();
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 { EuiFlexItem } from '@elastic/eui';
import React, { memo, useMemo } from 'react';
import type {
ActionDetails,
MaybeImmutable,
ResponseActionExecuteOutputContent,
} from '../../../../common/endpoint/types';
import { EXECUTE_FILE_LINK_TITLE } from '../endpoint_response_actions_list/translations';
import { ResponseActionFileDownloadLink } from '../response_action_file_download_link';
import { ExecuteActionHostResponseOutput } from './execute_action_host_response_output';
export interface ExecuteActionHostResponseProps {
action: MaybeImmutable<ActionDetails>;
agentId?: string;
canAccessFileDownloadLink: boolean;
'data-test-subj'?: string;
textSize?: 'xs' | 's';
}
export const ExecuteActionHostResponse = memo<ExecuteActionHostResponseProps>(
({
action,
agentId = action.agents[0],
canAccessFileDownloadLink,
textSize = 's',
'data-test-subj': dataTestSubj,
}) => {
const outputContent = useMemo(
() =>
action.outputs &&
action.outputs[agentId] &&
(action.outputs[agentId].content as ResponseActionExecuteOutputContent),
[action.outputs, agentId]
);
const isTruncatedFile = useMemo<boolean>(
() =>
(outputContent?.output_file_stderr_truncated ||
outputContent?.output_file_stdout_truncated) ??
false,
[outputContent]
);
return (
<>
<EuiFlexItem>
<ResponseActionFileDownloadLink
action={action}
buttonTitle={EXECUTE_FILE_LINK_TITLE}
canAccessFileDownloadLink={canAccessFileDownloadLink}
isTruncatedFile={isTruncatedFile}
data-test-subj={`${dataTestSubj}-getExecuteLink`}
textSize={textSize}
/>
</EuiFlexItem>
{outputContent && (
<ExecuteActionHostResponseOutput
outputContent={outputContent}
data-test-subj={`${dataTestSubj}-executeResponseOutput`}
textSize={textSize}
/>
)}
</>
);
}
);
ExecuteActionHostResponse.displayName = 'ExecuteActionHostResponse';

View file

@ -4,15 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import React, { memo } from 'react';
import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiText, useGeneratedHtmlId } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type {
ActionDetails,
MaybeImmutable,
ResponseActionExecuteOutputContent,
} from '../../../../common/endpoint/types';
import type { ResponseActionExecuteOutputContent } from '../../../../common/endpoint/types';
import { getEmptyValue } from '../../../common/components/empty_value';
const emptyValue = getEmptyValue();
@ -84,45 +81,30 @@ const ExecutionActionOutputAccordion = memo<ExecuteActionOutputProps>(
ExecutionActionOutputAccordion.displayName = 'ExecutionActionOutputAccordion';
export interface ExecuteActionHostResponseOutputProps {
action: MaybeImmutable<ActionDetails>;
agentId?: string;
outputContent: ResponseActionExecuteOutputContent;
'data-test-subj'?: string;
textSize?: 's' | 'xs';
}
export const ExecuteActionHostResponseOutput = memo<ExecuteActionHostResponseOutputProps>(
({ action, agentId = action.agents[0], 'data-test-subj': dataTestSubj, textSize = 'xs' }) => {
const outputContent = useMemo(
() =>
action.outputs &&
action.outputs[agentId] &&
(action.outputs[agentId].content as ResponseActionExecuteOutputContent),
[action.outputs, agentId]
);
if (!outputContent) {
return <></>;
}
return (
<EuiFlexItem data-test-subj={dataTestSubj}>
<EuiSpacer size="m" />
<ExecutionActionOutputAccordion
content={outputContent.stdout}
isTruncated={outputContent.stdout_truncated}
initialIsOpen
textSize={textSize}
type="output"
/>
<EuiSpacer size="m" />
<ExecutionActionOutputAccordion
content={outputContent.stderr}
isTruncated={outputContent.stderr_truncated}
textSize={textSize}
type="error"
/>
</EuiFlexItem>
);
}
({ outputContent, 'data-test-subj': dataTestSubj, textSize = 'xs' }) => (
<EuiFlexItem data-test-subj={dataTestSubj}>
<EuiSpacer size="m" />
<ExecutionActionOutputAccordion
content={outputContent.stdout.length ? outputContent.stdout : undefined}
isTruncated={outputContent.stdout_truncated}
initialIsOpen
textSize={textSize}
type="output"
/>
<EuiSpacer size="m" />
<ExecutionActionOutputAccordion
content={outputContent.stderr.length ? outputContent.stderr : undefined}
isTruncated={outputContent.stderr_truncated}
textSize={textSize}
type="error"
/>
</EuiFlexItem>
)
);
ExecuteActionHostResponseOutput.displayName = 'ExecuteActionHostResponseOutput';

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { ExecuteActionHostResponseOutput } from './execute_action_host_response_output';
export { ExecuteActionHostResponse } from './execute_action_host_response';

View file

@ -8,16 +8,13 @@
import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexItem } from '@elastic/eui';
import type { ExecuteActionRequestBody } from '../../../../../common/endpoint/schema/actions';
import { useConsoleActionSubmitter } from '../hooks/use_console_action_submitter';
import type { ResponseActionExecuteOutputContent } from '../../../../../common/endpoint/types';
import { useSendExecuteEndpoint } from '../../../hooks/response_actions/use_send_execute_endpoint_request';
import type { ActionRequestComponentProps } from '../types';
import { parsedExecuteTimeout } from '../lib/utils';
import { ExecuteActionHostResponseOutput } from '../../endpoint_execute_action';
import { ResponseActionFileDownloadLink } from '../../response_action_file_download_link';
import { EXECUTE_FILE_LINK_TITLE } from '../../endpoint_response_actions_list/translations';
import { ExecuteActionHostResponse } from '../../endpoint_execute_action';
export const ExecuteActionResult = memo<
ActionRequestComponentProps<{
@ -75,20 +72,12 @@ export const ExecuteActionResult = memo<
{ defaultMessage: 'Command execution was successful.' }
)}
>
<EuiFlexItem>
<ResponseActionFileDownloadLink
action={completedActionDetails}
buttonTitle={EXECUTE_FILE_LINK_TITLE}
canAccessFileDownloadLink={true}
data-test-subj="consoleGetExecuteLink"
textSize="s"
/>
</EuiFlexItem>
<ExecuteActionHostResponseOutput
<ExecuteActionHostResponse
action={completedActionDetails}
canAccessFileDownloadLink={true}
agentId={command.commandDefinition?.meta?.endpointId}
data-test-subj="consoleExecuteResponseOutput"
textSize="s"
data-test-subj="console"
/>
</ResultComponent>
);

View file

@ -9,11 +9,11 @@ import React, { memo, useMemo } from 'react';
import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiDescriptionList } from '@elastic/eui';
import { css, euiStyled } from '@kbn/kibana-react-plugin/common';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { OUTPUT_MESSAGES, EXECUTE_FILE_LINK_TITLE } from '../translations';
import { OUTPUT_MESSAGES } from '../translations';
import { getUiCommand } from './hooks';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
import { ResponseActionFileDownloadLink } from '../../response_action_file_download_link';
import { ExecuteActionHostResponseOutput } from '../../endpoint_execute_action';
import { ExecuteActionHostResponse } from '../../endpoint_execute_action';
import { getEmptyValue } from '../../../../common/components/empty_value';
import { type ActionDetails, type MaybeImmutable } from '../../../../../common/endpoint/types';
@ -119,22 +119,14 @@ const OutputContent = memo<{ action: MaybeImmutable<ActionDetails>; 'data-test-s
{action.agents.map((agentId) => (
<div key={agentId}>
{OUTPUT_MESSAGES.wasSuccessful(command)}
<EuiFlexItem>
<ResponseActionFileDownloadLink
action={action}
buttonTitle={EXECUTE_FILE_LINK_TITLE}
canAccessFileDownloadLink={
canAccessEndpointActionsLogManagement || canReadActionsLogManagement
}
data-test-subj={getTestId('getExecuteLink')}
textSize="xs"
/>
</EuiFlexItem>
<ExecuteActionHostResponseOutput
<ExecuteActionHostResponse
action={action}
agentId={agentId}
data-test-subj={getTestId('executeResponseOutput')}
canAccessFileDownloadLink={
canAccessEndpointActionsLogManagement || canReadActionsLogManagement
}
textSize="xs"
data-test-subj={getTestId('actionsLogTray')}
/>
</div>
))}
@ -160,7 +152,7 @@ export const ActionsLogExpandedTray = memo<{
() =>
parameters
? Object.entries(parameters).map(([key, value]) => {
return `${key}:${value}`;
return `${key}: ${value}`;
})
: undefined,
[parameters]

View file

@ -16,6 +16,9 @@ import React from 'react';
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
import {
FILE_NO_LONGER_AVAILABLE_MESSAGE,
FILE_TRUNCATED_MESSAGE,
FILE_DELETED_MESSAGE,
FILE_PASSCODE_INFO_MESSAGE,
ResponseActionFileDownloadLink,
type ResponseActionFileDownloadLinkProps,
} from './response_action_file_download_link';
@ -42,6 +45,7 @@ describe('When using the `ResponseActionFileDownloadLink` component', () => {
>({ command: 'get-file' }),
'data-test-subj': 'test',
canAccessFileDownloadLink: true,
isTruncatedFile: false,
};
render = () => {
@ -58,10 +62,10 @@ describe('When using the `ResponseActionFileDownloadLink` component', () => {
expect(renderResult.getByTestId('test-downloadButton')).not.toBeNull();
expect(renderResult.getByTestId('test-passcodeMessage')).toHaveTextContent(
'(ZIP file passcode: elastic)'
FILE_PASSCODE_INFO_MESSAGE
);
expect(renderResult.getByTestId('test-fileDeleteMessage')).toHaveTextContent(
'Files are periodically deleted to clear storage space. Download and save file locally if needed.'
FILE_DELETED_MESSAGE
);
});
@ -114,6 +118,20 @@ describe('When using the `ResponseActionFileDownloadLink` component', () => {
});
});
it('should show file is truncated if execute file output is truncated', async () => {
renderProps.isTruncatedFile = true;
render();
await waitFor(() => {
expect(apiMocks.responseProvider.fileInfo).toHaveBeenCalled();
});
await waitFor(() => {
expect(renderResult.getByTestId('test-fileTruncatedMessage')).toHaveTextContent(
FILE_TRUNCATED_MESSAGE
);
});
});
it('should show file no longer available message if file info api returns 404', async () => {
const error = { message: 'not found', response: { status: 404 } } as IHttpFetchError;

View file

@ -6,8 +6,15 @@
*/
import React, { memo, useMemo, type CSSProperties } from 'react';
import { EuiButtonEmpty, EuiSkeletonText, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButtonEmpty,
EuiSkeletonText,
EuiText,
EuiSpacer,
EuiIcon,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import { getFileDownloadId } from '../../../../common/endpoint/service/response_actions/get_file_download_id';
@ -33,18 +40,72 @@ export const FILE_NO_LONGER_AVAILABLE_MESSAGE = i18n.translate(
{ defaultMessage: 'File has expired and is no longer available for download.' }
);
export const FILE_DELETED_MESSAGE = i18n.translate(
'xpack.securitySolution.responseActionFileDownloadLink.deleteNotice',
{
defaultMessage:
'Files are periodically deleted to clear storage space. Download and save file locally if needed.',
}
);
export const FILE_PASSCODE_INFO_MESSAGE = i18n.translate(
'xpack.securitySolution.responseActionFileDownloadLink.passcodeInfo',
{
defaultMessage: '(ZIP file passcode: {passcode}).',
values: {
passcode: 'elastic',
},
}
);
export const FILE_TRUNCATED_MESSAGE = i18n.translate(
'xpack.securitySolution.responseActionFileDownloadLink.fileTruncated',
{
defaultMessage:
'Output data in the provided zip file is truncated due to file size limitations.',
}
);
const FileDownloadLinkContainer = styled.div`
& > * {
vertical-align: middle;
}
`;
interface TruncatedTextInfoProps {
textSize: Required<ResponseActionFileDownloadLinkProps['textSize']>;
'data-test-subj'?: string;
}
const TruncatedTextInfo = memo<TruncatedTextInfoProps>(
({ textSize, 'data-test-subj': dataTestSubj }) => {
const alertIconSize = useMemo(() => (textSize === 'xs' ? 's' : 'm'), [textSize]);
return (
<>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="s" justifyContent="flexStart" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon size={alertIconSize} type="warning" color="warning" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size={textSize} data-test-subj={dataTestSubj}>
{FILE_TRUNCATED_MESSAGE}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
);
TruncatedTextInfo.displayName = 'TruncatedTextInfo';
export interface ResponseActionFileDownloadLinkProps {
action: MaybeImmutable<ActionDetails>;
/** If left undefined, the first agent that the action was sent to will be used */
agentId?: string;
buttonTitle?: string;
canAccessFileDownloadLink: boolean;
isTruncatedFile?: boolean;
'data-test-subj'?: string;
textSize?: 's' | 'xs';
}
@ -61,8 +122,9 @@ export const ResponseActionFileDownloadLink = memo<ResponseActionFileDownloadLin
agentId,
buttonTitle = DEFAULT_BUTTON_TITLE,
canAccessFileDownloadLink,
'data-test-subj': dataTestSubj,
isTruncatedFile = false,
textSize = 's',
'data-test-subj': dataTestSubj,
}) => {
const action = _action as ActionDetails; // cast to remove `Immutable`
const getTestId = useTestIdGenerator(dataTestSubj);
@ -123,20 +185,17 @@ export const ResponseActionFileDownloadLink = memo<ResponseActionFileDownloadLin
data-test-subj={getTestId('passcodeMessage')}
className="eui-displayInline"
>
<FormattedMessage
id="xpack.securitySolution.responseActionFileDownloadLink.passcodeInfo"
defaultMessage="(ZIP file passcode: {passcode})."
values={{
passcode: 'elastic',
}}
/>
{FILE_PASSCODE_INFO_MESSAGE}
</EuiText>
<EuiText size={textSize} data-test-subj={getTestId('fileDeleteMessage')}>
<FormattedMessage
id="xpack.securitySolution.responseActionFileDownloadLink.deleteNotice"
defaultMessage="Files are periodically deleted to clear storage space. Download and save file locally if needed."
/>
{FILE_DELETED_MESSAGE}
</EuiText>
{isTruncatedFile && (
<TruncatedTextInfo
textSize={textSize}
data-test-subj={getTestId('fileTruncatedMessage')}
/>
)}
</FileDownloadLinkContainer>
);
}