diff --git a/x-pack/plugins/files/server/mocks.ts b/x-pack/plugins/files/server/mocks.ts index 033e94c3bfd7f..de9a495818ff2 100644 --- a/x-pack/plugins/files/server/mocks.ts +++ b/x-pack/plugins/files/server/mocks.ts @@ -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 => ({ create: jest.fn(), @@ -26,3 +28,51 @@ export const createFileServiceFactoryMock = (): DeeplyMockedKeys createFileServiceMock()), }); + +export const createFileMock = (): DeeplyMockedKeys => { + const fileMock: DeeplyMockedKeys = { + 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 => { + 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: [] }), + }; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 8061057280eea..3aa4fe007a959 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 1743b50ffecd0..cb9a1d98eb326 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -127,3 +127,15 @@ export const EndpointActionGetFileSchema = { }; export type ResponseActionGetFileRequestBody = TypeOf; + +/** 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 +>; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts new file mode 100644 index 0000000000000..12d74207c57b9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts @@ -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]}`; +}; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 4dce859a3efd6..4a6e3a105ee72 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -31,7 +31,8 @@ "timelines", "triggersActionsUi", "uiActions", - "unifiedSearch" + "unifiedSearch", + "files" ], "optionalPlugins": [ "cloudExperiments", diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parse_command_input.test.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parse_command_input.test.ts index 1d0917d1a0959..a4d3a983041fd 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/service/parse_command_input.test.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/service/parse_command_input.test.ts @@ -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] }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts index 76866f41955e3..78ab197ebd227 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts @@ -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); diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts index 0a5a32bbdfebc..5269306424a84 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/endpoint_response_actions_console_commands.ts @@ -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, diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_file_action.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_file_action.tsx index 1f8cb4de72717..d0f090e6595c6 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_file_action.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/get_file_action.tsx @@ -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<{ @@ -33,7 +35,7 @@ export const GetFileActionResult = memo< : undefined; }, [command.args.args, command.commandDefinition?.meta?.endpointId]); - return useConsoleActionSubmitter({ + const { result, actionDetails } = useConsoleActionSubmitter({ ResultComponent, setStore, store, @@ -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 ( + + + + ); + } + + return result; }); GetFileActionResult.displayName = 'GetFileActionResult'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.tsx index 7183b5cc61ef7..98f8954f0dd65 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/hooks/use_console_action_submitter.tsx @@ -63,6 +63,11 @@ export interface UseConsoleActionSubmitterOptions< actionRequestBody: TReqBody | undefined; dataTestSubj?: string; + + /** */ + pendingMessage?: string; + + successMessage?: string; } /** @@ -78,6 +83,8 @@ export interface UseConsoleActionSubmitterOptions< * @param store * @param ResultComponent * @param dataTestSubj + * @param pendingMessage + * @param successMessage */ export const useConsoleActionSubmitter = < TReqBody extends BaseActionRequestBody = BaseActionRequestBody, @@ -91,6 +98,8 @@ export const useConsoleActionSubmitter = < store, ResultComponent, dataTestSubj, + pendingMessage, + successMessage, }: UseConsoleActionSubmitterOptions< TReqBody, TActionOutputContent @@ -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 ; + return ( + + {pendingMessage} + + ); } const apiError = actionRequestError || actionDetailsError; @@ -246,7 +259,7 @@ export const useConsoleActionSubmitter = < return ( @@ -271,6 +284,7 @@ export const useConsoleActionSubmitter = < ResultComponent={ResultComponent} action={actionDetails} data-test-subj={getTestId('success')} + title={successMessage} /> ); } @@ -283,6 +297,8 @@ export const useConsoleActionSubmitter = < actionDetails, ResultComponent, getTestId, + pendingMessage, + successMessage, ]); return { diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/integration_tests/get_file_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/integration_tests/get_file_action.test.tsx index 01b50dc759a8b..2f8b84c81e038 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/integration_tests/get_file_action.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/integration_tests/get_file_action.test.tsx @@ -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>; @@ -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)' + ); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/index.ts b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/index.ts new file mode 100644 index 0000000000000..35a781976e565 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/index.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx new file mode 100644 index 0000000000000..6c01b2284e997 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx @@ -0,0 +1,78 @@ +/* + * 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 { CSSProperties } from 'react'; +import React, { memo } from 'react'; +import { EuiButtonEmpty, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import type { MaybeImmutable } from '../../../../common/endpoint/types'; +import { getHostActionFileDownloadUrl } from '../../services/response_actions/get_host_action_file_download_url'; +import type { ActionDetails } from '../../../../common/endpoint/types/actions'; + +const STYLE_INHERIT_FONT_FAMILY = Object.freeze({ + fontFamily: 'inherit', +}); + +const DEFAULT_BUTTON_TITLE = i18n.translate( + 'xpack.securitySolution.responseActionFileDownloadLink.downloadButtonLabel', + { defaultMessage: 'Click here to download' } +); + +export interface ResponseActionFileDownloadLinkProps { + action: MaybeImmutable; + buttonTitle?: string; + 'data-test-subj'?: string; +} + +/** + * Displays the download link for a file retrieved via a Response Action. The download link + * button will only be displayed if the user has authorization to use file operations. + * + * NOTE: Currently displays only the link for the first host in the Action + */ +export const ResponseActionFileDownloadLink = memo( + ({ action, buttonTitle = DEFAULT_BUTTON_TITLE, 'data-test-subj': dataTestSubj }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + const { canWriteFileOperations } = useUserPrivileges().endpointPrivileges; + + if (!canWriteFileOperations) { + return null; + } + + return ( + <> + + {buttonTitle} + + + + + + ); + } +); +ResponseActionFileDownloadLink.displayName = 'ResponseActionFileDownloadLink'; diff --git a/x-pack/plugins/security_solution/public/management/services/response_actions/get_host_action_file_download_url.ts b/x-pack/plugins/security_solution/public/management/services/response_actions/get_host_action_file_download_url.ts new file mode 100644 index 0000000000000..5061c6cd36457 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/services/response_actions/get_host_action_file_download_url.ts @@ -0,0 +1,25 @@ +/* + * 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 { resolvePathVariables } from '../../../common/utils/resolve_path_variables'; +import { ACTION_AGENT_FILE_DOWNLOAD_ROUTE } from '../../../../common/endpoint/constants'; +import type { ActionDetails, MaybeImmutable } from '../../../../common/endpoint/types'; + +/** + * get the download URL for a `get-file` action + * @param action + * @param agentId + */ +export const getHostActionFileDownloadUrl = ( + action: MaybeImmutable, + agentId?: string +): string => { + return resolvePathVariables(ACTION_AGENT_FILE_DOWNLOAD_ROUTE, { + action_id: action.id, + agent_id: agentId ?? action.agents[0], + }); +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts index 8a1aa4050d07b..8daeae3a28767 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/agent_emulator/services/endpoint_response_actions.ts @@ -8,6 +8,8 @@ import type { KbnClient } from '@kbn/test'; import type { Client } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; +import * as cborx from 'cbor-x'; +import { getFileDownloadId } from '../../../../common/endpoint/service/response_actions/get_file_download_id'; import type { UploadedFile } from '../../../../common/endpoint/types/file_storage'; import { checkInFleetAgent } from '../../common/fleet_services'; import { sendEndpointMetadataUpdate } from '../../common/endpoint_metadata_services'; @@ -182,7 +184,7 @@ export const sendEndpointActionResponse = async ( // Index the file's metadata const fileMeta = await esClient.index({ index: FILE_STORAGE_METADATA_INDEX, - id: `${action.id}.${action.hosts[0]}`, + id: getFileDownloadId(action, action.agents[0]), body: { file: { created: new Date().toISOString(), @@ -200,16 +202,29 @@ export const sendEndpointActionResponse = async ( }); // Index the file content (just one chunk) - await esClient.index({ - index: FILE_STORAGE_DATA_INDEX, - id: `${fileMeta._id}.0`, - body: { - bid: fileMeta._id, - last: true, - data: 'UEsDBBQACAAIAFVeRFUAAAAAAAAAABMAAAAMACAAYmFkX2ZpbGUudHh0VVQNAAdTVjxjU1Y8Y1NWPGN1eAsAAQT1AQAABBQAAAArycgsVgCiRIWkxBSFtMycVC4AUEsHCKkCwMsTAAAAEwAAAFBLAQIUAxQACAAIAFVeRFWpAsDLEwAAABMAAAAMACAAAAAAAAAAAACkgQAAAABiYWRfZmlsZS50eHRVVA0AB1NWPGNTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFoAAABtAAAAAAA=', + // call to `.index()` copied from File plugin here: + // https://github.com/elastic/kibana/blob/main/x-pack/plugins/files/server/blob_storage_service/adapters/es/content_stream/content_stream.ts#L195 + await esClient.index( + { + index: FILE_STORAGE_DATA_INDEX, + id: `${fileMeta._id}.0`, + document: cborx.encode({ + bid: fileMeta._id, + last: true, + data: Buffer.from( + 'UEsDBAoACQAAAFZeRFWpAsDLHwAAABMAAAAMABwAYmFkX2ZpbGUudHh0VVQJAANTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAMOcoyEq/Q4VyG02U9O0LRbGlwP/y5SOCfRKqLz1rsBQSwcIqQLAyx8AAAATAAAAUEsBAh4DCgAJAAAAVl5EVakCwMsfAAAAEwAAAAwAGAAAAAAAAQAAAKSBAAAAAGJhZF9maWxlLnR4dFVUBQADU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFIAAAB1AAAAAAA=', + 'base64' + ), + }), + refresh: 'wait_for', }, - refresh: 'wait_for', - }); + { + headers: { + 'content-type': 'application/cbor', + accept: 'application/json', + }, + } + ); } return endpointResponse; diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 7639e73b0a36d..7a0d7fa1950fe 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -5,10 +5,25 @@ * 2.0. */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import type { AwaitedProperties } from '@kbn/utility-types'; import type { ScopedClusterClientMock } from '@kbn/core/server/mocks'; -import { loggingSystemMock, savedObjectsServiceMock } from '@kbn/core/server/mocks'; -import type { SavedObjectsClientContract } from '@kbn/core/server'; +import { + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, + savedObjectsServiceMock, +} from '@kbn/core/server/mocks'; +import type { + KibanaRequest, + RouteConfig, + SavedObjectsClientContract, + RequestHandler, + IRouter, +} from '@kbn/core/server'; import { listMock } from '@kbn/lists-plugin/server/mocks'; import { securityMock } from '@kbn/security-plugin/server/mocks'; import { alertsMock } from '@kbn/alerting-plugin/server/mocks'; @@ -25,6 +40,8 @@ import { // a restricted path. import { createCasesClientMock } from '@kbn/cases-plugin/server/client/mocks'; import { createFleetAuthzMock } from '@kbn/fleet-plugin/common'; +import type { RequestFixtureOptions } from '@kbn/core-http-router-server-mocks'; +import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { xpackMocks } from '../fixtures'; import { createMockConfig, requestContextMock } from '../lib/detection_engine/routes/__mocks__'; import type { @@ -192,3 +209,77 @@ export function createRouteHandlerContext( context.core.savedObjects.client = savedObjectsClient; return context; } + +export interface HttpApiTestSetupMock

{ + routerMock: ReturnType; + scopedEsClusterClientMock: ReturnType; + savedObjectClientMock: ReturnType; + endpointAppContextMock: EndpointAppContext; + httpResponseMock: ReturnType; + httpHandlerContextMock: ReturnType; + getEsClientMock: (type?: 'internalUser' | 'currentUser') => ElasticsearchClientMock; + createRequestMock: (options?: RequestFixtureOptions) => KibanaRequest; + /** Retrieves the handler that was registered with the `router` for a given `method` and `path` */ + getRegisteredRouteHandler: ( + method: keyof Pick, + path: string + ) => RequestHandler; +} + +/** + * Returns all of the setup needed to test an HTTP api handler + */ +export const createHttpApiTestSetupMock =

(): HttpApiTestSetupMock< + P, + Q, + B +> => { + const routerMock = httpServiceMock.createRouter(); + const endpointAppContextMock = createMockEndpointAppContext(); + const scopedEsClusterClientMock = elasticsearchServiceMock.createScopedClusterClient(); + const savedObjectClientMock = savedObjectsClientMock.create(); + const httpHandlerContextMock = requestContextMock.convertContext( + createRouteHandlerContext(scopedEsClusterClientMock, savedObjectClientMock) + ); + const httpResponseMock = httpServerMock.createResponseFactory(); + const getRegisteredRouteHandler: HttpApiTestSetupMock['getRegisteredRouteHandler'] = ( + method, + path + ): RequestHandler => { + const methodCalls = routerMock[method].mock.calls as Array< + [route: RouteConfig, handler: RequestHandler] + >; + const handler = methodCalls.find(([routeConfig]) => routeConfig.path.startsWith(path)); + + if (!handler) { + throw new Error(`Handler for [${method}][${path}] not found`); + } + + return handler[1]; + }; + + return { + routerMock, + + endpointAppContextMock, + scopedEsClusterClientMock, + savedObjectClientMock, + + httpHandlerContextMock, + httpResponseMock, + + createRequestMock: (options: RequestFixtureOptions = {}): KibanaRequest => { + return httpServerMock.createKibanaRequest(options); + }, + + getEsClientMock: ( + type: 'internalUser' | 'currentUser' = 'internalUser' + ): ElasticsearchClientMock => { + return type === 'currentUser' + ? scopedEsClusterClientMock.asCurrentUser + : scopedEsClusterClientMock.asInternalUser; + }, + + getRegisteredRouteHandler, + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.test.ts new file mode 100644 index 0000000000000..5711baac65f54 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.test.ts @@ -0,0 +1,147 @@ +/* + * 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 { + getActionFileDownloadRouteHandler, + registerActionFileDownloadRoutes, +} from './file_download_handler'; +import type { HttpApiTestSetupMock } from '../../mocks'; +import { createHttpApiTestSetupMock } from '../../mocks'; +import type { EndpointActionFileDownloadParams } from '../../../../common/endpoint/schema/actions'; +import { getActionDetailsById as _getActionDetailsById } from '../../services'; +import { EndpointAuthorizationError, NotFoundError } from '../../errors'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; +import { getFileDownloadStream as _getFileDownloadStream } from '../../services/actions/action_files'; +import stream from 'stream'; +import type { ActionDetails } from '../../../../common/endpoint/types'; +import { ACTION_AGENT_FILE_DOWNLOAD_ROUTE } from '../../../../common/endpoint/constants'; + +jest.mock('../../services'); +jest.mock('../../services/actions/action_files'); + +describe('Response Actions file download API', () => { + const getActionDetailsById = _getActionDetailsById as jest.Mock; + const getFileDownloadStream = _getFileDownloadStream as jest.Mock; + + let apiTestSetup: HttpApiTestSetupMock; + let httpRequestMock: ReturnType< + HttpApiTestSetupMock['createRequestMock'] + >; + let httpHandlerContextMock: HttpApiTestSetupMock['httpHandlerContextMock']; + let httpResponseMock: HttpApiTestSetupMock['httpResponseMock']; + + beforeEach(() => { + apiTestSetup = createHttpApiTestSetupMock(); + + ({ httpHandlerContextMock, httpResponseMock } = apiTestSetup); + httpRequestMock = apiTestSetup.createRequestMock({ + params: { action_id: '111', agent_id: '222' }, + }); + }); + + describe('#registerActionFileDownloadRoutes()', () => { + beforeEach(() => { + registerActionFileDownloadRoutes( + apiTestSetup.routerMock, + apiTestSetup.endpointAppContextMock + ); + }); + + it('should register the route', () => { + expect( + apiTestSetup.getRegisteredRouteHandler('get', ACTION_AGENT_FILE_DOWNLOAD_ROUTE) + ).toBeDefined(); + }); + + it('should error if user has no authz to api', async () => { + const authz = (await httpHandlerContextMock.securitySolution).endpointAuthz; + authz.canWriteFileOperations = false; + + await apiTestSetup.getRegisteredRouteHandler('get', ACTION_AGENT_FILE_DOWNLOAD_ROUTE)( + httpHandlerContextMock, + httpRequestMock, + httpResponseMock + ); + + expect(httpResponseMock.forbidden).toHaveBeenCalledWith({ + body: expect.any(EndpointAuthorizationError), + }); + }); + }); + + describe('Route handler', () => { + let fileDownloadHandler: ReturnType; + let esClientMock: ReturnType; + let action: ActionDetails; + + beforeEach(() => { + esClientMock = apiTestSetup.getEsClientMock(); + action = new EndpointActionGenerator().generateActionDetails({ + id: '111', + agents: ['222'], + }); + fileDownloadHandler = getActionFileDownloadRouteHandler(apiTestSetup.endpointAppContextMock); + + getActionDetailsById.mockImplementation(async () => { + return action; + }); + + getFileDownloadStream.mockImplementation(async () => { + return { + stream: new stream.Readable(), + fileName: 'test.txt', + mimeType: 'text/plain', + }; + }); + }); + + it('should error if action ID is invalid', async () => { + getActionDetailsById.mockImplementationOnce(async () => { + throw new NotFoundError('not found'); + }); + await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); + + expect(httpResponseMock.notFound).toHaveBeenCalled(); + }); + + it('should error if agent id is not in the action', async () => { + action.agents = ['333']; + await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); + + expect(httpResponseMock.customError).toHaveBeenCalledWith({ + statusCode: 400, + body: expect.any(CustomHttpRequestError), + }); + }); + + it('should retrieve the download Stream using correct file ID', async () => { + await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); + + expect(getFileDownloadStream).toHaveBeenCalledWith( + esClientMock, + expect.anything(), + '111.222' + ); + }); + + it('should respond with expected HTTP headers', async () => { + await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); + + expect(httpResponseMock.ok).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + 'cache-control': 'max-age=31536000, immutable', + 'content-disposition': 'attachment; filename="test.txt"', + 'content-type': 'application/octet-stream', + 'x-content-type-options': 'nosniff', + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts new file mode 100644 index 0000000000000..304c92e6d0184 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts @@ -0,0 +1,85 @@ +/* + * 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 { RequestHandler } from '@kbn/core/server'; +import { getFileDownloadId } from '../../../../common/endpoint/service/response_actions/get_file_download_id'; +import { getActionDetailsById } from '../../services'; +import { errorHandler } from '../error_handler'; +import { ACTION_AGENT_FILE_DOWNLOAD_ROUTE } from '../../../../common/endpoint/constants'; +import type { EndpointActionFileDownloadParams } from '../../../../common/endpoint/schema/actions'; +import { EndpointActionFileDownloadSchema } from '../../../../common/endpoint/schema/actions'; +import { withEndpointAuthz } from '../with_endpoint_authz'; +import type { EndpointAppContext } from '../../types'; +import type { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; +import { getFileDownloadStream } from '../../services/actions/action_files'; + +export const registerActionFileDownloadRoutes = ( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) => { + const logger = endpointContext.logFactory.get('actionFileDownload'); + + router.get( + { + path: ACTION_AGENT_FILE_DOWNLOAD_ROUTE, + validate: EndpointActionFileDownloadSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + withEndpointAuthz( + { all: ['canWriteFileOperations'] }, + logger, + getActionFileDownloadRouteHandler(endpointContext) + ) + ); +}; + +export const getActionFileDownloadRouteHandler = ( + endpointContext: EndpointAppContext +): RequestHandler< + EndpointActionFileDownloadParams, + unknown, + unknown, + SecuritySolutionRequestHandlerContext +> => { + const logger = endpointContext.logFactory.get('actionFileDownload'); + + return async (context, req, res) => { + const { action_id: actionId, agent_id: agentId } = req.params; + const esClient = (await context.core).elasticsearch.client.asInternalUser; + const endpointMetadataService = endpointContext.service.getEndpointMetadataService(); + + try { + // Ensure action id is valid and that it was sent to the Agent ID requested. + const actionDetails = await getActionDetailsById(esClient, endpointMetadataService, actionId); + + if (!actionDetails.agents.includes(agentId)) { + throw new CustomHttpRequestError(`Action was not sent to agent id [${agentId}]`, 400); + } + + const fileDownloadId = getFileDownloadId(actionDetails, agentId); + const { stream, fileName } = await getFileDownloadStream(esClient, logger, fileDownloadId); + + return res.ok({ + body: stream, + headers: { + 'content-type': 'application/octet-stream', + 'cache-control': 'max-age=31536000, immutable', + // Note, this name can be overridden by the client if set via a "download" attribute on the HTML tag. + 'content-disposition': `attachment; filename="${fileName ?? 'download.zip'}"`, + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + 'x-content-type-options': 'nosniff', + }, + }); + } catch (error) { + return errorHandler(logger, res, error); + } + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index d947cfefa9a2a..a801360772b28 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { registerActionFileDownloadRoutes } from './file_download_handler'; import { registerActionDetailsRoutes } from './details'; import type { SecuritySolutionPluginRouter } from '../../../types'; import type { EndpointAppContext } from '../../types'; @@ -23,5 +24,6 @@ export function registerActionRoutes( registerActionAuditLogRoutes(router, endpointContext); registerActionListRoutes(router, endpointContext); registerActionDetailsRoutes(router, endpointContext); + registerActionFileDownloadRoutes(router, endpointContext); registerResponseActionRoutes(router, endpointContext); } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.test.ts new file mode 100644 index 0000000000000..288c8ba043693 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { ElasticsearchClientMock } from '@kbn/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import type { Logger } from '@kbn/core/server'; +import { createEsFileClient as _createEsFileClient } from '@kbn/files-plugin/server'; +import { createFileClientMock } from '@kbn/files-plugin/server/mocks'; +import { getFileDownloadStream } from './action_files'; +import type { DiagnosticResult } from '@elastic/elasticsearch'; +import { errors } from '@elastic/elasticsearch'; +import { NotFoundError } from '../../errors'; + +jest.mock('@kbn/files-plugin/server'); +const createEsFileClient = _createEsFileClient as jest.Mock; + +describe('Action Files service', () => { + describe('#getFileDownloadStream()', () => { + let loggerMock: Logger; + let esClientMock: ElasticsearchClientMock; + let fileClientMock: ReturnType; + + beforeEach(() => { + loggerMock = loggingSystemMock.create().get('mock'); + esClientMock = elasticsearchServiceMock.createElasticsearchClient(); + fileClientMock = createFileClientMock(); + createEsFileClient.mockReturnValue(fileClientMock); + }); + + it('should return expected output', async () => { + await expect(getFileDownloadStream(esClientMock, loggerMock, '123')).resolves.toEqual({ + stream: expect.anything(), + fileName: 'test.txt', + mimeType: 'text/plain', + }); + }); + + it('should return NotFoundError if file or index is not found', async () => { + fileClientMock.get.mockRejectedValue( + new errors.ResponseError({ + statusCode: 404, + } as DiagnosticResult) + ); + + await expect(getFileDownloadStream(esClientMock, loggerMock, '123')).rejects.toBeInstanceOf( + NotFoundError + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts new file mode 100644 index 0000000000000..5db82681c3572 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_files.ts @@ -0,0 +1,59 @@ +/* + * 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 { ElasticsearchClient, Logger } from '@kbn/core/server'; +import type { Readable } from 'stream'; +import { createEsFileClient } from '@kbn/files-plugin/server'; +import { errors } from '@elastic/elasticsearch'; +import { NotFoundError } from '../../errors'; +import { + FILE_STORAGE_DATA_INDEX, + FILE_STORAGE_METADATA_INDEX, +} from '../../../../common/endpoint/constants'; +import { EndpointError } from '../../../../common/endpoint/errors'; + +/** + * Returns a NodeJS `Readable` data stream to a file + * @param esClient + * @param logger + * @param fileId + */ +export const getFileDownloadStream = async ( + esClient: ElasticsearchClient, + logger: Logger, + fileId: string +): Promise<{ stream: Readable; fileName: string; mimeType?: string }> => { + const fileClient = createEsFileClient({ + metadataIndex: FILE_STORAGE_METADATA_INDEX, + blobStorageIndex: FILE_STORAGE_DATA_INDEX, + elasticsearchClient: esClient, + logger, + }); + + try { + const file = await fileClient.get({ id: fileId }); + const { name: fileName, mimeType } = file.data; + + return { + stream: await file.downloadContent(), + fileName, + mimeType, + }; + } catch (error) { + if (error instanceof errors.ResponseError) { + const statusCode = error.statusCode; + + // 404 will be returned if file id is not found -or- index does not exist yet. + // Using the `NotFoundError` error class will result in the API returning a 404 + if (statusCode === 404) { + throw new NotFoundError(`File with id [${fileId}] not found`, error); + } + } + + throw new EndpointError(`Failed to get file using id [${fileId}]: ${error.message}`, error); + } +}; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 76e347d7a0b17..f69973fdac74f 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -47,6 +47,7 @@ { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, { "path": "../threat_intelligence/tsconfig.json" }, - { "path": "../timelines/tsconfig.json" } + { "path": "../timelines/tsconfig.json" }, + { "path": "../files/tsconfig.json"} ] }