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] get-file response action kibana download file API #143708

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d7fd9c4
`getFileDownloadId()` utility to construct the id of a download
paul-tavares Oct 17, 2022
5fbec2f
fix action responder script for writing file to ES
paul-tavares Oct 17, 2022
3e79c2a
Action file download API and service
paul-tavares Oct 17, 2022
c7245ec
add `files` plugin to tsconfig
paul-tavares Oct 17, 2022
ca06557
`getFileDownloadStream()` now returns some meta about the file
paul-tavares Oct 17, 2022
37adc52
correct i18n key name on `useConsoleActionSubmitter()`
paul-tavares Oct 17, 2022
21464f9
Show download button on action success message
paul-tavares Oct 17, 2022
2b30504
`ResponseActionFileDownloadLink` component
paul-tavares Oct 18, 2022
f9555b6
Fix writing the file chunk to ES
paul-tavares Oct 18, 2022
e00cd4f
Adjust the Pending message for get-file
paul-tavares Oct 18, 2022
e02cde1
Additional test for get-file command
paul-tavares Oct 18, 2022
8ce54eb
created test files with suggested `todo` tests
paul-tavares Oct 19, 2022
d7a937a
File plugin mocks for `File` and `FileClient` classes
paul-tavares Oct 19, 2022
7a4a4e0
Tests for the action files service
paul-tavares Oct 19, 2022
9163093
New test mock setup creator for testing http API handlers
paul-tavares Oct 19, 2022
a59cd0e
Tests for file download api handler
paul-tavares Oct 19, 2022
b74ba52
additional tests + restructure of test for handler
paul-tavares Oct 19, 2022
7f7e442
Add authz check to File download link component
paul-tavares Oct 19, 2022
8f9bc84
Fix `get-file` command definition
paul-tavares Oct 19, 2022
efee62a
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Oct 19, 2022
88971a3
add `buttonTitle` to `<ResponseActionFileDownloadLink />`
paul-tavares Oct 20, 2022
e8a098a
Remove processing of backslash from console command parser in order t…
paul-tavares Oct 20, 2022
bc555a7
Merge remote-tracking branch 'origin/task/olm-4662-get-file-download-…
paul-tavares Oct 20, 2022
808bf35
word correction
paul-tavares Oct 20, 2022
56e99fe
Merge remote-tracking branch 'upstream/main' into task/olm-4662-get-f…
paul-tavares Oct 20, 2022
f27fc7d
use a zip file with passcode of `elastic` for `get-file` response action
paul-tavares Oct 20, 2022
1fb37ad
change API route for `get-file` file download
paul-tavares Oct 20, 2022
83813a7
correct i18n error
paul-tavares Oct 20, 2022
6e2e99f
fix get-file failing test
paul-tavares Oct 20, 2022
216533e
Merge remote-tracking branch 'upstream/main' into task/olm-4662-get-f…
paul-tavares Oct 20, 2022
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
52 changes: 51 additions & 1 deletion x-pack/plugins/files/server/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
*/
import { KibanaRequest } from '@kbn/core/server';
import { DeeplyMockedKeys } from '@kbn/utility-types-jest';
import { FileServiceFactory, FileServiceStart } from '.';
import * as stream from 'stream';
import { File } from '../common';
import { FileClient, FileServiceFactory, FileServiceStart } from '.';

export const createFileServiceMock = (): DeeplyMockedKeys<FileServiceStart> => ({
create: jest.fn(),
Expand All @@ -26,3 +28,51 @@ export const createFileServiceFactoryMock = (): DeeplyMockedKeys<FileServiceFact
asInternal: jest.fn(createFileServiceMock),
asScoped: jest.fn((_: KibanaRequest) => createFileServiceMock()),
});

export const createFileMock = (): DeeplyMockedKeys<File> => {
const fileMock: DeeplyMockedKeys<File> = {
id: '123',
data: {
id: '123',
created: '2022-10-10T14:57:30.682Z',
updated: '2022-10-19T14:43:20.112Z',
name: 'test.txt',
mimeType: 'text/plain',
size: 1234,
extension: '.txt',
meta: {},
alt: undefined,
fileKind: 'none',
status: 'READY',
},
update: jest.fn(),
uploadContent: jest.fn(),
downloadContent: jest.fn().mockResolvedValue(new stream.Readable()),
delete: jest.fn(),
share: jest.fn(),
listShares: jest.fn(),
unshare: jest.fn(),
toJSON: jest.fn(),
};

fileMock.update.mockResolvedValue(fileMock);
fileMock.uploadContent.mockResolvedValue(fileMock);

return fileMock;
};

