[EDR Workflows] Add RunScript CS Command - UI (#202012)

This commit is contained in:
Tomasz Ciecierski 2024-12-10 15:02:12 +01:00 committed by GitHub
parent 2818a7cc5a
commit 9b27804a9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 259 additions and 19 deletions

View file

@ -96,6 +96,7 @@ export const GET_FILE_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/get_file`;
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`;
/** Endpoint Actions Routes */
export const ENDPOINT_ACTION_LOG_ROUTE = `${BASE_ENDPOINT_ROUTE}/action_log/{agent_id}`;

View file

@ -147,6 +147,17 @@ export class EndpointActionGenerator extends BaseDataGenerator {
}
}
if (command === 'runscript') {
if (!output) {
output = {
type: 'json',
content: {
code: '200',
},
};
}
}
if (command === 'execute') {
if (!output) {
output = this.generateExecuteActionResponseOutput();

View file

@ -28,6 +28,7 @@ export const RESPONSE_ACTION_API_COMMANDS_NAMES = [
'execute',
'upload',
'scan',
'runscript',
] as const;
export type ResponseActionsApiCommandNames = (typeof RESPONSE_ACTION_API_COMMANDS_NAMES)[number];
@ -54,6 +55,7 @@ export const ENDPOINT_CAPABILITIES = [
'execute',
'upload_file',
'scan',
'runscript',
] as const;
export type EndpointCapabilities = (typeof ENDPOINT_CAPABILITIES)[number];
@ -72,6 +74,7 @@ export const CONSOLE_RESPONSE_ACTION_COMMANDS = [
'execute',
'upload',
'scan',
'runscript',
] as const;
export type ConsoleResponseActionCommands = (typeof CONSOLE_RESPONSE_ACTION_COMMANDS)[number];
@ -100,6 +103,7 @@ export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL: Record<
execute: 'writeExecuteOperations',
upload: 'writeFileOperations',
scan: 'writeScanOperations',
runscript: 'writeExecuteOperations',
});
export const RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP = Object.freeze<
@ -114,6 +118,7 @@ export const RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP = Object.freeze<
'suspend-process': 'suspend-process',
upload: 'upload',
scan: 'scan',
runscript: 'runscript',
});
export const RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP = Object.freeze<
@ -128,6 +133,7 @@ export const RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP = Object.freeze<
'suspend-process': 'suspend-process',
upload: 'upload',
scan: 'scan',
runscript: 'runscript',
});
export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_ENDPOINT_CAPABILITY = Object.freeze<
@ -142,6 +148,7 @@ export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_ENDPOINT_CAPABILITY = Object.fr
'suspend-process': 'suspend_process',
upload: 'upload_file',
scan: 'scan',
runscript: 'runscript',
});
/**
@ -159,6 +166,7 @@ export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ = Object.freeze<
'kill-process': 'canKillProcess',
'suspend-process': 'canSuspendProcess',
scan: 'canWriteScanOperations',
runscript: 'canWriteExecuteOperations',
});
// 4 hrs in seconds

View file

@ -126,6 +126,18 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = {
crowdstrike: false,
},
},
runscript: {
automated: {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
},
manual: {
endpoint: false,
sentinel_one: false,
crowdstrike: true,
},
},
};
/**

View file

@ -257,6 +257,12 @@ export const allowedExperimentalValues = Object.freeze({
* Enables the Defend Insights feature
*/
defendInsights: false,
/**
* Enables CrowdStrike's RunScript RTR command
*/
crowdstrikeRunScriptEnabled: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -215,6 +215,88 @@ export const CONSOLE_COMMANDS = {
},
};
export const CROWDSTRIKE_CONSOLE_COMMANDS = {
runscript: {
args: {
raw: {
about: i18n.translate(
'xpack.securitySolution.crowdStrikeConsoleCommands.runscript.args.raw.about',
{
defaultMessage: 'Raw script content',
}
),
},
cloudFile: {
about: i18n.translate(
'xpack.securitySolution.crowdStrikeConsoleCommands.runscript.args.cloudFile.about',
{
defaultMessage: 'Script name in cloud storage',
}
),
},
commandLine: {
about: i18n.translate(
'xpack.securitySolution.crowdStrikeConsoleCommands.runscript.args.commandLine.about',
{
defaultMessage: 'Command line arguments',
}
),
},
hostPath: {
about: i18n.translate(
'xpack.securitySolution.crowdStrikeConsoleCommands.runscript.args.hostPath.about',
{
defaultMessage: 'Absolute or relative path of script on host machine',
}
),
},
timeout: {
about: i18n.translate(
'xpack.securitySolution.crowdStrikeConsoleCommands.runscript.args.timeout.about',
{
defaultMessage: 'Timeout in seconds',
}
),
},
},
title: i18n.translate('xpack.securitySolution.crowdStrikeConsoleCommands.runscript.title', {
defaultMessage: 'Isolate',
}),
about: i18n.translate('xpack.securitySolution.crowdStrikeConsoleCommands.runscript.about', {
defaultMessage: 'Run a script on the host',
}),
helpUsage: i18n.translate('xpack.securitySolution.crowdStrikeConsoleCommands.runscript.about', {
defaultMessage: `
Command Examples for Running Scripts:
1. Executes a script saved in the CrowdStrike cloud with the specified command-line arguments.
runscript --CloudFile="CloudScript1.ps1" --CommandLine="-Verbose true"
2. Executes a script saved in the CrowdStrike cloud with the specified command-line arguments and a 180-second timeout.
runscript --CloudFile="CloudScript1.ps1" --CommandLine="-Verbose true" -Timeout=180
3. Executes a raw script provided entirely within the "--Raw" flag.
runscript --Raw="Get-ChildItem."
4. Executes a script located on the remote host at the specified path with the provided command-line arguments.
runscript --HostPath="C:\\temp\\LocalScript.ps1" --CommandLine="-Verbose true"
`,
}),
privileges: i18n.translate(
'xpack.securitySolution.crowdStrikeConsoleCommands.runscript.privileges',
{
defaultMessage:
'Insufficient privileges to run script. Contact your Kibana administrator if you think you should have this permission.',
}
),
},
};
export const CONFIRM_WARNING_MODAL_LABELS = (entryType: string) => {
return {
title: i18n.translate('xpack.securitySolution.artifacts.confirmWarningModal.title', {

View file

@ -33,6 +33,13 @@ export const CommandInputUsage = memo<Pick<CommandUsageProps, 'commandDef'>>(({
});
}, [commandDef]);
const helpExample = useMemo(() => {
if (commandDef.helpUsage) {
return commandDef.helpUsage;
}
return commandDef.exampleUsage;
}, [commandDef]);
return (
<>
<EuiDescriptionList
@ -55,7 +62,7 @@ export const CommandInputUsage = memo<Pick<CommandUsageProps, 'commandDef'>>(({
titleProps={additionalProps}
/>
<EuiSpacer size="s" />
{commandDef.exampleUsage && (
{helpExample && (
<EuiDescriptionList
compressed
type="column"
@ -69,7 +76,7 @@ export const CommandInputUsage = memo<Pick<CommandUsageProps, 'commandDef'>>(({
})}
</ConsoleCodeBlock>
),
description: <ConsoleCodeBlock>{commandDef.exampleUsage}</ConsoleCodeBlock>,
description: <ConsoleCodeBlock>{helpExample}</ConsoleCodeBlock>,
},
]}
descriptionProps={additionalProps}

View file

@ -49,7 +49,12 @@ export interface CommandArgDefinition {
* - `truthy`: The argument must have a value and the values must be "truthy" (evaluate to `Boolean` true)
*/
mustHaveValue?: boolean | 'non-empty-string' | 'number' | 'number-greater-than-zero' | 'truthy';
/**
* Specifies that one or more arguments might be required, but only one of them can be used at a time.
*/
exclusiveOr?: boolean;
/**
* Validate the individual values given to this argument.
* Should return `true` if valid or a string with the error message
@ -124,10 +129,17 @@ export interface CommandDefinition<TMeta = any> {
/**
* Displayed in the input hint area when the user types the command as well as in the output of
* this command's `--help`. This value will override the command usage generated by the console
* from the Command Definition.
* from the Command Definition. It's value displayed in `--help` would overriden by `helpUsage` if defined.
*/
exampleUsage?: string;
/**
* Displayed in the output of this command's `--help`.
* This value will override the command usage generated by the console
* from the Command Definition.
*/
helpUsage?: string;
/**
* Validate the command entered by the user. This is called only after the Console has ran
* through all of its builtin validations (based on `CommandDefinition`).

View file

@ -42,7 +42,7 @@ import {
import { getCommandAboutInfo } from './get_command_about_info';
import { validateUnitOfTime } from './utils';
import { CONSOLE_COMMANDS } from '../../../common/translations';
import { CONSOLE_COMMANDS, CROWDSTRIKE_CONSOLE_COMMANDS } from '../../../common/translations';
import { ScanActionResult } from '../command_render_components/scan_action';
const emptyArgumentValidator = (argData: ParsedArgData): true | string => {
@ -167,6 +167,7 @@ export const getEndpointConsoleCommands = ({
const featureFlags = ExperimentalFeaturesService.get();
const isUploadEnabled = featureFlags.responseActionUploadEnabled;
const crowdstrikeRunScriptEnabled = featureFlags.crowdstrikeRunScriptEnabled;
const doesEndpointSupportCommand = (commandName: ConsoleResponseActionCommands) => {
// Agent capabilities is only validated for Endpoint agent types
@ -523,6 +524,71 @@ export const getEndpointConsoleCommands = ({
privileges: endpointPrivileges,
}),
});
if (crowdstrikeRunScriptEnabled) {
consoleCommands.push({
name: 'runscript',
about: getCommandAboutInfo({
aboutInfo: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.about,
isSupported: doesEndpointSupportCommand('runscript'),
}),
RenderComponent: () => null,
meta: {
agentType,
endpointId: endpointAgentId,
capabilities: endpointCapabilities,
privileges: endpointPrivileges,
},
exampleUsage: `runscript --Raw=\`\`\`Get-ChildItem .\`\`\` -CommandLine=""`,
helpUsage: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.helpUsage,
exampleInstruction: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.about,
validate: capabilitiesAndPrivilegesValidator(agentType),
mustHaveArgs: true,
args: {
Raw: {
required: false,
allowMultiples: false,
about: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.args.raw.about,
mustHaveValue: 'non-empty-string',
exclusiveOr: true,
},
CloudFile: {
required: false,
allowMultiples: false,
about: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.args.cloudFile.about,
mustHaveValue: 'non-empty-string',
exclusiveOr: true,
},
CommandLine: {
required: false,
allowMultiples: false,
about: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.args.commandLine.about,
mustHaveValue: 'non-empty-string',
},
HostPath: {
required: false,
allowMultiples: false,
about: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.args.hostPath.about,
mustHaveValue: 'non-empty-string',
exclusiveOr: true,
},
Timeout: {
required: false,
allowMultiples: false,
about: CROWDSTRIKE_CONSOLE_COMMANDS.runscript.args.timeout.about,
mustHaveValue: 'number-greater-than-zero',
},
...commandCommentArgument(),
},
helpGroupLabel: HELP_GROUPS.responseActions.label,
helpGroupPosition: HELP_GROUPS.responseActions.position,
helpCommandPosition: 9,
helpDisabled: !doesEndpointSupportCommand('runscript'),
helpHidden: !getRbacControl({
commandName: 'runscript',
privileges: endpointPrivileges,
}),
});
}
switch (agentType) {
case 'sentinel_one':

View file

@ -73,7 +73,10 @@ describe('When displaying Endpoint Response Actions', () => {
HELP_GROUPS.responseActions.label
);
const expectedCommands: string[] = [...CONSOLE_RESPONSE_ACTION_COMMANDS];
const endpointCommands = CONSOLE_RESPONSE_ACTION_COMMANDS.filter(
(command) => command !== 'runscript'
);
const expectedCommands: string[] = [...endpointCommands];
// add status to the list of expected commands in that order
expectedCommands.splice(2, 0, 'status');
@ -149,6 +152,7 @@ describe('When displaying Endpoint Response Actions', () => {
beforeEach(() => {
(ExperimentalFeaturesService.get as jest.Mock).mockReturnValue({
responseActionsCrowdstrikeManualHostIsolationEnabled: true,
crowdstrikeRunScriptEnabled: true,
});
commands = getEndpointConsoleCommands({
agentType: 'crowdstrike',
@ -176,7 +180,7 @@ describe('When displaying Endpoint Response Actions', () => {
HELP_GROUPS.responseActions.label
);
expect(commandsInPanel).toEqual(['isolate', 'release']);
expect(commandsInPanel).toEqual(['isolate', 'release', 'runscript --Raw']);
});
});
});

View file

@ -35,12 +35,14 @@ export const ActionsLogFilter = memo(
isFlyout,
onChangeFilterOptions,
'data-test-subj': dataTestSubj,
'data-test-height': dataTestHeight,
}: {
filterName: ActionsLogPopupFilters;
typesFilters?: TypesFilters;
isFlyout: boolean;
onChangeFilterOptions?: (selectedOptions: string[]) => void;
'data-test-subj'?: string;
'data-test-height'?: number;
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
@ -263,6 +265,7 @@ export const ActionsLogFilter = memo(
data-test-subj={dataTestSubj}
>
<EuiSelectable
height={dataTestHeight}
aria-label={`${filterName}`}
emptyMessage={UX_MESSAGES.filterEmptyMessage(filterName)}
isLoading={isLoading}

View file

@ -37,6 +37,7 @@ export const ActionsLogFilters = memo(
onTimeChange,
showHostsFilter,
'data-test-subj': dataTestSubj,
'data-test-height': dataTestHeight,
}: {
dateRangePickerState: DateRangePickerValues;
isDataLoading: boolean;
@ -53,6 +54,7 @@ export const ActionsLogFilters = memo(
onClick: ReturnType<typeof useGetEndpointActionList>['refetch'];
showHostsFilter: boolean;
'data-test-subj'?: string;
'data-test-height'?: number;
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
@ -76,6 +78,7 @@ export const ActionsLogFilters = memo(
isFlyout={isFlyout}
onChangeFilterOptions={onChangeCommandsFilter}
data-test-subj={dataTestSubj}
data-test-height={dataTestHeight}
/>
<ActionsLogFilter
filterName={'statuses'}
@ -109,6 +112,7 @@ export const ActionsLogFilters = memo(
isSentinelOneV1Enabled,
onChangeHostsFilter,
dataTestSubj,
dataTestHeight,
onChangeCommandsFilter,
onChangeStatusesFilter,
onChangeAgentTypesFilter,

View file

@ -334,6 +334,10 @@ export const useActionsLogFilter = ({
return false;
}
if (commandName === 'runscript' && !featureFlags.crowdstrikeRunScriptEnabled) {
return false;
}
return true;
}).map((commandName) => ({
key: commandName,

View file

@ -1518,6 +1518,7 @@ describe('Response actions history', () => {
beforeEach(() => {
featureFlags = {
responseActionUploadEnabled: true,
crowdstrikeRunScriptEnabled: true,
};
mockedContext.setExperimentalFlag(featureFlags);
@ -1537,8 +1538,9 @@ describe('Response actions history', () => {
);
});
it('should show a list of actions (with `scan`) when opened', async () => {
render();
it('should show a list of actions (with `runscript`) when opened', async () => {
// Note: when we enable new commands, it might be needed to increase the height
render({ 'data-test-height': 350 });
const { getByTestId, getAllByTestId } = renderResult;
await user.click(getByTestId(`${testPrefix}-${filterPrefix}-popoverButton`));
@ -1557,6 +1559,7 @@ describe('Response actions history', () => {
'execute. To check this option, press Enter.',
'upload. To check this option, press Enter.',
'scan. To check this option, press Enter.',
'runscript. To check this option, press Enter.',
]);
});

View file

@ -38,6 +38,7 @@ export const ResponseActionsLog = memo<
isFlyout?: boolean;
setIsDataInResponse?: (isData: boolean) => void;
'data-test-subj'?: string;
'data-test-height'?: number;
}
>(
({
@ -46,6 +47,7 @@ export const ResponseActionsLog = memo<
isFlyout = true,
setIsDataInResponse,
'data-test-subj': dataTestSubj = 'response-actions-list',
'data-test-height': dataTestHeight,
}) => {
const { pagination: paginationFromUrlParams, setPagination: setPaginationOnUrlParams } =
useUrlPagination();
@ -295,6 +297,7 @@ export const ResponseActionsLog = memo<
onTimeChange={onTimeChange}
showHostsFilter={showHostNames}
data-test-subj={dataTestSubj}
data-test-height={dataTestHeight}
/>
{isFetched && !totalItemCount ? (
<ManagementEmptyStateWrapper>

View file

@ -63,7 +63,7 @@ describe(
// No access to response actions (except `unisolate`)
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
(apiName) => apiName !== 'unisolate'
(apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);
@ -88,7 +88,7 @@ describe(
// No access to response actions (except `unisolate`)
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
(apiName) => apiName !== 'unisolate'
(apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);

View file

@ -63,7 +63,7 @@ describe(
// No access to response actions (except `unisolate`)
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
(apiName) => apiName !== 'unisolate'
(apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);
@ -88,7 +88,7 @@ describe(
// No access to response actions (except `unisolate`)
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
(apiName) => apiName !== 'unisolate'
(apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);

View file

@ -70,7 +70,7 @@ describe(
}
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
(apiName) => apiName !== 'unisolate'
(apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);
@ -99,7 +99,7 @@ describe(
});
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
(apiName) => apiName !== 'unisolate'
(apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);

View file

@ -43,9 +43,7 @@ describe(
// This is not needed for this test, but it's a good example of
// how to enable experimental features in the Cypress tests.
// kbnServerArgs: [
// `--xpack.securitySolution.enableExperimental=${JSON.stringify([
// 'featureFlagName',
// ])}`,
// `--xpack.securitySolution.enableExperimental=${JSON.stringify(['featureFlagName'])}`,
// ],
},
},
@ -127,6 +125,8 @@ describe(
'get-file',
'upload',
'scan'
// TODO: currently not implemented for Endpoint
// 'runscript'
);
const deniedResponseActions = pick(consoleHelpPanelResponseActionsTestSubj, 'execute');

View file

@ -15,7 +15,8 @@ const TEST_SUBJ = Object.freeze({
});
export const getConsoleHelpPanelResponseActionTestSubj = (): Record<
ConsoleResponseActionCommands,
// TODO: currently runscript is not supported in Endpoint
Exclude<ConsoleResponseActionCommands, 'runscript'>,
string
> => {
return {

View file

@ -16,6 +16,7 @@ import {
GET_PROCESSES_ROUTE,
ISOLATE_HOST_ROUTE_V2,
KILL_PROCESS_ROUTE,
RUN_SCRIPT_ROUTE,
SCAN_ROUTE,
SUSPEND_PROCESS_ROUTE,
UNISOLATE_HOST_ROUTE_V2,
@ -274,6 +275,11 @@ export const ensureResponseActionAuthzAccess = (
Object.assign(apiPayload, { parameters: { path: 'scan/two' } });
break;
case 'runscript':
url = RUN_SCRIPT_ROUTE;
Object.assign(apiPayload, { parameters: { Raw: 'ls' } });
break;
default:
throw new Error(`Response action [${responseAction}] has no API payload defined`);
}

View file

@ -70,6 +70,11 @@ const COMMANDS_WITH_ACCESS_TO_FILES: CommandsWithFileAccess = deepFreeze<Command
sentinel_one: false,
crowdstrike: false,
},
runscript: {
endpoint: false,
sentinel_one: false,
crowdstrike: false,
},
});
/**

View file

@ -319,7 +319,7 @@ describe('When using `getActionList()', () => {
outputs: {
'agent-a': {
content: {
code: 'ra_scan_success_done',
code: '200',
},
type: 'json',
},

View file

@ -26,6 +26,7 @@ export const FEATURE_KEYS = {
SCAN: 'Scan files',
ALERTS_BY_PROCESS_ANCESTRY: 'Get related alerts by process ancestry',
ENDPOINT_EXCEPTIONS: 'Endpoint exceptions',
RUN_SCRIPT: 'Run script',
} as const;
export type FeatureKeys = keyof typeof FEATURE_KEYS;
@ -41,6 +42,7 @@ const RESPONSE_ACTIONS_FEATURE_KEY: Readonly<Record<ResponseActionsApiCommandNam
execute: 'EXECUTE',
upload: 'UPLOAD',
scan: 'SCAN',
runscript: 'RUN_SCRIPT',
};
export const getResponseActionFeatureKey = (