Skip to content

Commit

Permalink
[Security Solution][Endpoint][Response Actions] Show shell info above…
Browse files Browse the repository at this point in the history
… `execute` action output (#154318)

## Summary

Shows shell name, shell execution return code, and current working
directory info along with the command execution output for `execute`
action response.
  • Loading branch information
ashokaditya authored Apr 10, 2023
1 parent 2cac3b2 commit f63fffc
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ export class EndpointActionGenerator extends BaseDataGenerator {
stderr_truncated: true,
shell_code: 0,
shell: 'bash',
cwd: '/some/path',
cwd: this.randomChoice(['/some/path', '/a-very/long/path'.repeat(30)]),
output_file_id: 'some-output-file-id',
output_file_stdout_truncated: this.randomChoice([true, false]),
output_file_stderr_truncated: this.randomChoice([true, false]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,42 @@ describe('When using the `ExecuteActionHostResponse` component', () => {
};
});

const outputSuffix = 'output';

it('should show shell info and shell code', async () => {
render();
const { queryByTestId } = renderResult;
expect(queryByTestId(`test-executeResponseOutput-context`)).toBeInTheDocument();
expect(queryByTestId(`test-executeResponseOutput-shell`)).toBeInTheDocument();
expect(queryByTestId(`test-executeResponseOutput-cwd`)).toBeInTheDocument();
});

it('should show execute context accordion as `closed`', async () => {
render();
expect(renderResult.getByTestId('test-executeResponseOutput-context').className).toEqual(
'euiAccordion'
);
});

it('should show current working directory', async () => {
render();
const { queryByTestId } = renderResult;
expect(queryByTestId(`test-executeResponseOutput-context`)).toBeInTheDocument();
expect(queryByTestId(`test-executeResponseOutput-cwd`)).toBeInTheDocument();
});

it('should show execute output and execute errors', async () => {
render();
expect(renderResult.getByTestId('test-executeResponseOutput')).toBeTruthy();
const { queryByTestId } = renderResult;
expect(queryByTestId(`test-executeResponseOutput-${outputSuffix}`)).toBeInTheDocument();
expect(queryByTestId(`test-executeResponseOutput-error`)).toBeInTheDocument();
});

it('should show execute output accordion as `open`', async () => {
render();
const accordionOutputButton = Array.from(
renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion')
)[0];
expect(accordionOutputButton.className).toContain('isOpen');
expect(
renderResult.getByTestId(`test-executeResponseOutput-${outputSuffix}`).className
).toContain('isOpen');
});

it('should show `-` in output accordion when no output content', async () => {
Expand All @@ -66,13 +91,11 @@ describe('When using the `ExecuteActionHostResponse` component', () => {
},
},
};

render();
const accordionOutputButton = Array.from(
renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion')
)[0];
expect(accordionOutputButton.textContent).toContain(
`Execution output (truncated)${getEmptyValue()}`
);
expect(
renderResult.getByTestId(`test-executeResponseOutput-${outputSuffix}`).textContent
).toContain(`Execution output (truncated)${getEmptyValue()}`);
});

it('should show `-` in error accordion when no error content', async () => {
Expand All @@ -85,18 +108,19 @@ describe('When using the `ExecuteActionHostResponse` component', () => {
},
},
};

render();
const accordionErrorButton = Array.from(
renderResult.getByTestId('test-executeResponseOutput').querySelectorAll('.euiAccordion')
)[1];
expect(accordionErrorButton.textContent).toContain(
expect(renderResult.getByTestId('test-executeResponseOutput-error').textContent).toContain(
`Execution error (truncated)${getEmptyValue()}`
);
});

it('should not show execute output accordions when no output in action details', () => {
(renderProps.action as ActionDetails).outputs = undefined;

render();
expect(renderResult.queryByTestId('test-executeResponseOutput')).toBeNull();
const { queryByTestId } = renderResult;
expect(queryByTestId(`test-executeResponseOutput-context`)).not.toBeInTheDocument();
expect(queryByTestId(`test-executeResponseOutput-${outputSuffix}`)).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,30 @@
* 2.0.
*/

import React, { memo } from 'react';
import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiText, useGeneratedHtmlId } from '@elastic/eui';
import React, { memo, useMemo } from 'react';
import {
EuiAccordion,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';

import { euiStyled } from '@kbn/kibana-react-plugin/common';
import type { ResponseActionExecuteOutputContent } from '../../../../common/endpoint/types';
import { getEmptyValue } from '../../../common/components/empty_value';

const emptyValue = getEmptyValue();

const ACCORDION_BUTTON_TEXT = Object.freeze({
context: i18n.translate(
'xpack.securitySolution.responseActionExecuteAccordion.executionContext',
{
defaultMessage: 'Execution context',
}
),
output: {
regular: i18n.translate(
'xpack.securitySolution.responseActionExecuteAccordion.outputButtonTextRegular',
Expand Down Expand Up @@ -44,36 +58,95 @@ const ACCORDION_BUTTON_TEXT = Object.freeze({
),
},
});

const SHELL_INFO = Object.freeze({
shell: i18n.translate('xpack.securitySolution.responseActionExecuteAccordion.shellInformation', {
defaultMessage: 'Shell',
}),

returnCode: i18n.translate(
'xpack.securitySolution.responseActionExecuteAccordion.shellReturnCode',
{
defaultMessage: 'Return code',
}
),
currentDir: i18n.translate(
'xpack.securitySolution.responseActionExecuteAccordion.currentWorkingDirectory',
{
defaultMessage: 'Current working directory',
}
),
});

const StyledEuiText = euiStyled(EuiText)`
white-space: pre-wrap;
line-break: anywhere;
`;

interface ShellInfoContentProps {
content: string | number;
textSize?: 's' | 'xs';
title: string;
}
const ShellInfoContent = memo<ShellInfoContentProps>(({ content, textSize, title }) => (
<StyledEuiText size={textSize}>
<strong>
{title}
{': '}
</strong>
{content}
</StyledEuiText>
));

ShellInfoContent.displayName = 'ShellInfoContent';

interface ExecuteActionOutputProps {
content?: string;
content?: string | React.ReactNode;
initialIsOpen?: boolean;
isTruncated?: boolean;
textSize?: 's' | 'xs';
type: 'error' | 'output';
type: 'error' | 'output' | 'context';
'data-test-subj'?: string;
}

const ExecutionActionOutputAccordion = memo<ExecuteActionOutputProps>(
({ content = emptyValue, initialIsOpen = false, isTruncated = false, textSize, type }) => {
({
content = emptyValue,
initialIsOpen = false,
isTruncated = false,
textSize,
type,
'data-test-subj': dataTestSubj,
}) => {
const id = useGeneratedHtmlId({
prefix: 'executeActionOutputAccordions',
suffix: type,
});

const accordionButtonContent = useMemo(
() => (
<EuiText size={textSize}>
{type !== 'context'
? isTruncated
? ACCORDION_BUTTON_TEXT[type].truncated
: ACCORDION_BUTTON_TEXT[type].regular
: ACCORDION_BUTTON_TEXT[type]}
</EuiText>
),
[isTruncated, textSize, type]
);

return (
<EuiAccordion
id={id}
initialIsOpen={initialIsOpen}
buttonContent={ACCORDION_BUTTON_TEXT[type][isTruncated ? 'truncated' : 'regular']}
buttonContent={accordionButtonContent}
paddingSize="s"
data-test-subj={dataTestSubj}
>
<EuiText
size={textSize}
style={{
whiteSpace: 'pre-wrap',
lineBreak: 'anywhere',
}}
>
<p>{content}</p>
</EuiText>
<StyledEuiText size={textSize}>
{typeof content === 'string' ? <p>{content}</p> : content}
</StyledEuiText>
</EuiAccordion>
);
}
Expand All @@ -87,24 +160,70 @@ export interface ExecuteActionHostResponseOutputProps {
}

export const ExecuteActionHostResponseOutput = memo<ExecuteActionHostResponseOutputProps>(
({ 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>
)
({ outputContent, 'data-test-subj': dataTestSubj, textSize = 'xs' }) => {
const contextContent = useMemo(
() => (
<>
<EuiFlexGroup gutterSize="m" data-test-subj={`${dataTestSubj}-shell`}>
<EuiFlexItem grow={false}>
<ShellInfoContent
title={SHELL_INFO.shell}
content={outputContent.shell}
textSize={textSize}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ShellInfoContent
title={SHELL_INFO.returnCode}
content={outputContent.shell_code}
textSize={textSize}
/>
</EuiFlexItem>
</EuiFlexGroup>
<div data-test-subj={`${dataTestSubj}-cwd`}>
<EuiSpacer size="m" />
<ShellInfoContent
title={SHELL_INFO.currentDir}
content={outputContent.cwd}
textSize={textSize}
/>
</div>
</>
),
[dataTestSubj, outputContent.cwd, outputContent.shell, outputContent.shell_code, textSize]
);
return (
<>
<EuiFlexItem>
<EuiSpacer size="m" />
<ExecutionActionOutputAccordion
content={contextContent}
data-test-subj={`${dataTestSubj}-context`}
textSize={textSize}
type="context"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiSpacer size="m" />
<ExecutionActionOutputAccordion
content={outputContent.stdout.length ? outputContent.stdout : undefined}
data-test-subj={`${dataTestSubj}-output`}
isTruncated={outputContent.stdout_truncated}
initialIsOpen
textSize={textSize}
type="output"
/>
<EuiSpacer size="m" />
<ExecutionActionOutputAccordion
content={outputContent.stderr.length ? outputContent.stderr : undefined}
data-test-subj={`${dataTestSubj}-error`}
isTruncated={outputContent.stderr_truncated}
textSize={textSize}
type="error"
/>
</EuiFlexItem>
</>
);
}
);
ExecuteActionHostResponseOutput.displayName = 'ExecuteActionHostResponseOutput';
Original file line number Diff line number Diff line change
Expand Up @@ -406,9 +406,7 @@ describe('When using Actions service utilities', () => {
])
).toEqual({
...NOT_COMPLETED_OUTPUT,
outputs: {
'789': expect.any(Object),
},
outputs: expect.any(Object),
agentState: {
'123': {
completedAt: '2022-01-05T19:27:23.816Z',
Expand Down Expand Up @@ -444,10 +442,7 @@ describe('When using Actions service utilities', () => {
completedAt: COMPLETED_AT,
wasSuccessful: true,
errors: undefined,
outputs: {
456: expect.any(Object),
789: expect.any(Object),
},
outputs: expect.any(Object),
agentState: {
'123': {
completedAt: '2022-01-05T19:27:23.816Z',
Expand Down Expand Up @@ -489,9 +484,7 @@ describe('When using Actions service utilities', () => {
errors: ['Fleet action response error: something is no good'],
isCompleted: true,
wasSuccessful: false,
outputs: {
789: expect.any(Object),
},
outputs: expect.any(Object),
agentState: {
'123': {
completedAt: '2022-01-05T19:27:23.816Z',
Expand Down

0 comments on commit f63fffc

Please sign in to comment.