export const createFileClientMock = (): DeeplyMockedKeys<FileClient> => {
const fileMock = createFileMock();

return {
fileKind: 'none',
create: jest.fn().mockResolvedValue(fileMock),
get: jest.fn().mockResolvedValue(fileMock),
update: jest.fn(),
delete: jest.fn(),
find: jest.fn().mockResolvedValue({ files: [fileMock], total: 1 }),
share: jest.fn(),
unshare: jest.fn(),
listShares: jest.fn().mockResolvedValue({ shares: [] }),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const GET_FILE_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/get_file`;
export const ENDPOINT_ACTION_LOG_ROUTE = `${BASE_ENDPOINT_ROUTE}/action_log/{agent_id}`;
export const ACTION_STATUS_ROUTE = `${BASE_ENDPOINT_ROUTE}/action_status`;
export const ACTION_DETAILS_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{action_id}`;
export const ACTION_AGENT_FILE_DOWNLOAD_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{action_id}/{agent_id}/file/download`;
export const ENDPOINTS_ACTION_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/action`;

export const failedFleetActionErrorCode = '424';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,15 @@ export const EndpointActionGetFileSchema = {
};

export type ResponseActionGetFileRequestBody = TypeOf<typeof EndpointActionGetFileSchema.body>;

/** Schema that validates the file download API */
export const EndpointActionFileDownloadSchema = {
params: schema.object({
action_id: schema.string({ minLength: 1 }),
agent_id: schema.string({ minLength: 1 }),
}),
};

export type EndpointActionFileDownloadParams = TypeOf<
typeof EndpointActionFileDownloadSchema.params
>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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 type { ActionDetails } from '../../types';

/**
* Constructs a file ID for a given agent.
* @param action
* @param agentId
*/
export const getFileDownloadId = (action: ActionDetails, agentId?: string): string => {
const { id: actionId, agents } = action;

if (agentId && !agents.includes(agentId)) {
throw new Error(`Action [${actionId}] was not sent to agent id [${agentId}]`);
}

return `${actionId}.${agentId ?? agents[0]}`;
};
3 changes: 2 additions & 1 deletion x-pack/plugins/security_solution/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"timelines",
"triggersActionsUi",
"uiActions",
"unifiedSearch"
"unifiedSearch",
"files"
],
"optionalPlugins": [
"cloudExperiments",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,25 @@ describe('when using parsed command input utils', () => {
})
);
});

