Skip to content

Commit

Permalink
[Security Solution][Endpoint] Show upload response action results i…
Browse files Browse the repository at this point in the history
…n Actions Log (elastic#157390)

## Summary

- Adds the results of the `upload` action to the details tray in the
Actions Log
- Refactors the `parameters` sent to the Endpoint for `upload` to meet
the newly agreed structure
  • Loading branch information
paul-tavares authored May 12, 2023
1 parent bba20a2 commit 2cc04e9
Show file tree
Hide file tree
Showing 11 changed files with 178 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,10 @@ export class EndpointActionGenerator extends BaseDataGenerator {
>;

uploadActionDetails.parameters = {
file: {
file_id: 'file-x-y-z',
file_name: 'foo.txt',
size: 1234,
sha256: 'file-hash-sha-256',
},
file_id: 'file-x-y-z',
file_name: 'foo.txt',
file_size: 1234,
file_sha256: 'file-hash-sha-256',
};

uploadActionDetails.outputs = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,10 @@ export interface ActionFileInfoApiResponse {
* the action's parameters via the API route handler
*/
export type ResponseActionUploadParameters = UploadActionApiRequestBody['parameters'] & {
file: {
sha256: string;
size: number;
file_name: string;
file_id: string;
};
file_sha256: string;
file_size: number;
file_name: string;
file_id: string;
};

export interface ResponseActionUploadOutputContent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import React, { memo, useMemo } from 'react';
import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiDescriptionList } from '@elastic/eui';
import { css, euiStyled } from '@kbn/kibana-react-plugin/common';
import { EndpointUploadActionResult } from '../../endpoint_upload_action_result';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import { OUTPUT_MESSAGES } from '../translations';
import { getUiCommand } from './hooks';
Expand All @@ -16,6 +17,10 @@ import { ResponseActionFileDownloadLink } from '../../response_action_file_downl
import { ExecuteActionHostResponse } from '../../endpoint_execute_action';
import { getEmptyValue } from '../../../../common/components/empty_value';

import type {
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
} from '../../../../../common/endpoint/types';
import { type ActionDetails, type MaybeImmutable } from '../../../../../common/endpoint/types';

const emptyValue = getEmptyValue();
Expand Down Expand Up @@ -75,6 +80,12 @@ const StyledEuiFlexGroup = euiStyled(EuiFlexGroup).attrs({
overflow-y: auto;
`;

const isUploadAction = (
action: MaybeImmutable<ActionDetails>
): action is ActionDetails<ResponseActionUploadOutputContent, ResponseActionUploadParameters> => {
return action.command === 'upload';
};

const OutputContent = memo<{ action: MaybeImmutable<ActionDetails>; 'data-test-subj'?: string }>(
({ action, 'data-test-subj': dataTestSubj }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
Expand Down Expand Up @@ -145,6 +156,20 @@ const OutputContent = memo<{ action: MaybeImmutable<ActionDetails>; 'data-test-s
);
}

if (isUploadAction(action)) {
return (
<EuiFlexGroup direction="column" data-test-subj={getTestId('uploadDetails')}>
<p>{OUTPUT_MESSAGES.wasSuccessful(command)}</p>

<EndpointUploadActionResult
action={action}
data-test-subj={getTestId('uploadOutput')}
textSize="xs"
/>
</EuiFlexGroup>
);
}

return <>{OUTPUT_MESSAGES.wasSuccessful(command)}</>;
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ import {
} from '../../../../common/mock/endpoint';
import { ResponseActionsLog } from '../response_actions_log';
import type {
ActionDetails,
ActionDetailsApiResponse,
ActionFileInfoApiResponse,
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
} from '../../../../../common/endpoint/types';
import { MANAGEMENT_PATH } from '../../../../../common/constants';
import { getActionListMock } from '../mocks';
Expand All @@ -29,6 +32,8 @@ import { responseActionsHttpMocks } from '../../../mocks/response_actions_http_m
import { waitFor } from '@testing-library/react';
import { getEndpointAuthzInitialStateMock } from '../../../../../common/endpoint/service/authz/mocks';
import { useGetEndpointActionList as _useGetEndpointActionList } from '../../../hooks/response_actions/use_get_endpoint_action_list';
import { OUTPUT_MESSAGES } from '../translations';
import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator';

const useGetEndpointActionListMock = _useGetEndpointActionList as jest.Mock;

Expand Down Expand Up @@ -786,6 +791,84 @@ describe('Response actions history', () => {
}
);
});

describe('`upload` action', () => {
let action: ActionDetails<ResponseActionUploadOutputContent, ResponseActionUploadParameters>;

beforeEach(async () => {
action = new EndpointActionGenerator().generateActionDetails<
ResponseActionUploadOutputContent,
ResponseActionUploadParameters
>({ command: 'upload' });

const actionListApiResponse = await getActionListMock({
actionCount: 1,
commands: ['upload'],
});

actionListApiResponse.data = [action];

useGetEndpointActionListMock.mockReturnValue({
...getBaseMockedActionList(),
data: actionListApiResponse,
});
});

it('should display pending output if action is not complete yet', () => {
action.isCompleted = false;
const { getByTestId } = render();
getByTestId(`${testPrefix}-expand-button`).click();

expect(getByTestId(`${testPrefix}-details-tray-output`)).toHaveTextContent(
OUTPUT_MESSAGES.isPending('upload')
);
});

it('should display output for single agent', () => {
const { getByTestId } = render();
getByTestId(`${testPrefix}-expand-button`).click();

expect(getByTestId(`${testPrefix}-uploadDetails`)).toHaveTextContent(
'upload completed successfully' +
'File saved to: /path/to/uploaded/file' +
'Free disk space on drive: 1.18MB'
);
});

it('should display output for multiple agents', () => {
action.agents.push('agent-b');
action.hosts['agent-b'] = {
name: 'host b',
};
action.agentState['agent-b'] = {
errors: undefined,
wasSuccessful: true,
isCompleted: true,
completedAt: '2023-05-10T20:09:25.824Z',
};
(action.outputs = action.outputs ?? {})['agent-b'] = {
type: 'json',
content: {
code: 'ra_upload_file-success',
path: 'some/path/to/file',
disk_free_space: 123445,
},
};

const { getByTestId } = render();
getByTestId(`${testPrefix}-expand-button`).click();

expect(getByTestId(`${testPrefix}-uploadDetails`)).toHaveTextContent(
'upload completed successfully' +
'Host: Host-agent-a' +
'File saved to: /path/to/uploaded/file' +
'Free disk space on drive: 1.18MB' +
'Host: host b' +
'File saved to: some/path/to/file' +
'Free disk space on drive: 120.55KB'
);
});
});
});

describe('Action status ', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,49 @@ export const getActionListMock = async ({
.map(() => uuidv4());

const actionDetails: ActionListApiResponse['data'] = actionIds.map((actionId) => {
const command = (commands?.[0] ?? 'isolate') as ResponseActionsApiCommandNames;
return endpointActionGenerator.generateActionDetails({
agents: [id],
command: (commands?.[0] ?? 'isolate') as ResponseActionsApiCommandNames,
command,
id: actionId,
isCompleted,
isExpired,
wasSuccessful,
status,
completedAt: isExpired ? undefined : new Date().toISOString(),
hosts: {
...(command === 'upload'
? {
[id]: { name: 'host name' },
}
: {}),
},
agentState: {
...(command === 'upload'
? {
[id]: {
errors: undefined,
wasSuccessful: true,
isCompleted: true,
completedAt: '2023-05-10T20:09:25.824Z',
},
}
: {}),
},
outputs: {
...(command === 'upload'
? {
[id]: {
type: 'json',
content: {
code: 'ra_upload_file-success',
path: 'some/path/to/file',
disk_free_space: 123445,
},
},
}
: {}),
},
});
});
return actionDetails;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { FormattedMessage } from '@kbn/i18n-react';
import type { PropsWithChildren } from 'react';
import React, { memo, useMemo } from 'react';
import type { EuiTextProps } from '@elastic/eui';
import { EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import numeral from '@elastic/numeral';
Expand Down Expand Up @@ -53,11 +54,12 @@ interface EndpointUploadActionResultProps {
>;
/** The agent id to display the result for. If undefined, the output for ALL agents will be displayed */
agentId?: string;
textSize?: EuiTextProps['size'];
'data-test-subj'?: string;
}

export const EndpointUploadActionResult = memo<EndpointUploadActionResultProps>(
({ action: _action, agentId, 'data-test-subj': dataTestSubj }) => {
({ action: _action, agentId, textSize = 's', 'data-test-subj': dataTestSubj }) => {
const action = _action as ActionDetails<
ResponseActionUploadOutputContent,
ResponseActionUploadParameters
Expand Down Expand Up @@ -99,7 +101,7 @@ export const EndpointUploadActionResult = memo<EndpointUploadActionResultProps>(
}

return (
<div data-test-subj={getTestId()}>
<EuiText data-test-subj={getTestId()} size={textSize}>
{outputs.map(({ name, state, result }) => {
// Use case: action log
if (!state.isCompleted) {
Expand Down Expand Up @@ -157,7 +159,7 @@ export const EndpointUploadActionResult = memo<EndpointUploadActionResultProps>(
</HostUploadResult>
);
})}
</div>
</EuiText>
);
}
);
Expand All @@ -169,9 +171,8 @@ export interface KeyValueDisplayProps {
}
const KeyValueDisplay = memo<KeyValueDisplayProps>(({ name, value }) => {
return (
<EuiText
<div
className="eui-textBreakWord"
size="s"
css={css`
white-space: pre-wrap;
`}
Expand All @@ -181,7 +182,7 @@ const KeyValueDisplay = memo<KeyValueDisplayProps>(({ name, value }) => {
{': '}
</strong>
{value}
</EuiText>
</div>
);
});
KeyValueDisplay.displayName = 'KeyValueDisplay';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const useSendUploadEndpointRequest = (
formData.append('file', file, file.name);

for (const [key, value] of Object.entries(payload)) {
formData.append(key, JSON.stringify(value));
formData.append(key, typeof value !== 'string' ? JSON.stringify(value) : value);
}

return http.post<ResponseActionApiResponse>(UPLOAD_ROUTE, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,10 @@ describe('Upload response action create API handler', () => {
command: 'upload',
endpoint_ids: ['123-456'],
parameters: {
file: {
file_id: '123',
file_name: 'test.txt',
sha256: 'abc',
size: 1234,
},
file_id: '123',
file_name: 'test.txt',
file_sha256: 'abc',
file_size: 1234,
overwrite: true,
},
user: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,10 @@ export const getActionFileUploadHandler = (
const { file: _, parameters: userParams, ...actionPayload } = req.body;
const uploadParameters: ResponseActionUploadParameters = {
...userParams,
file: {
file_id: '',
file_name: '',
sha256: '',
size: 0,
},
file_id: '',
file_name: '',
file_sha256: '',
file_size: 0,
};

try {
Expand All @@ -92,10 +90,10 @@ export const getActionFileUploadHandler = (
maxFileBytes,
});

uploadParameters.file.file_id = createdFile.file.id;
uploadParameters.file.file_name = createdFile.file.name;
uploadParameters.file.sha256 = createdFile.file.hash?.sha256;
uploadParameters.file.size = createdFile.file.size;
uploadParameters.file_id = createdFile.file.id;
uploadParameters.file_name = createdFile.file.name;
uploadParameters.file_sha256 = createdFile.file.hash?.sha256;
uploadParameters.file_size = createdFile.file.size;
} catch (err) {
return errorHandler(logger, res, err);
}
Expand Down Expand Up @@ -131,10 +129,10 @@ export const getActionFileUploadHandler = (
},
});
} catch (err) {
if (uploadParameters.file.file_id) {
if (uploadParameters.file_id) {
// Try to delete the created file since creating the action threw an error
try {
await deleteFile(esClient, logger, uploadParameters.file.file_id);
await deleteFile(esClient, logger, uploadParameters.file_id);
} catch (e) {
logger.error(
`Attempt to clean up file (after action creation was unsuccessful) failed; ${e.message}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,10 @@ describe('Action Files service', () => {
});

it('should throw an error no `action.parameters.file.file_id` defined', async () => {
action.parameters!.file.file_id = '';
action.parameters!.file_id = '';

await expect(setFileActionId(esClientMock, loggerMock, action)).rejects.toThrow(
"Action [123] has no 'parameters.file.file_id' defined. Unable to set action id on file record"
"Action [123] has no 'parameters.file_id' defined. Unable to set action id on file metadata record"
);
});
});
Expand Down
Loading

0 comments on commit 2cc04e9

Please sign in to comment.