Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Endpoint][Response Actions] Show shell info above execute action output #154318

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,39 @@ describe('When using the `ExecuteActionHostResponse` component', () => {
};
});

const outputSuffix = 'output';

it('should show shell info and shell code', async () => {
render();
expect(renderResult.getByTestId(`test-executeResponseOutput-context`)).toBeTruthy();
expect(renderResult.getByTestId(`test-executeResponseOutput-shell`)).toBeTruthy();
expect(renderResult.getByTestId(`test-executeResponseOutput-cwd`)).toBeTruthy();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: instead of toBeTruthy(), it may be semantically better to use toBeInTheDocument(). In this case, you can also use queryByX instead of getByX, so if the queried element is not present, the query function won't fail, but the toBeInTheDocument() will.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I like that better. Will update it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done 177a31b

});

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();
expect(renderResult.getByTestId(`test-executeResponseOutput-context`)).toBeTruthy();
expect(renderResult.getByTestId(`test-executeResponseOutput-cwd`)).toBeTruthy();
});

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

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 @@ -67,12 +89,10 @@ 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 @@ -86,17 +106,16 @@ 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();
expect(renderResult.queryByTestId(`test-executeResponseOutput-context`)).toBeNull();
expect(renderResult.queryByTestId(`test-executeResponseOutput-${outputSuffix}`)).toBeNull();
});
});
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