it.each([
[String.raw`C:\Foo\Dir\whatever.jpg`, undefined],
[String.raw`C:\\abc`, undefined],
[String.raw`F:\foo\bar.docx`, undefined],
[String.raw`C:/foo/bar.docx`, undefined],
[String.raw`C:\\\//\/\\/\\\/abc/\/\/\///def.txt`, undefined],
[String.raw`C:\abc~!@#$%^&*()_'+`, undefined],
[String.raw`C:foobar`, undefined],
[String.raw`C:\dir with spaces\foo.txt`, undefined],
[String.raw`C:\dir\file with spaces.txt`, undefined],
[String.raw`/tmp/linux file with spaces "and quotes" omg.txt`, undefined],
['c\\foo\\b\\-\\-ar.txt', String.raw`c\foo\b--ar.txt`],
['c:\\foo\\b \\-\\-ar.txt', String.raw`c:\foo\b --ar.txt`],
])('should preserve backslashes in argument values: %s', (path, expected) => {
const input = `foo --path "${path}"`;
const parsedCommand = parseCommandInput(input);

expect(parsedCommand.args).toEqual({ path: [expected ?? path] });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const parseInputString = (rawInput: string): ParsedCommandInput => {
let newArgValue = argNameAndValueTrimmedString
.substring(firstSpaceOrEqualSign.index + 1)
.trim()
.replace(/\\/g, '');
.replace(/\\-\\-/g, '--');

if (newArgValue.charAt(0) === '"') {
newArgValue = newArgValue.substring(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,10 @@ export const getEndpointResponseActionsConsoleCommands = ({
capabilities: endpointCapabilities,
privileges: endpointPrivileges,
},
exampleUsage: 'get-file path="/full/path/to/file.txt"',
exampleUsage: 'get-file path "/full/path/to/file.txt" --comment "Possible malware"',
exampleInstruction: ENTER_OR_ADD_COMMENT_ARG_INSTRUCTION,
validate: capabilitiesAndPrivilegesValidator,
mustHaveArgs: true,
args: {
path: {
required: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
* 2.0.
*/

import { memo, useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useSendGetFileRequest } from '../../hooks/endpoint/use_send_get_file_request';
import type { ResponseActionGetFileRequestBody } from '../../../../common/endpoint/schema/actions';
import { useConsoleActionSubmitter } from './hooks/use_console_action_submitter';
import type { ActionRequestComponentProps } from './types';
import { ResponseActionFileDownloadLink } from '../response_action_file_download_link';

export const GetFileActionResult = memo<
ActionRequestComponentProps<{
Expand All @@ -33,7 +35,7 @@ export const GetFileActionResult = memo<
: undefined;
}, [command.args.args, command.commandDefinition?.meta?.endpointId]);

return useConsoleActionSubmitter<ResponseActionGetFileRequestBody>({
const { result, actionDetails } = useConsoleActionSubmitter<ResponseActionGetFileRequestBody>({
ResultComponent,
setStore,
store,
Expand All @@ -42,8 +44,26 @@ export const GetFileActionResult = memo<
actionCreator,
actionRequestBody,
dataTestSubj: 'getFile',
}).result;
pendingMessage: i18n.translate('xpack.securitySolution.getFileAction.pendingMessage', {
defaultMessage: 'Retrieving the file from host.',
}),
});

// FIXME:PT implement success UI output once we have download API
if (actionDetails?.isCompleted && actionDetails.wasSuccessful) {
return (
<ResultComponent
showAs="success"
data-test-subj="getFileSuccess"
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.getFileAction.successTitle',
{ defaultMessage: 'File retrieved from the host.' }
)}
>
<ResponseActionFileDownloadLink action={actionDetails} />
</ResultComponent>
);
}

return result;
});
GetFileActionResult.displayName = 'GetFileActionResult';
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ export interface UseConsoleActionSubmitterOptions<
actionRequestBody: TReqBody | undefined;

dataTestSubj?: string;

/** */
pendingMessage?: string;

successMessage?: string;
}

/**
Expand All @@ -78,6 +83,8 @@ export interface UseConsoleActionSubmitterOptions<
* @param store
* @param ResultComponent
* @param dataTestSubj
* @param pendingMessage
* @param successMessage
*/
export const useConsoleActionSubmitter = <
TReqBody extends BaseActionRequestBody = BaseActionRequestBody,
Expand All @@ -91,6 +98,8 @@ export const useConsoleActionSubmitter = <
store,
ResultComponent,
dataTestSubj,
pendingMessage,
successMessage,
}: UseConsoleActionSubmitterOptions<
TReqBody,
TActionOutputContent
Expand Down Expand Up @@ -237,7 +246,11 @@ export const useConsoleActionSubmitter = <
// Calculate the action's UI result based on the different API responses
const result = useMemo(() => {
if (isPending) {
return <ResultComponent showAs="pending" data-test-subj={getTestId('pending')} />;
return (
<ResultComponent showAs="pending" data-test-subj={getTestId('pending')}>
{pendingMessage}
</ResultComponent>
);
}

const apiError = actionRequestError || actionDetailsError;
Expand All @@ -246,7 +259,7 @@ export const useConsoleActionSubmitter = <
return (
<ResultComponent showAs="failure" data-test-subj={getTestId('apiFailure')}>
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.killProcess.performApiErrorMessage"
id="xpack.securitySolution.endpointResponseActions.actionSubmitter.apiErrorDetails"
defaultMessage="The following error was encountered:"
/>
<FormattedError error={apiError} data-test-subj={getTestId('apiErrorDetails')} />
Expand All @@ -271,6 +284,7 @@ export const useConsoleActionSubmitter = <
ResultComponent={ResultComponent}
action={actionDetails}
data-test-subj={getTestId('success')}
title={successMessage}
/>
);
}
Expand All @@ -283,6 +297,8 @@ export const useConsoleActionSubmitter = <
actionDetails,
ResultComponent,
getTestId,
pendingMessage,
successMessage,
]);

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import { getEndpointAuthzInitialStateMock } from '../../../../../common/endpoint
import type { EndpointPrivileges } from '../../../../../common/endpoint/types';
import { INSUFFICIENT_PRIVILEGES_FOR_COMMAND } from '../../../../common/translations';

describe('When using get-file aciton from response actions console', () => {
jest.mock('../../../../common/components/user_privileges');

describe('When using get-file action from response actions console', () => {
let render: (
capabilities?: EndpointCapabilities[]
) => Promise<ReturnType<AppContextTestRender['render']>>;
Expand Down Expand Up @@ -124,4 +126,17 @@ describe('When using get-file aciton from response actions console', () => {
'Argument can only be used once: --comment'
);
});

it('should display download link once action completes', async () => {
await render();
enterConsoleCommand(renderResult, 'get-file --path="one/two"');

await waitFor(() => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalled();
});

expect(renderResult.getByTestId('getFileSuccess').textContent).toEqual(
'File retrieved from the host.Click here to download(ZIP file passcode: elastic)'
);
});
});
Original file line number Diff line number Diff line change
@@ -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 { ResponseActionFileDownloadLink } from './response_action_file_download_link';
Loading