[8.x] [EDR Workflows] CrowdStrike RunScript: Log Actions and UI Output (#204044) (#204693)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[EDR Workflows] CrowdStrike RunScript: Log Actions and UI Output
(#204044)](https://github.com/elastic/kibana/pull/204044)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Tomasz
Ciecierski","email":"tomasz.ciecierski@elastic.co"},"sourceCommit":{"committedDate":"2024-12-17T15:53:03Z","message":"[EDR
Workflows] CrowdStrike RunScript: Log Actions and UI Output
(#204044)","sha":"a7addbadd394b33fa3212e57f55be251c1a3a371","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["v9.0.0","Team:Defend
Workflows","release_note:feature","backport:version","v8.18.0"],"number":204044,"url":"https://github.com/elastic/kibana/pull/204044","mergeCommit":{"message":"[EDR
Workflows] CrowdStrike RunScript: Log Actions and UI Output
(#204044)","sha":"a7addbadd394b33fa3212e57f55be251c1a3a371"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/204044","number":204044,"mergeCommit":{"message":"[EDR
Workflows] CrowdStrike RunScript: Log Actions and UI Output
(#204044)","sha":"a7addbadd394b33fa3212e57f55be251c1a3a371"}},{"branch":"8.x","label":"v8.18.0","labelRegex":"^v8.18.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Tomasz Ciecierski 2024-12-18 11:32:01 +01:00 committed by GitHub
parent 2735f3cfa3
commit 8e8fdee03f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 546 additions and 65 deletions

View file

@ -318,16 +318,16 @@ export const CrowdstrikeExecuteRTRResponseSchema = schema.object(
schema.string(),
schema.object(
{
session_id: schema.maybe(schema.string()),
task_id: schema.maybe(schema.string()),
complete: schema.maybe(schema.boolean()),
stdout: schema.maybe(schema.string()),
stderr: schema.maybe(schema.string()),
base_command: schema.maybe(schema.string()),
aid: schema.maybe(schema.string()),
errors: schema.maybe(schema.arrayOf(schema.any())),
query_time: schema.maybe(schema.number()),
offline_queued: schema.maybe(schema.boolean()),
session_id: schema.string(),
task_id: schema.string(),
complete: schema.boolean(),
stdout: schema.string(),
stderr: schema.string(),
base_command: schema.string(),
aid: schema.string(),
errors: schema.arrayOf(schema.any()),
query_time: schema.number(),
offline_queued: schema.boolean(),
},
{ unknowns: 'allow' }
)
@ -337,9 +337,9 @@ export const CrowdstrikeExecuteRTRResponseSchema = schema.object(
),
meta: schema.object(
{
query_time: schema.maybe(schema.number()),
powered_by: schema.maybe(schema.string()),
trace_id: schema.maybe(schema.string()),
query_time: schema.number(),
powered_by: schema.string(),
trace_id: schema.string(),
},
{ unknowns: 'allow' }
),
@ -348,7 +348,5 @@ export const CrowdstrikeExecuteRTRResponseSchema = schema.object(
{ unknowns: 'allow' }
);
export type CrowdStrikeExecuteRTRResponse = typeof CrowdstrikeExecuteRTRResponseSchema;
// TODO: will be part of a next PR
export const CrowdstrikeGetScriptsParamsSchema = schema.any({});

View file

@ -17,8 +17,8 @@ import {
CrowdstrikeGetTokenResponseSchema,
CrowdstrikeGetAgentsResponseSchema,
RelaxedCrowdstrikeBaseApiResponseSchema,
CrowdstrikeInitRTRResponseSchema,
CrowdstrikeInitRTRParamsSchema,
CrowdstrikeExecuteRTRResponseSchema,
} from './schema';
export type CrowdstrikeConfig = TypeOf<typeof CrowdstrikeConfigSchema>;
@ -35,9 +35,10 @@ export type CrowdstrikeGetAgentOnlineStatusResponse = TypeOf<
typeof CrowdstrikeGetAgentOnlineStatusResponseSchema
>;
export type CrowdstrikeGetTokenResponse = TypeOf<typeof CrowdstrikeGetTokenResponseSchema>;
export type CrowdstrikeInitRTRResponse = TypeOf<typeof CrowdstrikeInitRTRResponseSchema>;
export type CrowdstrikeHostActionsParams = TypeOf<typeof CrowdstrikeHostActionsParamsSchema>;
export type CrowdstrikeActionParams = TypeOf<typeof CrowdstrikeActionParamsSchema>;
export type CrowdstrikeInitRTRParams = TypeOf<typeof CrowdstrikeInitRTRParamsSchema>;
export type CrowdStrikeExecuteRTRResponse = TypeOf<typeof CrowdstrikeExecuteRTRResponseSchema>;

View file

@ -22,6 +22,7 @@ import type {
CrowdstrikeGetTokenResponse,
CrowdstrikeGetAgentOnlineStatusResponse,
RelaxedCrowdstrikeBaseApiResponse,
CrowdStrikeExecuteRTRResponse,
} from '../../../common/crowdstrike/types';
import {
CrowdstrikeHostActionsParamsSchema,
@ -31,7 +32,6 @@ import {
CrowdstrikeRTRCommandParamsSchema,
CrowdstrikeExecuteRTRResponseSchema,
CrowdstrikeGetScriptsParamsSchema,
CrowdStrikeExecuteRTRResponse,
CrowdstrikeApiDoNotValidateResponsesSchema,
CrowdstrikeGetTokenResponseSchema,
} from '../../../common/crowdstrike/schema';
@ -292,15 +292,9 @@ export class CrowdstrikeConnector extends SubActionConnector<
payload: {
command: string;
endpoint_ids: string[];
overwriteUrl?: 'batchExecuteRTR' | 'batchActiveResponderExecuteRTR' | 'batchAdminExecuteRTR';
},
connectorUsageCollector: ConnectorUsageCollector
): Promise<CrowdStrikeExecuteRTRResponse> => {
// Some commands are only available in specific API endpoints, however there's an additional requirement check for the command's argument
// Eg. runscript command is available with the batchExecuteRTR endpoint, but if it goes with --Raw parameter, it should go to batchAdminExecuteRTR endpoint
// This overwrite value will be coming from kibana response actions api
const csUrl = payload.overwriteUrl ? this.urls[payload.overwriteUrl] : url;
const batchId = await this.crowdStrikeSessionManager.initializeSession(
{ endpoint_ids: payload.endpoint_ids },
connectorUsageCollector
@ -313,7 +307,7 @@ export class CrowdstrikeConnector extends SubActionConnector<
}
return await this.crowdstrikeApiRequest<CrowdStrikeExecuteRTRResponse>(
{
url: csUrl,
url,
method: 'post',
data: {
base_command: baseCommand,
@ -335,7 +329,6 @@ export class CrowdstrikeConnector extends SubActionConnector<
payload: {
command: string;
endpoint_ids: string[];
overwriteUrl?: 'batchActiveResponderExecuteRTR' | 'batchAdminExecuteRTR';
},
connectorUsageCollector: ConnectorUsageCollector
): Promise<CrowdStrikeExecuteRTRResponse> {
@ -351,7 +344,6 @@ export class CrowdstrikeConnector extends SubActionConnector<
payload: {
command: string;
endpoint_ids: string[];
overwriteUrl?: 'batchAdminExecuteRTR';
},
connectorUsageCollector: ConnectorUsageCollector
): Promise<CrowdStrikeExecuteRTRResponse> {

View file

@ -17,6 +17,7 @@ import { GetProcessesRouteRequestSchema } from '../response_actions/running_proc
import { KillProcessRouteRequestSchema } from '../response_actions/kill_process';
import { SuspendProcessRouteRequestSchema } from '../response_actions/suspend_process';
import { UploadActionRequestSchema } from '../response_actions/upload';
import { RunScriptActionRequestSchema } from '../response_actions/run_script';
export const ResponseActionBodySchema = schema.oneOf([
IsolateRouteRequestSchema.body,
@ -28,6 +29,7 @@ export const ResponseActionBodySchema = schema.oneOf([
ExecuteActionRequestSchema.body,
UploadActionRequestSchema.body,
ScanActionRequestSchema.body,
RunScriptActionRequestSchema.body,
]);
export type ResponseActionsRequestBody = TypeOf<typeof ResponseActionBodySchema>;

View file

@ -26,27 +26,27 @@ export const RunScriptActionRequestSchema = {
/**
* The script to run
*/
Raw: schema.maybe(NonEmptyString),
raw: schema.maybe(NonEmptyString),
/**
* The path to the script on the host to run
*/
HostPath: schema.maybe(NonEmptyString),
hostPath: schema.maybe(NonEmptyString),
/**
* The path to the script in the cloud to run
*/
CloudFile: schema.maybe(NonEmptyString),
cloudFile: schema.maybe(NonEmptyString),
/**
* The command line to run
*/
CommandLine: schema.maybe(NonEmptyString),
commandLine: schema.maybe(NonEmptyString),
/**
* The max timeout value before the command is killed. Number represents milliseconds
*/
Timeout: schema.maybe(schema.number({ min: 1 })),
timeout: schema.maybe(schema.number({ min: 1 })),
},
{
validate: (params) => {
if (!params.Raw && !params.HostPath && !params.CloudFile) {
if (!params.raw && !params.hostPath && !params.cloudFile) {
return 'At least one of Raw, HostPath, or CloudFile must be provided';
}
},

View file

@ -15,6 +15,8 @@ import type {
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
GetProcessesActionOutputContent,
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters,
} from '../../types';
import { RESPONSE_ACTION_AGENT_TYPE, RESPONSE_ACTION_TYPE } from './constants';
@ -47,6 +49,15 @@ export const isProcessesAction = (
return action.command === 'running-processes';
};
export const isRunScriptAction = (
action: MaybeImmutable<SomeObjectWithCommand>
): action is ActionDetails<
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters
> => {
return action.command === 'runscript';
};
// type guards to ensure only the matching string values are attached to the types filter type
export const isAgentType = (type: string): type is (typeof RESPONSE_ACTION_AGENT_TYPE)[number] =>
RESPONSE_ACTION_AGENT_TYPE.includes(type as (typeof RESPONSE_ACTION_AGENT_TYPE)[number]);

View file

@ -97,7 +97,8 @@ export interface ResponseActionScanOutputContent {
}
export interface ResponseActionRunScriptOutputContent {
output: string;
stdout: string;
stderr: string;
code: string;
}

View file

@ -279,7 +279,7 @@ Command Examples for Running Scripts:
3. Executes a raw script provided entirely within the "--Raw" flag.
runscript --Raw="Get-ChildItem."
runscript --Raw=\`\`\`Get-ChildItem.\`\`\`
4. Executes a script located on the remote host at the specified path with the provided command-line arguments.

View file

@ -10,6 +10,8 @@ import type {
ActionDetails,
MaybeImmutable,
ResponseActionExecuteOutputContent,
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters,
ResponseActionsExecuteParameters,
} from '../../../../common/endpoint/types';
import { EXECUTE_FILE_LINK_TITLE } from '../endpoint_response_actions_list/translations';
@ -18,14 +20,18 @@ import { ExecuteActionHostResponseOutput } from './execute_action_host_response_
export interface ExecuteActionHostResponseProps {
action: MaybeImmutable<
ActionDetails<ResponseActionExecuteOutputContent, ResponseActionsExecuteParameters>
| ActionDetails<ResponseActionExecuteOutputContent, ResponseActionsExecuteParameters>
| ActionDetails<ResponseActionRunScriptOutputContent, ResponseActionRunScriptParameters>
>;
agentId?: string;
canAccessFileDownloadLink: boolean;
'data-test-subj'?: string;
textSize?: 'xs' | 's';
hideFile?: boolean;
hideContext?: boolean;
}
// Note: also used for RunScript command
export const ExecuteActionHostResponse = memo<ExecuteActionHostResponseProps>(
({
action,
@ -33,6 +39,8 @@ export const ExecuteActionHostResponse = memo<ExecuteActionHostResponseProps>(
canAccessFileDownloadLink,
textSize = 's',
'data-test-subj': dataTestSubj,
hideFile,
hideContext,
}) => {
const outputContent = useMemo(
() =>
@ -44,21 +52,24 @@ export const ExecuteActionHostResponse = memo<ExecuteActionHostResponseProps>(
return (
<>
<EuiFlexItem>
<ResponseActionFileDownloadLink
action={action}
buttonTitle={EXECUTE_FILE_LINK_TITLE}
canAccessFileDownloadLink={canAccessFileDownloadLink}
data-test-subj={`${dataTestSubj}-getExecuteLink`}
textSize={textSize}
/>
<EuiSpacer size="xxl" />
</EuiFlexItem>
{!hideFile && (
<EuiFlexItem>
<ResponseActionFileDownloadLink
action={action}
buttonTitle={EXECUTE_FILE_LINK_TITLE}
canAccessFileDownloadLink={canAccessFileDownloadLink}
data-test-subj={`${dataTestSubj}-getExecuteLink`}
textSize={textSize}
/>
<EuiSpacer size="xxl" />
</EuiFlexItem>
)}
{outputContent && (
<ExecuteActionHostResponseOutput
outputContent={outputContent}
data-test-subj={`${dataTestSubj}-executeResponseOutput`}
textSize={textSize}
hideContext={hideContext}
/>
)}
</>

View file

@ -98,6 +98,7 @@ interface ShellInfoContentProps {
textSize?: 's' | 'xs';
title: string;
}
const ShellInfoContent = memo<ShellInfoContentProps>(({ content, textSize, title }) => (
<StyledEuiText size={textSize}>
<strong>
@ -178,10 +179,12 @@ export interface ExecuteActionHostResponseOutputProps {
outputContent: ResponseActionExecuteOutputContent;
'data-test-subj'?: string;
textSize?: 's' | 'xs';
hideContext?: boolean;
}
// Note: also used for RunScript command
export const ExecuteActionHostResponseOutput = memo<ExecuteActionHostResponseOutputProps>(
({ outputContent, 'data-test-subj': dataTestSubj, textSize = 'xs' }) => {
({ outputContent, 'data-test-subj': dataTestSubj, textSize = 'xs', hideContext }) => {
const contextContent = useMemo(
() => (
<>
@ -216,14 +219,16 @@ export const ExecuteActionHostResponseOutput = memo<ExecuteActionHostResponseOut
return (
<>
<EuiFlexItem>
<ExecutionActionOutputAccordion
content={contextContent}
data-test-subj={`${dataTestSubj}-context`}
textSize={textSize}
type="context"
/>
</EuiFlexItem>
{!hideContext && (
<EuiFlexItem>
<ExecutionActionOutputAccordion
content={contextContent}
data-test-subj={`${dataTestSubj}-context`}
textSize={textSize}
type="context"
/>
</EuiFlexItem>
)}
<EuiFlexItem>
{outputContent.stderr.length > 0 && (
<>

View file

@ -0,0 +1,96 @@
/*
* 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, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { ExecuteActionHostResponse } from '../../endpoint_execute_action';
import { useSendRunScriptEndpoint } from '../../../hooks/response_actions/use_send_run_script_endpoint_request';
import type { RunScriptActionRequestBody } from '../../../../../common/api/endpoint';
import { useConsoleActionSubmitter } from '../hooks/use_console_action_submitter';
import type {
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters,
} from '../../../../../common/endpoint/types';
import type { ActionRequestComponentProps } from '../types';
export const RunScriptActionResult = memo<
ActionRequestComponentProps<
{
Raw?: string;
HostPath?: string;
CloudFile?: string;
CommandLine?: string;
Timeout?: number;
comment?: string;
},
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters
>
>(({ command, setStore, store, status, setStatus, ResultComponent }) => {
const actionCreator = useSendRunScriptEndpoint();
const actionRequestBody = useMemo<undefined | RunScriptActionRequestBody>(() => {
const { endpointId, agentType } = command.commandDefinition?.meta ?? {};
if (!endpointId) {
return;
}
return {
agent_type: agentType,
endpoint_ids: [endpointId],
parameters: {
raw: command.args.args.Raw?.[0],
hostPath: command.args.args.HostPath?.[0],
cloudFile: command.args.args.CloudFile?.[0],
commandLine: command.args.args.CommandLine?.[0],
timeout: command.args.args.Timeout?.[0],
},
comment: command.args.args?.comment?.[0],
};
}, [command]);
const { result, actionDetails: completedActionDetails } = useConsoleActionSubmitter<
RunScriptActionRequestBody,
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters
>({
ResultComponent,
setStore,
store,
status,
setStatus,
actionCreator,
actionRequestBody,
dataTestSubj: 'runscript',
});
if (!completedActionDetails || !completedActionDetails.wasSuccessful) {
return result;
}
return (
<ResultComponent
data-test-subj="executeSuccess"
showAs="success"
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.runScriptAction.successTitle',
{ defaultMessage: 'RunScript was successful.' }
)}
>
<ExecuteActionHostResponse
action={completedActionDetails}
canAccessFileDownloadLink={true}
agentId={command.commandDefinition?.meta?.endpointId}
textSize="s"
data-test-subj="console"
hideFile={true}
hideContext={true}
/>
</ResultComponent>
);
});
RunScriptActionResult.displayName = 'RunScriptActionResult';

View file

@ -6,6 +6,7 @@
*/
import { i18n } from '@kbn/i18n';
import { RunScriptActionResult } from '../command_render_components/run_script_action';
import type { CommandArgDefinition } from '../../console/types';
import { isAgentTypeAndActionSupported } from '../../../../common/lib/endpoint';
import { getRbacControl } from '../../../../../common/endpoint/service/response_actions/utils';
@ -531,14 +532,14 @@ export const getEndpointConsoleCommands = ({
aboutInfo: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.about,
isSupported: doesEndpointSupportCommand('runscript'),
}),
RenderComponent: () => null,
RenderComponent: RunScriptActionResult,
meta: {
agentType,
endpointId: endpointAgentId,
capabilities: endpointCapabilities,
privileges: endpointPrivileges,
},
exampleUsage: `runscript --Raw="Get-ChildItem ." --CommandLine=""`,
exampleUsage: `runscript --Raw=\`\`\`Get-ChildItem .\`\`\` --CommandLine=""`,
helpUsage: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.helpUsage,
exampleInstruction: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.about,
validate: capabilitiesAndPrivilegesValidator(agentType),

View file

@ -23,6 +23,7 @@ import {
isExecuteAction,
isGetFileAction,
isProcessesAction,
isRunScriptAction,
isUploadAction,
} from '../../../../../common/endpoint/service/response_actions/type_guards';
import { EndpointUploadActionResult } from '../../endpoint_upload_action_result';
@ -209,6 +210,30 @@ const OutputContent = memo<{
);
}
if (isRunScriptAction(action)) {
return (
<EuiFlexGroup direction="column" data-test-subj={getTestId('runScriptDetails')}>
{action.agents.map((agentId) => (
<div key={agentId}>
{OUTPUT_MESSAGES.wasSuccessful(command)}
<ExecuteActionHostResponse
action={action}
agentId={agentId}
canAccessFileDownloadLink={
canAccessEndpointActionsLogManagement || canReadActionsLogManagement
}
textSize="xs"
data-test-subj={getTestId('actionsLogTray')}
hideFile={true}
hideContext={true}
/>
</div>
))}
</EuiFlexGroup>
);
}
// CrowdStrike Isolate/Release actions
if (action.agentType === 'crowdstrike') {
return <>{OUTPUT_MESSAGES.submittedSuccessfully(command)}</>;
}

View file

@ -0,0 +1,81 @@
/*
* 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 { useMutation as _useMutation } from '@tanstack/react-query';
import type { AppContextTestRender } from '../../../common/mock/endpoint';
import type { RenderHookResult } from '@testing-library/react';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mocks';
import { RUN_SCRIPT_ROUTE } from '../../../../common/endpoint/constants';
import type { RunScriptActionRequestBody } from '../../../../common/api/endpoint';
import type {
RunScriptRequestCustomOptions,
UseSendRunScriptRequestResult,
} from './use_send_run_script_endpoint_request';
import { useSendRunScriptEndpoint } from './use_send_run_script_endpoint_request';
const useMutationMock = _useMutation as jest.Mock;
jest.mock('@tanstack/react-query', () => {
const actualReactQueryModule = jest.requireActual('@tanstack/react-query');
return {
...actualReactQueryModule,
useMutation: jest.fn((...args) => actualReactQueryModule.useMutation(...args)),
};
});
const runScriptPayload: RunScriptActionRequestBody = {
endpoint_ids: ['test-endpoint-id'],
agent_type: 'crowdstrike',
parameters: { raw: 'ls' },
};
describe('When using the `useSendRunScriptRequest()` hook', () => {
let customOptions: RunScriptRequestCustomOptions;
let http: AppContextTestRender['coreStart']['http'];
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
let renderHook: () => RenderHookResult<
UseSendRunScriptRequestResult,
RunScriptRequestCustomOptions
>;
beforeEach(() => {
const testContext = createAppRootMockRenderer();
http = testContext.coreStart.http;
apiMocks = responseActionsHttpMocks(http);
customOptions = {};
renderHook = () => {
return testContext.renderHook(() => useSendRunScriptEndpoint(customOptions));
};
});
it('should call the `runScript` API with correct payload', async () => {
const {
result: {
current: { mutateAsync },
},
} = renderHook();
await mutateAsync(runScriptPayload);
expect(apiMocks.responseProvider.runscript).toHaveBeenCalledWith({
body: JSON.stringify(runScriptPayload),
path: RUN_SCRIPT_ROUTE,
version: '2023-10-31',
});
});
it('should allow custom options to be passed to ReactQuery', async () => {
customOptions.mutationKey = ['pqr-abc'];
customOptions.cacheTime = 10;
renderHook();
expect(useMutationMock).toHaveBeenCalledWith(expect.any(Function), customOptions);
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { useMutation } from '@tanstack/react-query';
import type { RunScriptActionRequestBody } from '../../../../common/api/endpoint';
import { KibanaServices } from '../../../common/lib/kibana';
import { RUN_SCRIPT_ROUTE } from '../../../../common/endpoint/constants';
import type { ResponseActionApiResponse } from '../../../../common/endpoint/types';
export type RunScriptRequestCustomOptions = UseMutationOptions<
ResponseActionApiResponse,
IHttpFetchError,
RunScriptActionRequestBody
>;
export type UseSendRunScriptRequestResult = UseMutationResult<
ResponseActionApiResponse,
IHttpFetchError,
RunScriptActionRequestBody
>;
export const useSendRunScriptEndpoint = (
options?: RunScriptRequestCustomOptions
): UseSendRunScriptRequestResult => {
return useMutation<ResponseActionApiResponse, IHttpFetchError, RunScriptActionRequestBody>(
(runScriptActionReqBody) => {
return KibanaServices.get().http.post<ResponseActionApiResponse>(RUN_SCRIPT_ROUTE, {
body: JSON.stringify(runScriptActionReqBody),
version: '2023-10-31',
});
},
options
);
};

View file

@ -17,6 +17,7 @@ import {
GET_PROCESSES_ROUTE,
ISOLATE_HOST_ROUTE_V2,
KILL_PROCESS_ROUTE,
RUN_SCRIPT_ROUTE,
SCAN_ROUTE,
SUSPEND_PROCESS_ROUTE,
UNISOLATE_HOST_ROUTE_V2,
@ -42,6 +43,8 @@ import type {
ResponseActionScanParameters,
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters,
} from '../../../common/endpoint/types';
export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
@ -73,6 +76,8 @@ export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
>;
scan: () => ActionDetailsApiResponse<ResponseActionScanOutputContent>;
runscript: () => ActionDetailsApiResponse<ResponseActionRunScriptOutputContent>;
}>;
export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHttpMocksInterface>([
@ -273,6 +278,25 @@ export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHt
command: 'scan',
});
return { data: response };
},
},
{
id: 'runscript',
path: RUN_SCRIPT_ROUTE,
method: 'post',
handler: (): ActionDetailsApiResponse<
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters
> => {
const generator = new EndpointActionGenerator('seed');
const response = generator.generateActionDetails<
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters
>({
command: 'runscript',
});
return { data: response };
},
},

View file

@ -27,6 +27,7 @@ import type {
ResponseActionGetFileParameters,
EndpointActionResponseDataOutput,
ResponseActionScanOutputContent,
ResponseActionRunScriptOutputContent,
} from '../../../common/endpoint/types';
import { getFileDownloadId } from '../../../common/endpoint/service/response_actions/get_file_download_id';
import {
@ -138,6 +139,15 @@ export const sendEndpointActionResponse = async (
.content as unknown as ResponseActionExecuteOutputContent
).stderr = 'execute command timed out';
}
if (
endpointResponse.EndpointActions.data.command === 'runscript' &&
endpointResponse.EndpointActions.data.output
) {
(
endpointResponse.EndpointActions.data.output
.content as unknown as ResponseActionRunScriptOutputContent
).stderr = 'runscript command timed out';
}
}
await esClient.index({

View file

@ -11,8 +11,13 @@ import {
CROWDSTRIKE_CONNECTOR_ID,
} from '@kbn/stack-connectors-plugin/common/crowdstrike/constants';
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import type { CrowdstrikeBaseApiResponse } from '@kbn/stack-connectors-plugin/common/crowdstrike/types';
import type {
CrowdstrikeBaseApiResponse,
CrowdStrikeExecuteRTRResponse,
} from '@kbn/stack-connectors-plugin/common/crowdstrike/types';
import { v4 as uuidv4 } from 'uuid';
import { mapParametersToCrowdStrikeArguments } from './utils';
import type { CrowdstrikeActionRequestCommonMeta } from '../../../../../../common/endpoint/types/crowdstrike';
import type {
CommonResponseActionMethodOptions,
@ -305,15 +310,99 @@ export class CrowdstrikeActionsClient extends ResponseActionsClientImpl {
): Promise<
ActionDetails<ResponseActionRunScriptOutputContent, ResponseActionRunScriptParameters>
> {
// TODO: just a placeholder for now
return Promise.resolve({ output: 'runscript', code: 200 }) as never as ActionDetails<
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters
>;
const reqIndexOptions: ResponseActionsClientWriteActionRequestToEndpointIndexOptions = {
...actionRequest,
...this.getMethodOptions(options),
command: 'runscript',
};
let actionResponse: ActionTypeExecutorResult<CrowdStrikeExecuteRTRResponse> | undefined;
if (!reqIndexOptions.error) {
let error = (await this.validateRequest(reqIndexOptions)).error;
if (!error) {
if (!reqIndexOptions.actionId) {
reqIndexOptions.actionId = uuidv4();
}
try {
actionResponse = (await this.sendAction(SUB_ACTION.EXECUTE_ADMIN_RTR, {
actionParameters: { comment: this.buildExternalComment(reqIndexOptions) },
command: mapParametersToCrowdStrikeArguments('runscript', actionRequest.parameters),
endpoint_ids: actionRequest.endpoint_ids,
})) as ActionTypeExecutorResult<CrowdStrikeExecuteRTRResponse>;
} catch (err) {
error = err;
}
}
reqIndexOptions.error = error?.message;
if (!this.options.isAutomated && error) {
throw error;
}
}
const actionRequestDoc = await this.writeActionRequestToEndpointIndex(reqIndexOptions);
// Ensure actionResponse is assigned before using it
if (actionResponse) {
await this.completeCrowdstrikeBatchAction(actionResponse, actionRequestDoc);
}
await this.updateCases({
command: reqIndexOptions.command,
caseIds: reqIndexOptions.case_ids,
alertIds: reqIndexOptions.alert_ids,
actionId: actionRequestDoc.EndpointActions.action_id,
hosts: actionRequest.endpoint_ids.map((agentId) => {
return {
hostId: agentId,
hostname: actionRequestDoc.EndpointActions.data.hosts?.[agentId].name ?? '',
};
}),
comment: reqIndexOptions.comment,
});
return this.fetchActionDetails(actionRequestDoc.EndpointActions.action_id);
}
private async completeCrowdstrikeBatchAction(
actionResponse: ActionTypeExecutorResult<CrowdStrikeExecuteRTRResponse>,
doc: LogsEndpointAction
): Promise<void> {
const agentId = doc.agent.id as string;
const stdout = actionResponse.data?.combined.resources[agentId].stdout || '';
const stderr = actionResponse.data?.combined.resources[agentId].stderr || '';
const error = actionResponse.data?.combined.resources[agentId].errors?.[0];
const options = {
actionId: doc.EndpointActions.action_id,
agentId,
data: {
...doc.EndpointActions.data,
output: {
content: {
stdout,
stderr,
code: '200',
},
type: 'text' as const,
},
},
...(error
? {
error: {
code: error.code,
message: `Crowdstrike action failed: ${error.message}`,
},
}
: {}),
};
await this.writeActionResponseToEndpointIndex(options);
}
private async completeCrowdstrikeAction(
actionResponse: ActionTypeExecutorResult<CrowdstrikeBaseApiResponse> | undefined,
actionResponse: ActionTypeExecutorResult<CrowdstrikeBaseApiResponse>,
doc: LogsEndpointAction
): Promise<void> {
const options = {

View file

@ -0,0 +1,51 @@
/*
* 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 { mapParametersToCrowdStrikeArguments } from './utils';
describe('mapParametersToCrowdStrikeArguments', () => {
it('returns command with single word parameter as is', () => {
const result = mapParametersToCrowdStrikeArguments('runscript', { raw: 'echo Hello' });
expect(result).toBe('runscript --Raw=```echo Hello```');
});
it('wraps multi-word parameter in triple backticks', () => {
const result = mapParametersToCrowdStrikeArguments('runscript', {
commandLine: 'echo Hello World',
});
expect(result).toBe('runscript --CommandLine=```echo Hello World```');
});
it('leaves parameter already wrapped in triple backticks unchanged', () => {
const result = mapParametersToCrowdStrikeArguments('runscript', {
commandLine: '```echo Hello World```',
});
expect(result).toBe('runscript --CommandLine=```echo Hello World```');
});
it('trims spaces from parameter values', () => {
const result = mapParametersToCrowdStrikeArguments('runscript', { raw: ' echo Hello ' });
expect(result).toBe('runscript --Raw=```echo Hello```');
});
it('handles multiple parameters correctly', () => {
const result = mapParametersToCrowdStrikeArguments('runscript', {
raw: 'echo Hello',
commandLine: 'echo Hello World',
hostPath: '/home/user',
cloudFile: 'file.txt',
});
expect(result).toBe(
'runscript --Raw=```echo Hello``` --CommandLine=```echo Hello World``` --HostPath=/home/user --CloudFile=file.txt'
);
});
it('returns command with no parameters correctly', () => {
const result = mapParametersToCrowdStrikeArguments('runscript', {});
expect(result).toBe('runscript ');
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { upperFirst } from 'lodash';
import type { RunScriptActionRequestBody } from '../../../../../../common/api/endpoint';
export const mapParametersToCrowdStrikeArguments = (
commandName: string,
parameters: RunScriptActionRequestBody['parameters']
): string => {
// Map each parameter to the required syntax and join them with spaces
// In short: this function has to transform the parameters object into a string that can be used as a CS command
// One word commands eg. 'ls' can go as it is, but if there are more elements eg. 'ls -l', they have to be wrapped in triple backticks
const commandParts = Object.entries(parameters).map(([key, value]) => {
// Check and process the parameter value
let sanitizedValue;
if (typeof value === 'string') {
if (/^```.*```$/.test(value)) {
// If already wrapped in triple backticks, leave unchanged
sanitizedValue = value;
} else {
const strippedValue = value.trim(); // Remove spaces at the beginning and end
if (strippedValue.split(/\s+/).length === 1) {
// 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}\`\`\``;
}
}
} else {
sanitizedValue = value;
}
return `--${upperFirst(key)}=${sanitizedValue}`;
});
// Combine the base command with the constructed parameters
return `${commandName} ${commandParts.join(' ')}`;
};

View file

@ -248,7 +248,7 @@ const createRunScriptOptionsMock = (
const options: RunScriptActionRequestBody = {
...createNoParamsResponseActionOptionsMock(),
parameters: {
Raw: 'ls',
raw: 'ls',
},
};
return merge(options, overrides);