diff --git a/x-pack/platform/plugins/shared/stack_connectors/common/crowdstrike/schema.ts b/x-pack/platform/plugins/shared/stack_connectors/common/crowdstrike/schema.ts index 40e58613c69e0..0dfa5db068d1c 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/common/crowdstrike/schema.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/common/crowdstrike/schema.ts @@ -348,5 +348,45 @@ export const CrowdstrikeExecuteRTRResponseSchema = schema.object( { unknowns: 'allow' } ); -// TODO: will be part of a next PR -export const CrowdstrikeGetScriptsParamsSchema = schema.any({}); +export const CrowdstrikeGetScriptsResponseSchema = schema.object( + { + meta: schema.maybe( + schema.object( + { + query_time: schema.maybe(schema.number()), + powered_by: schema.maybe(schema.string()), + trace_id: schema.maybe(schema.string()), + }, + { unknowns: 'allow' } + ) + ), + resources: schema.maybe( + schema.arrayOf( + schema.object( + { + content: schema.maybe(schema.string()), + created_by: schema.maybe(schema.string()), + created_by_uuid: schema.maybe(schema.string()), + created_timestamp: schema.maybe(schema.string()), + file_type: schema.maybe(schema.string()), + id: schema.maybe(schema.string()), + description: schema.maybe(schema.string()), + modified_by: schema.maybe(schema.string()), + modified_timestamp: schema.maybe(schema.string()), + name: schema.maybe(schema.string()), + permission_type: schema.maybe(schema.string()), + platform: schema.maybe(schema.arrayOf(schema.string())), + run_attempt_count: schema.maybe(schema.number()), + run_success_count: schema.maybe(schema.number()), + sha256: schema.maybe(schema.string()), + size: schema.maybe(schema.number()), + write_access: schema.maybe(schema.boolean()), + }, + { unknowns: 'allow' } + ) + ) + ), + errors: schema.maybe(schema.arrayOf(schema.any())), + }, + { unknowns: 'allow' } +); diff --git a/x-pack/platform/plugins/shared/stack_connectors/common/crowdstrike/types.ts b/x-pack/platform/plugins/shared/stack_connectors/common/crowdstrike/types.ts index b28cc8129578f..1021bec47fcc8 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/common/crowdstrike/types.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/common/crowdstrike/types.ts @@ -19,6 +19,7 @@ import type { RelaxedCrowdstrikeBaseApiResponseSchema, CrowdstrikeInitRTRParamsSchema, CrowdstrikeExecuteRTRResponseSchema, + CrowdstrikeGetScriptsResponseSchema, } from './schema'; export type CrowdstrikeConfig = TypeOf; @@ -42,3 +43,4 @@ export type CrowdstrikeActionParams = TypeOf; export type CrowdStrikeExecuteRTRResponse = TypeOf; +export type CrowdstrikeGetScriptsResponse = TypeOf; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts index 815e22de5259c..35b4bc04fc521 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/crowdstrike.test.ts @@ -458,10 +458,7 @@ describe('CrowdstrikeConnector', () => { mockedRequest.mockResolvedValueOnce({ data: { access_token: 'testToken' } }); mockedRequest.mockResolvedValueOnce(mockResponse); - const result = await connector.getRTRCloudScripts( - { ids: ['script1', 'script2'] }, - connectorUsageCollector - ); + const result = await connector.getRTRCloudScripts({}, connectorUsageCollector); expect(mockedRequest).toHaveBeenNthCalledWith( 1, diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts index 20481918dcd4c..c8b73321a6fe4 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/crowdstrike.ts @@ -25,8 +25,10 @@ import type { CrowdstrikeGetAgentOnlineStatusResponse, RelaxedCrowdstrikeBaseApiResponse, CrowdStrikeExecuteRTRResponse, + CrowdstrikeGetScriptsResponse, } from '../../../common/crowdstrike/types'; import type { CrowdstrikeGetTokenResponseSchema } from '../../../common/crowdstrike/schema'; +import { CrowdstrikeGetScriptsResponseSchema } from '../../../common/crowdstrike/schema'; import { CrowdstrikeHostActionsParamsSchema, CrowdstrikeGetAgentsParamsSchema, @@ -34,7 +36,6 @@ import { RelaxedCrowdstrikeBaseApiResponseSchema, CrowdstrikeRTRCommandParamsSchema, CrowdstrikeExecuteRTRResponseSchema, - CrowdstrikeGetScriptsParamsSchema, CrowdstrikeApiDoNotValidateResponsesSchema, } from '../../../common/crowdstrike/schema'; import { SUB_ACTION } from '../../../common/crowdstrike/constants'; @@ -76,7 +77,7 @@ export class CrowdstrikeConnector extends SubActionConnector< batchExecuteRTR: string; batchActiveResponderExecuteRTR: string; batchAdminExecuteRTR: string; - getRTRCloudScriptsDetails: string; + getRTRCloudScripts: string; }; constructor( @@ -95,7 +96,7 @@ export class CrowdstrikeConnector extends SubActionConnector< batchExecuteRTR: `${this.config.url}/real-time-response/combined/batch-command/v1`, batchActiveResponderExecuteRTR: `${this.config.url}/real-time-response/combined/batch-active-responder-command/v1`, batchAdminExecuteRTR: `${this.config.url}/real-time-response/combined/batch-admin-command/v1`, - getRTRCloudScriptsDetails: `${this.config.url}/real-time-response/entities/scripts/v1`, + getRTRCloudScripts: `${this.config.url}/real-time-response/entities/scripts/v1`, }; if (!CrowdstrikeConnector.base64encodedToken) { @@ -146,11 +147,10 @@ export class CrowdstrikeConnector extends SubActionConnector< method: 'batchAdminExecuteRTR', schema: CrowdstrikeRTRCommandParamsSchema, // Define a proper schema for the command }); - // temporary to fetch scripts and help testing this.registerSubAction({ name: SUB_ACTION.GET_RTR_CLOUD_SCRIPTS, method: 'getRTRCloudScripts', - schema: CrowdstrikeGetScriptsParamsSchema, + schema: CrowdstrikeRTRCommandParamsSchema, // Empty schema - this request do not have any parameters }); } } @@ -371,18 +371,16 @@ export class CrowdstrikeConnector extends SubActionConnector< ); } - // TODO: for now just for testing purposes, will be a part of a following PR public async getRTRCloudScripts( - payload: CrowdstrikeGetAgentsParams, + payload: {}, connectorUsageCollector: ConnectorUsageCollector - ): Promise { - // @ts-expect-error will be a part of the next PR - return this.crowdstrikeApiRequest( + ): Promise { + return await this.crowdstrikeApiRequest( { - url: this.urls.getRTRCloudScriptsDetails, + url: this.urls.getRTRCloudScripts, method: 'GET', paramsSerializer, - responseSchema: RelaxedCrowdstrikeBaseApiResponseSchema, + responseSchema: CrowdstrikeGetScriptsResponseSchema, }, connectorUsageCollector ); diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/custom_scripts/get_custom_scripts_route.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/custom_scripts/get_custom_scripts_route.ts new file mode 100644 index 0000000000000..83896dd034b44 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/custom_scripts/get_custom_scripts_route.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 { schema, type TypeOf } from '@kbn/config-schema'; +import { AgentTypeSchemaLiteral } from '..'; + +export const CustomScriptsRequestSchema = { + query: schema.object({ + agentType: schema.maybe( + schema.oneOf( + // @ts-expect-error TS2769: No overload matches this call + AgentTypeSchemaLiteral, + { + defaultValue: 'endpoint', + } + ) + ), + }), +}; + +export type CustomScriptsRequestQueryParams = TypeOf; diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/constants.ts index c08dc5b811f84..60a4f17e4a1a0 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/constants.ts @@ -97,6 +97,7 @@ export const EXECUTE_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/execute`; export const UPLOAD_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/upload`; export const SCAN_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/scan`; export const RUN_SCRIPT_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/run_script`; +export const CUSTOM_SCRIPTS_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/custom_scripts`; /** Endpoint Actions Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `${BASE_ENDPOINT_ROUTE}/action_log/{agent_id}`; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_script_selector.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_script_selector.test.tsx new file mode 100644 index 0000000000000..1d26d612e2efc --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_script_selector.test.tsx @@ -0,0 +1,239 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { CustomScriptSelector } from './custom_script_selector'; +import { useGetCustomScripts } from '../../hooks/custom_scripts/use_get_custom_scripts'; +import { useConsoleStateDispatch } from '../console/hooks/state_selectors/use_console_state_dispatch'; +import type { CommandArgumentValueSelectorProps } from '../console/types'; +import type { CustomScript } from '../../../../server/endpoint/services'; + +jest.mock('../../hooks/custom_scripts/use_get_custom_scripts'); +jest.mock('../console/hooks/state_selectors/use_console_state_dispatch'); + +// Mock setTimeout to execute immediately in tests +jest.useFakeTimers(); + +describe('CustomScriptSelector', () => { + const mockUseGetCustomScripts = useGetCustomScripts as jest.MockedFunction< + typeof useGetCustomScripts + >; + const mockUseConsoleStateDispatch = useConsoleStateDispatch as jest.MockedFunction< + typeof useConsoleStateDispatch + >; + const mockOnChange = jest.fn(); + const mockDispatch = jest.fn(); + const mockScripts: CustomScript[] = [ + { id: 'script1', name: 'Script 1', description: 'Test script 1' }, + { id: 'script2', name: 'Script 2', description: 'Test script 2' }, + ]; + + const defaultProps: CommandArgumentValueSelectorProps = { + value: undefined, + valueText: '', + argName: 'script', + argIndex: 0, + store: { isPopoverOpen: false }, + onChange: mockOnChange, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseGetCustomScripts.mockReturnValue({ + data: mockScripts, + isLoading: false, + isError: false, + error: null, + } as unknown as ReturnType); + + // Mock the dispatch function + mockUseConsoleStateDispatch.mockReturnValue(mockDispatch); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + const renderAndWaitForComponent = async (component: React.ReactElement) => { + const result = render(component); + // Fast-forward the timers to skip the delay + act(() => { + jest.advanceTimersByTime(10); + }); + // Wait for component to finish rendering + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + return result; + }; + + test('renders loading spinner when fetching data', () => { + mockUseGetCustomScripts.mockReturnValueOnce({ + data: undefined, + isLoading: true, + isError: false, + error: null, + } as unknown as ReturnType); + + const SelectorComponent = CustomScriptSelector('endpoint'); + render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + test('renders initial display label when no script is selected', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent(); + + expect(screen.getByText('Click to select script')).toBeInTheDocument(); + }); + + test('renders selected script name when a script is selected', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent( + + ); + + expect(screen.getByText('Script 1')).toBeInTheDocument(); + }); + + test('opens popover when clicked', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent(); + + // Click to open the popover + fireEvent.click(screen.getByText('Click to select script')); + + // Check that onChange was called with isPopoverOpen set to true + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + store: { isPopoverOpen: true }, + }) + ); + }); + + test('displays script options in the popover when open', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent( + + ); + + // Check that the searchbox is rendered + expect(screen.getByRole('searchbox', { name: 'Filter options' })).toBeInTheDocument(); + expect(screen.getByRole('listbox', { name: 'Filter options' })).toBeInTheDocument(); + }); + + test('calls onChange with selected script when user makes selection', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent( + + ); + + const searchbox = screen.getByRole('searchbox', { name: 'Filter options' }); + + // Click on the input to show options + act(() => { + fireEvent.click(searchbox); + }); + + // Find and click the first option + const option = screen.getByRole('option', { name: /Script 1/i }); + fireEvent.click(option); + + // Check that onChange was called with the selected script + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: 'Script 1', + valueText: 'Script 1', + store: { isPopoverOpen: false }, + }) + ); + }); + + test('closes popover after selection', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent( + + ); + + const searchbox = screen.getByRole('searchbox', { name: 'Filter options' }); + + // Click on the input to show options + fireEvent.click(searchbox); + + // Find and click the first option + const option = screen.getByRole('option', { name: /Script 1/i }); + fireEvent.click(option); + + // Check that onChange was called with isPopoverOpen set to false + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + store: { isPopoverOpen: false }, + }) + ); + }); + + test('calls useGetCustomScripts with correct agent type', async () => { + const SelectorComponent = CustomScriptSelector('crowdstrike'); + await renderAndWaitForComponent(); + + expect(mockUseGetCustomScripts).toHaveBeenCalledWith('crowdstrike'); + }); + + test('displays script description in dropdown', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent( + + ); + + // The descriptions should be contained within the option elements + expect(screen.getByText('Test script 1')).toBeInTheDocument(); + expect(screen.getByText('Test script 2')).toBeInTheDocument(); + }); + + test('displays the selected script name in the search box', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent( + + ); + + const searchbox = screen.getByRole('searchbox', { name: 'Filter options' }); + expect(searchbox).toHaveValue('Script 1'); + }); + + test('filters script options as the user types in the search box', async () => { + const SelectorComponent = CustomScriptSelector('endpoint'); + await renderAndWaitForComponent( + + ); + + const searchbox = screen.getByRole('searchbox', { name: 'Filter options' }); + + // Verify initial value is set correctly + expect(searchbox).toHaveValue('Script 1'); + + // Change the search text to filter for only "Script 2" + fireEvent.change(searchbox, { target: { value: 'Script 2' } }); + + // Script 1 should no longer be visible, only Script 2 + await waitFor(() => { + expect(screen.queryByText('Test script 1')).not.toBeInTheDocument(); + expect(screen.getByText('Test script 2')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_script_selector.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_script_selector.tsx new file mode 100644 index 0000000000000..964409e6339d1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_script_selector.tsx @@ -0,0 +1,205 @@ +/* + * 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 React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { + EuiPopover, + EuiFlexGroup, + EuiFlexItem, + EuiSelectable, + EuiToolTip, + EuiLoadingSpinner, + EuiText, +} from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { i18n } from '@kbn/i18n'; +import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; +import type { CustomScript } from '../../../../server/endpoint/services'; +import { useConsoleStateDispatch } from '../console/hooks/state_selectors/use_console_state_dispatch'; +import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants'; +import { useGetCustomScripts } from '../../hooks/custom_scripts/use_get_custom_scripts'; +import type { CommandArgumentValueSelectorProps } from '../console/types'; + +// Css to have a tooltip in place with a one line truncated description +const truncationStyle = css({ + display: '-webkit-box', + overflow: 'hidden', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 1, + lineClamp: 1, // standardized fallback for modern Firefox + textOverflow: 'ellipsis', + whiteSpace: 'normal', +}); + +const INITIAL_DISPLAY_LABEL = i18n.translate( + 'xpack.securitySolution.consoleArgumentSelectors.customScriptSelector.initialDisplayLabel', + { defaultMessage: 'Click to select script' } +); + +/** + * State for the custom script selector component + */ +interface CustomScriptSelectorState { + isPopoverOpen: boolean; +} + +type SelectableOption = EuiSelectableOption>; + +export const CustomScriptSelector = (agentType: ResponseActionAgentType) => { + const CustomScriptSelectorComponent = memo< + CommandArgumentValueSelectorProps + >(({ value, valueText, onChange, store: _store }) => { + const dispatch = useConsoleStateDispatch(); + const state = useMemo(() => { + return _store ?? { isPopoverOpen: true }; + }, [_store]); + const setIsPopoverOpen = useCallback( + (newValue: boolean) => { + onChange({ + value, + valueText, + store: { + ...state, + isPopoverOpen: newValue, + }, + }); + }, + [onChange, state, value, valueText] + ); + + const { data = [], isLoading: isLoadingScripts } = useGetCustomScripts(agentType); + const scriptsOptions: SelectableOption[] = useMemo(() => { + return data.map((script: CustomScript) => ({ + label: script.name, + description: script.description, + })); + }, [data]); + + // There is a race condition between the parent input and search input which results in search having the last char of the argument eg. 'e' from '--CloudFile' + // This is a workaround to ensure the popover is not shown until the input is focused + const [isAwaitingRenderDelay, setIsAwaitingRenderDelay] = useState(true); + useEffect(() => { + const timer = setTimeout(() => { + setIsAwaitingRenderDelay(false); + }, 0); + + return () => clearTimeout(timer); + }, []); + + const renderOption = (option: SelectableOption) => { + return ( + <> + + {option.label} + + {option?.description && ( + + + {option.description} + + + )} + + ); + }; + + const handleOpenPopover = useCallback(() => { + setIsPopoverOpen(true); + }, [setIsPopoverOpen]); + + const handleClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, [setIsPopoverOpen]); + + // Focus on the console's input element when the popover closes + useEffect(() => { + if (!state.isPopoverOpen) { + // Use setTimeout to ensure focus happens after the popover closes + setTimeout(() => { + dispatch({ type: 'addFocusToKeyCapture' }); + }, 0); + } + }, [state.isPopoverOpen, dispatch]); + + const handleScriptSelection = useCallback( + (options: EuiSelectableOption[]) => { + const selected = options.find((option: EuiSelectableOption) => option.checked === 'on'); + if (selected) { + onChange({ + value: selected.label, + valueText: selected.label, + store: { + ...state, + isPopoverOpen: false, + }, + }); + } + }, + [onChange, state] + ); + + if (isAwaitingRenderDelay || isLoadingScripts) { + return ; + } + + return ( + + +
{valueText || INITIAL_DISPLAY_LABEL}
+
+ + } + > + {state.isPopoverOpen && ( + ) => { + // Only stop propagation for typing keys, not for navigation keys - otherwise input lose focus + if (!['Enter', 'ArrowUp', 'ArrowDown', 'Escape'].includes(event.key)) { + event.stopPropagation(); + } + }, + }} + listProps={{ + rowHeight: 60, + showIcons: false, + textWrap: 'truncate', + }} + > + {(list, search) => ( + <> +
{search}
+ {list} + + )} +
+ )} +
+ ); + }); + + CustomScriptSelectorComponent.displayName = 'CustomScriptSelector'; + return CustomScriptSelectorComponent; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts index 024b80d24a208..32c3dca06c0ef 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { CustomScriptSelector } from '../../console_argument_selectors/custom_script_selector'; import { RunScriptActionResult } from '../command_render_components/run_script_action'; import type { CommandArgDefinition } from '../../console/types'; import { isAgentTypeAndActionSupported } from '../../../../common/lib/endpoint'; @@ -556,8 +557,9 @@ export const getEndpointConsoleCommands = ({ required: false, allowMultiples: false, about: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.args.cloudFile.about, - mustHaveValue: 'non-empty-string', + mustHaveValue: 'truthy', exclusiveOr: true, + SelectorComponent: CustomScriptSelector(agentType), }, CommandLine: { required: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/custom_scripts/use_get_custom_scripts.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/custom_scripts/use_get_custom_scripts.test.ts new file mode 100644 index 0000000000000..e02be7871c46c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/custom_scripts/use_get_custom_scripts.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { useQuery } from '@tanstack/react-query'; +import { useGetCustomScripts } from './use_get_custom_scripts'; + +jest.mock('@tanstack/react-query'); +jest.mock('../../../common/lib/kibana'); + +describe('useGetCustomScripts', () => { + const mockUseQuery = useQuery as jest.MockedFunction; + const mockHttpGet = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock the useHttp hook + jest.requireMock('../../../common/lib/kibana').useHttp.mockReturnValue({ + get: mockHttpGet, + }); + + // Mock the useQuery hook + mockUseQuery.mockReturnValue({ + data: [{ id: 'script1', name: 'Script 1', description: 'Test script 1' }], + isLoading: false, + isError: false, + error: null, + // Add other required properties for UseQueryResult + isSuccess: true, + isFetching: false, + isPending: false, + isStale: false, + status: 'success', + fetchStatus: 'idle', + refetch: jest.fn(), + } as unknown as ReturnType); + }); + + test('calls useQuery with correct parameters for endpoint agent type', () => { + renderHook(() => useGetCustomScripts('endpoint')); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: ['get-custom-scripts', 'endpoint'], + queryFn: expect.any(Function), + }); + }); + + test('calls useQuery with correct parameters for crowdstrike agent type', () => { + renderHook(() => useGetCustomScripts('crowdstrike')); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: ['get-custom-scripts', 'crowdstrike'], + queryFn: expect.any(Function), + }); + }); + + test('calls useQuery with correct parameters for sentinel_one agent type', () => { + renderHook(() => useGetCustomScripts('sentinel_one')); + + expect(mockUseQuery).toHaveBeenCalledWith({ + queryKey: ['get-custom-scripts', 'sentinel_one'], + queryFn: expect.any(Function), + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/custom_scripts/use_get_custom_scripts.ts b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/custom_scripts/use_get_custom_scripts.ts new file mode 100644 index 0000000000000..3751e655bfcde --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/custom_scripts/use_get_custom_scripts.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 { UseQueryResult, UseQueryOptions } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import type { IHttpFetchError } from '@kbn/core-http-browser'; +import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import type { CustomScript, CustomScriptsResponse } from '../../../../server/endpoint/services'; +import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants'; +import { CUSTOM_SCRIPTS_ROUTE } from '../../../../common/endpoint/constants'; +import { useHttp } from '../../../common/lib/kibana'; + +/** + * Error type for custom scripts API errors + */ +interface CustomScriptsErrorType { + statusCode: number; + message: string; + meta: ActionTypeExecutorResult; +} + +/** + * Hook to retrieve custom scripts for a specific agent type + * @param agentType - The type of agent to get scripts for (e.g., 'crowdstrike') + * @param options - Additional options for the query + * @returns Query result containing custom scripts data + */ +export const useGetCustomScripts = ( + agentType: ResponseActionAgentType, + options: Omit< + UseQueryOptions>, + 'queryKey' | 'queryFn' + > = {} +): UseQueryResult> => { + const http = useHttp(); + + return useQuery>({ + queryKey: ['get-custom-scripts', agentType], + queryFn: () => { + return http + .get(CUSTOM_SCRIPTS_ROUTE, { + version: '1', + query: { + agentType, + }, + }) + .then((response) => response.data); + }, + }); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/custom_scripts_handler.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/custom_scripts_handler.test.ts new file mode 100644 index 0000000000000..3842d63150c28 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/custom_scripts_handler.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { KibanaResponseFactory, RequestHandler } from '@kbn/core/server'; +import type { HttpApiTestSetupMock } from '../../mocks'; +import { createHttpApiTestSetupMock } from '../../mocks'; +import { registerCustomScriptsRoute } from './custom_scripts_handler'; +import { CUSTOM_SCRIPTS_ROUTE } from '../../../../common/endpoint/constants'; +import { getResponseActionsClient } from '../../services'; + +jest.mock('../../services', () => { + const actual = jest.requireActual('../../services'); + return { + ...actual, + getResponseActionsClient: jest.fn(), + }; +}); + +const mockGetResponseActionsClient = getResponseActionsClient as jest.Mock; +const mockCustomScripts = [ + { id: 'script-1', name: 'Test Script', description: 'Test description' }, +]; + +describe('custom_scripts_handler', () => { + let testSetup: HttpApiTestSetupMock; + let httpRequestMock: ReturnType; + let httpHandlerContextMock: HttpApiTestSetupMock['httpHandlerContextMock']; + let httpResponseMock: jest.Mocked; + let callHandler: () => ReturnType; + + beforeEach(() => { + testSetup = createHttpApiTestSetupMock(); + ({ httpHandlerContextMock, httpResponseMock } = testSetup); + + httpRequestMock = testSetup.createRequestMock({ + query: { + agentType: 'crowdstrike', + }, + }); + + mockGetResponseActionsClient.mockReturnValue({ + getCustomScripts: jest.fn().mockResolvedValue(mockCustomScripts), + }); + + registerCustomScriptsRoute(testSetup.routerMock, testSetup.endpointAppContextMock); + + const { routeHandler } = testSetup.getRegisteredVersionedRoute( + 'get', + CUSTOM_SCRIPTS_ROUTE, + '1' + ); + callHandler = () => routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns custom scripts from the response actions client', async () => { + await callHandler(); + + expect(mockGetResponseActionsClient).toHaveBeenCalledWith( + 'crowdstrike', + expect.objectContaining({ + esClient: expect.anything(), + spaceId: expect.anything(), + endpointService: expect.anything(), + username: expect.any(String), + connectorActions: expect.anything(), + }) + ); + + expect(httpResponseMock.ok).toHaveBeenCalledWith({ body: mockCustomScripts }); + }); + + it('passes agentType from query params', async () => { + httpRequestMock = testSetup.createRequestMock({ + query: { agentType: 'crowdstrike' }, + }); + + await callHandler(); + + expect(mockGetResponseActionsClient).toHaveBeenCalledWith('crowdstrike', expect.anything()); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/custom_scripts_handler.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/custom_scripts_handler.ts new file mode 100644 index 0000000000000..574a4f005bc15 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/custom_scripts_handler.ts @@ -0,0 +1,99 @@ +/* + * 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 type { ResponseActionsClient } from '../../services'; +import { getResponseActionsClient, NormalizedExternalConnectorClient } from '../../services'; +import { errorHandler } from '../error_handler'; +import { CUSTOM_SCRIPTS_ROUTE } from '../../../../common/endpoint/constants'; +import type { CustomScriptsRequestQueryParams } from '../../../../common/api/endpoint/custom_scripts/get_custom_scripts_route'; +import { CustomScriptsRequestSchema } from '../../../../common/api/endpoint/custom_scripts/get_custom_scripts_route'; + +import type { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import type { EndpointAppContext } from '../../types'; +import { withEndpointAuthz } from '../with_endpoint_authz'; + +/** + * Registers the custom scripts route + * @param router - Security solution plugin router + * @param endpointContext - Endpoint app context + */ +export const registerCustomScriptsRoute = ( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) => { + router.versioned + .get({ + access: 'internal', + path: CUSTOM_SCRIPTS_ROUTE, + security: { + authz: { + requiredPrivileges: ['securitySolution'], + }, + }, + options: { authRequired: true }, + }) + .addVersion( + { + version: '1', + validate: { + request: CustomScriptsRequestSchema, + }, + }, + withEndpointAuthz( + { all: ['canReadSecuritySolution'] }, + endpointContext.logFactory.get('customScriptsRoute'), + getCustomScriptsRouteHandler(endpointContext) + ) + ); +}; + +/** + * Creates a handler for the custom scripts route + * @param endpointContext - Endpoint app context + * @returns Request handler for custom scripts + */ +export const getCustomScriptsRouteHandler = ( + endpointContext: EndpointAppContext +): RequestHandler< + never, + CustomScriptsRequestQueryParams, + unknown, + SecuritySolutionRequestHandlerContext +> => { + const logger = endpointContext.logFactory.get('customScriptsRoute'); + + return async (context, request, response) => { + const { agentType = 'endpoint' } = request.query; + + logger.debug(`Retrieving custom scripts for: agentType ${agentType}`); + + try { + const coreContext = await context.core; + const user = coreContext.security.authc.getCurrentUser(); + const esClient = coreContext.elasticsearch.client.asInternalUser; + const connectorActions = (await context.actions).getActionsClient(); + const spaceId = (await context.securitySolution).getSpaceId(); + const responseActionsClient: ResponseActionsClient = getResponseActionsClient(agentType, { + esClient, + spaceId, + endpointService: endpointContext.service, + username: user?.username || 'unknown', + connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger), + }); + + const data = await responseActionsClient.getCustomScripts(); + + return response.ok({ body: data }); + } catch (e) { + return errorHandler(logger, response, e); + } + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/index.ts index 96e90e37e24cb..dcbbd3e64d85c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -14,7 +14,7 @@ import { registerActionStatusRoutes } from './status'; import { registerActionStateRoutes } from './state'; import { registerActionListRoutes } from './list'; import { registerResponseActionRoutes } from './response_actions'; - +import { registerCustomScriptsRoute } from './custom_scripts_handler'; // wrap route registration export function registerActionRoutes( @@ -29,4 +29,5 @@ export function registerActionRoutes( registerResponseActionRoutes(router, endpointContext); registerActionFileDownloadRoutes(router, endpointContext); registerActionFileInfoRoute(router, endpointContext); + registerCustomScriptsRoute(router, endpointContext); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.ts index 677b59a42ca04..eb69cc15d0768 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.ts @@ -14,6 +14,7 @@ import type { SearchRequest, SearchResponse } from '@elastic/elasticsearch/lib/a import type { CrowdstrikeBaseApiResponse, CrowdStrikeExecuteRTRResponse, + CrowdstrikeGetScriptsResponse, } from '@kbn/stack-connectors-plugin/common/crowdstrike/types'; import { v4 as uuidv4 } from 'uuid'; @@ -22,6 +23,7 @@ import { mapParametersToCrowdStrikeArguments } from './utils'; import type { CrowdstrikeActionRequestCommonMeta } from '../../../../../../common/endpoint/types/crowdstrike'; import type { CommonResponseActionMethodOptions, + CustomScriptsResponse, ProcessPendingActionsMethodOptions, } from '../../..'; import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants'; @@ -549,6 +551,33 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl { await this.writeActionResponseToEndpointIndex(options); } + async getCustomScripts(): Promise { + try { + const customScriptsResponse = (await this.sendAction( + SUB_ACTION.GET_RTR_CLOUD_SCRIPTS, + {} + )) as ActionTypeExecutorResult; + + const resources = customScriptsResponse.data?.resources || []; + // Transform CrowdStrike script resources to CustomScriptsResponse format + const data = resources.map((script) => ({ + // due to External EDR's schema nature - we expect a maybe() everywhere - empty strings are needed + id: script.id || '', + name: script.name || '', + description: script.description || '', + })); + return { data } as CustomScriptsResponse; + } catch (err) { + const error = new ResponseActionsClientError( + `Failed to fetch Crowdstrike scripts, failed with: ${err.message}`, + 500, + err + ); + this.log.error(error); + throw error; + } + } + async processPendingActions({ abortSignal, addToQueue, diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/utils.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/utils.test.ts index f961c7a3d1d6b..d084fba3a78e3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/utils.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/utils.test.ts @@ -17,7 +17,7 @@ describe('mapParametersToCrowdStrikeArguments', () => { const result = mapParametersToCrowdStrikeArguments('runscript', { commandLine: 'echo Hello World', }); - expect(result).toBe('runscript --CommandLine=```echo Hello World```'); + expect(result).toBe(`runscript --CommandLine='echo Hello World'`); }); it('leaves parameter already wrapped in triple backticks unchanged', () => { @@ -40,7 +40,7 @@ describe('mapParametersToCrowdStrikeArguments', () => { cloudFile: 'file.txt', }); expect(result).toBe( - 'runscript --Raw=```echo Hello``` --CommandLine=```echo Hello World``` --HostPath=/home/user --CloudFile=file.txt' + "runscript --Raw=```echo Hello``` --CommandLine='echo Hello World' --HostPath=/home/user --CloudFile=file.txt" ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/utils.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/utils.ts index 2ec2ec2bb0cf8..3ae35806e9c8a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/utils.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/utils.ts @@ -28,8 +28,11 @@ export const mapParametersToCrowdStrikeArguments = ( // If it's a single element (no spaces), use it as-is sanitizedValue = strippedValue; } else { - // If it contains multiple elements (spaces), wrap in ``` - sanitizedValue = `\`\`\`${strippedValue}\`\`\``; + // If parameter is raw and it contains multiple elements (spaces), wrap in ``` + const wrappedValue = + key === 'raw' ? `\`\`\`${strippedValue}\`\`\`` : `'${strippedValue}'`; + + sanitizedValue = wrappedValue; } } } else { diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts index c61a64fa2d179..4fdcaf8ef50e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts @@ -359,8 +359,7 @@ describe('EndpointActionsClient', () => { type ResponseActionsMethodsOnly = keyof Omit< ResponseActionsClient, - // TODO: not yet implemented - 'processPendingActions' | 'getFileDownload' | 'getFileInfo' | 'runscript' + 'processPendingActions' | 'getFileDownload' | 'getFileInfo' | 'runscript' | 'getCustomScripts' >; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts index 9ef1245c247f3..5aa48b670e627 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts @@ -51,6 +51,7 @@ import { } from '../../../../../../common/endpoint/constants'; import type { CommonResponseActionMethodOptions, + CustomScriptsResponse, GetFileDownloadMethodResponse, ProcessPendingActionsMethodOptions, ResponseActionsClient, @@ -969,6 +970,10 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient throw new ResponseActionsNotSupportedError('runscript'); } + public async getCustomScripts(): Promise { + throw new ResponseActionsNotSupportedError('getCustomScripts'); + } + public async processPendingActions(_: ProcessPendingActionsMethodOptions): Promise { this.log.debug(`#processPendingActions() method is not implemented for ${this.agentType}!`); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts index 0247a43669a80..a8cadd2623527 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts @@ -78,6 +78,25 @@ export interface GetFileDownloadMethodResponse { mimeType?: string; } +export interface CustomScript { + /** + * Unique identifier for the script + */ + id: string; + /** + * Display name of the script + */ + name: string; + /** + * Description of what the script does + */ + description: string; +} + +export interface CustomScriptsResponse { + data: CustomScript[]; +} + /** * The interface required for a Response Actions provider */ @@ -135,6 +154,11 @@ export interface ResponseActionsClient { */ processPendingActions: (options: ProcessPendingActionsMethodOptions) => Promise; + /** + * Retrieves a list of all custom scripts for a given agent type - ** not a Response Action ** + */ + getCustomScripts: () => Promise; + /** * Retrieve a file for download * @param actionId @@ -154,6 +178,7 @@ export interface ResponseActionsClient { * @param actionRequest * @param options */ + scan: ( actionRequest: OmitUnsupportedAttributes, options?: CommonResponseActionMethodOptions diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts index 5f443621f79c4..a467a7de1b67f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.test.ts @@ -52,7 +52,7 @@ describe('MS Defender response actions client', () => { msClientMock = new MicrosoftDefenderEndpointActionsClient(clientConstructorOptionsMock); }); - const supporteResponseActionClassMethods: Record = { + const supportedResponseActionClassMethods: Record = { upload: false, scan: false, execute: false, @@ -66,10 +66,11 @@ describe('MS Defender response actions client', () => { isolate: true, release: true, processPendingActions: true, + getCustomScripts: false, }; it.each( - Object.entries(supporteResponseActionClassMethods).reduce((acc, [key, value]) => { + Object.entries(supportedResponseActionClassMethods).reduce((acc, [key, value]) => { if (!value) { acc.push(key as keyof ResponseActionsClient); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts index 414c77d9d7521..040a70889f442 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts @@ -83,6 +83,7 @@ const createResponseActionClientMock = (): jest.Mocked => getFileDownload: jest.fn().mockReturnValue(Promise.resolve()), scan: jest.fn().mockReturnValue(Promise.resolve()), runscript: jest.fn().mockReturnValue(Promise.resolve()), + getCustomScripts: jest.fn().mockReturnValue(Promise.resolve()), }; };