diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml
index 4017b766cc3ec..64095cce91b80 100644
--- a/oas_docs/output/kibana.serverless.yaml
+++ b/oas_docs/output/kibana.serverless.yaml
@@ -10991,6 +10991,39 @@ paths:
x-metaTags:
- content: Kibana, Elastic Cloud Serverless
name: product_name
+ /api/endpoint/action/cancel:
+ post:
+ description: Cancel a running or pending response action (Applies only to some agent types).
+ operationId: CancelAction
+ requestBody:
+ content:
+ application/json:
+ examples:
+ MicrosoftDefenderEndpoint:
+ summary: Cancel a response action on a Microsoft Defender for Endpoint host
+ value:
+ agent_type: microsoft_defender_endpoint
+ comment: Cancelling action due to change in requirements
+ endpoint_ids:
+ - ed518850-681a-4d60-bb98-e22640cae2a8
+ parameters:
+ id: 7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d
+ schema:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_CancelRouteRequestBody'
+ required: true
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_ResponseActionCreateSuccessResponse'
+ description: Successfully cancelled the response action
+ summary: Cancel a response action
+ tags:
+ - Security Endpoint Management API
+ x-metaTags:
+ - content: Kibana, Elastic Cloud Serverless
+ name: product_name
/api/endpoint/action/execute:
post:
description: Run a shell command on an endpoint.
@@ -69990,6 +70023,54 @@ components:
- microsoft_defender_endpoint
example: endpoint
type: string
+ Security_Endpoint_Management_API_CancelRouteRequestBody:
+ allOf:
+ - type: object
+ properties:
+ agent_type:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_AgentTypes'
+ alert_ids:
+ description: If this action is associated with any alerts, they can be specified here. The action will be logged in any cases associated with the specified alerts.
+ example:
+ - alert-id-1
+ - alert-id-2
+ items:
+ minLength: 1
+ type: string
+ minItems: 1
+ type: array
+ case_ids:
+ description: The IDs of cases where the action taken will be logged.
+ example:
+ - case-id-1
+ - case-id-2
+ items:
+ minLength: 1
+ type: string
+ minItems: 1
+ type: array
+ comment:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_Comment'
+ endpoint_ids:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_EndpointIds'
+ parameters:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_Parameters'
+ required:
+ - endpoint_ids
+ - type: object
+ properties:
+ parameters:
+ type: object
+ properties:
+ id:
+ description: ID of the response action to cancel
+ example: 7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d
+ minLength: 1
+ type: string
+ required:
+ - id
+ required:
+ - parameters
Security_Endpoint_Management_API_CloudFileScriptParameters:
type: object
properties:
diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml
index 591e21f68f20d..76577bb7b8d80 100644
--- a/oas_docs/output/kibana.yaml
+++ b/oas_docs/output/kibana.yaml
@@ -13976,6 +13976,46 @@ paths:
x-metaTags:
- content: Kibana
name: product_name
+ /api/endpoint/action/cancel:
+ post:
+ description: |-
+ **Spaces method and path for this operation:**
+
+
post /s/{space_id}/api/endpoint/action/cancel
+
+ Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information.
+
+ Cancel a running or pending response action (Applies only to some agent types).
+ operationId: CancelAction
+ requestBody:
+ content:
+ application/json:
+ examples:
+ MicrosoftDefenderEndpoint:
+ summary: Cancel a response action on a Microsoft Defender for Endpoint host
+ value:
+ agent_type: microsoft_defender_endpoint
+ comment: Cancelling action due to change in requirements
+ endpoint_ids:
+ - ed518850-681a-4d60-bb98-e22640cae2a8
+ parameters:
+ id: 7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d
+ schema:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_CancelRouteRequestBody'
+ required: true
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_ResponseActionCreateSuccessResponse'
+ description: Successfully cancelled the response action
+ summary: Cancel a response action
+ tags:
+ - Security Endpoint Management API
+ x-metaTags:
+ - content: Kibana
+ name: product_name
/api/endpoint/action/execute:
post:
description: |-
@@ -83039,6 +83079,54 @@ components:
- microsoft_defender_endpoint
example: endpoint
type: string
+ Security_Endpoint_Management_API_CancelRouteRequestBody:
+ allOf:
+ - type: object
+ properties:
+ agent_type:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_AgentTypes'
+ alert_ids:
+ description: If this action is associated with any alerts, they can be specified here. The action will be logged in any cases associated with the specified alerts.
+ example:
+ - alert-id-1
+ - alert-id-2
+ items:
+ minLength: 1
+ type: string
+ minItems: 1
+ type: array
+ case_ids:
+ description: The IDs of cases where the action taken will be logged.
+ example:
+ - case-id-1
+ - case-id-2
+ items:
+ minLength: 1
+ type: string
+ minItems: 1
+ type: array
+ comment:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_Comment'
+ endpoint_ids:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_EndpointIds'
+ parameters:
+ $ref: '#/components/schemas/Security_Endpoint_Management_API_Parameters'
+ required:
+ - endpoint_ids
+ - type: object
+ properties:
+ parameters:
+ type: object
+ properties:
+ id:
+ description: ID of the response action to cancel
+ example: 7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d
+ minLength: 1
+ type: string
+ required:
+ - id
+ required:
+ - parameters
Security_Endpoint_Management_API_CloudFileScriptParameters:
type: object
properties:
diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.gen.ts
new file mode 100644
index 0000000000000..0a2376da9e938
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.gen.ts
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+/*
+ * NOTICE: Do not edit this file manually.
+ * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
+ *
+ * info:
+ * title: Cancel Action Schema
+ * version: 2023-10-31
+ */
+
+import { z } from '@kbn/zod';
+
+import {
+ ResponseActionCreateSuccessResponse,
+ BaseActionSchema,
+} from '../../../model/schema/common.gen';
+
+export type CancelRouteRequestBody = z.infer;
+export const CancelRouteRequestBody = BaseActionSchema.merge(
+ z.object({
+ parameters: z.object({
+ /**
+ * ID of the response action to cancel
+ */
+ id: z.string().min(1),
+ }),
+ })
+);
+
+export type CancelActionRequestBody = z.infer;
+export const CancelActionRequestBody = CancelRouteRequestBody;
+export type CancelActionRequestBodyInput = z.input;
+
+export type CancelActionResponse = z.infer;
+export const CancelActionResponse = ResponseActionCreateSuccessResponse;
diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.schema.yaml
new file mode 100644
index 0000000000000..1906216faa11b
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.schema.yaml
@@ -0,0 +1,55 @@
+openapi: 3.0.0
+info:
+ title: Cancel Action Schema
+ version: '2023-10-31'
+ description: Schema for canceling response actions
+paths:
+ /api/endpoint/action/cancel:
+ post:
+ summary: Cancel a response action
+ operationId: CancelAction
+ description: Cancel a running or pending response action (Applies only to some agent types).
+ x-codegen-enabled: true
+ x-labels: [ess, serverless]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CancelRouteRequestBody'
+ examples:
+ MicrosoftDefenderEndpoint:
+ summary: Cancel a response action on a Microsoft Defender for Endpoint host
+ value:
+ endpoint_ids:
+ - 'ed518850-681a-4d60-bb98-e22640cae2a8'
+ agent_type: 'microsoft_defender_endpoint'
+ parameters:
+ id: '7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d'
+ comment: 'Cancelling action due to change in requirements'
+ responses:
+ '200':
+ description: Successfully cancelled the response action
+ content:
+ application/json:
+ schema:
+ $ref: '../../../model/schema/common.schema.yaml#/components/schemas/ResponseActionCreateSuccessResponse'
+components:
+ schemas:
+ CancelRouteRequestBody:
+ allOf:
+ - $ref: '../../../model/schema/common.schema.yaml#/components/schemas/BaseActionSchema'
+ - type: object
+ required:
+ - parameters
+ properties:
+ parameters:
+ required:
+ - id
+ type: object
+ properties:
+ id:
+ type: string
+ minLength: 1
+ description: ID of the response action to cancel
+ example: '7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d'
diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.ts
new file mode 100644
index 0000000000000..ae2e7c47e28aa
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/cancel.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { TypeOf } from '@kbn/config-schema';
+import { schema } from '@kbn/config-schema';
+import { BaseActionRequestSchema } from '../../common/base';
+
+const CancelActionRequestBodySchema = schema.object({
+ ...BaseActionRequestSchema,
+ parameters: schema.object({
+ id: schema.string({
+ minLength: 1,
+ validate: (value) => {
+ if (!value.trim().length) {
+ return 'id cannot be an empty string';
+ }
+ },
+ }),
+ }),
+});
+
+export const CancelActionRequestSchema = {
+ body: CancelActionRequestBodySchema,
+};
+
+export type CancelActionRequestBody = TypeOf;
diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/index.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/index.ts
new file mode 100644
index 0000000000000..2e9fae33d6cf0
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/actions/response_actions/cancel/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export * from './cancel';
diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/index.ts b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/index.ts
index af2df1d1ce09a..12285f737c18e 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/index.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/api/endpoint/index.ts
@@ -25,6 +25,7 @@ export * from './actions/response_actions/execute';
export * from './actions/response_actions/upload';
export * from './actions/response_actions/scan';
export * from './actions/response_actions/run_script';
+export * from './actions/response_actions/cancel';
export * from './metadata';
diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts
index 4eea1a88184fa..b3c678775623a 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts
@@ -126,6 +126,10 @@ import type {
EndpointGetActionsListRequestQueryInput,
EndpointGetActionsListResponse,
} from './endpoint/actions/list/list.gen';
+import type {
+ CancelActionRequestBodyInput,
+ CancelActionResponse,
+} from './endpoint/actions/response_actions/cancel/cancel.gen';
import type {
EndpointExecuteActionRequestBodyInput,
EndpointExecuteActionResponse,
@@ -568,6 +572,22 @@ If asset criticality records already exist for the specified entities, those rec
})
.catch(catchAxiosErrorFormatAndThrow);
}
+ /**
+ * Cancel a running or pending response action (Applies only to some agent types).
+ */
+ async cancelAction(props: CancelActionProps) {
+ this.log.info(`${new Date().toISOString()} Calling API CancelAction`);
+ return this.kbnClient
+ .request({
+ path: '/api/endpoint/action/cancel',
+ headers: {
+ [ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31',
+ },
+ method: 'POST',
+ body: props.body,
+ })
+ .catch(catchAxiosErrorFormatAndThrow);
+ }
/**
* Create a clean draft Timeline or Timeline template for the current user.
> info
@@ -3087,6 +3107,9 @@ export interface AlertsMigrationCleanupProps {
export interface BulkUpsertAssetCriticalityRecordsProps {
body: BulkUpsertAssetCriticalityRecordsRequestBodyInput;
}
+export interface CancelActionProps {
+ body: CancelActionRequestBodyInput;
+}
export interface CleanDraftTimelinesProps {
body: CleanDraftTimelinesRequestBodyInput;
}
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 65e3d6dbb14ef..10b49c9f2439f 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
@@ -98,6 +98,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 CANCEL_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/cancel`;
export const CUSTOM_SCRIPTS_ROUTE = `${BASE_INTERNAL_ENDPOINT_ACTION_ROUTE}/custom_scripts`;
/** Endpoint Actions Routes */
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/schema/actions.test.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/schema/actions.test.ts
index f8c377bd85ddf..45f874d17608f 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/schema/actions.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/schema/actions.test.ts
@@ -23,6 +23,7 @@ import {
ScanActionRequestSchema,
NoParametersRequestSchema,
RunScriptActionRequestSchema,
+ CancelActionRequestSchema,
} from '../../api/endpoint';
// NOTE: Even though schemas are kept in common/api/endpoint - we keep tests here, because common/api should import from outside
@@ -1111,4 +1112,98 @@ describe('actions schemas', () => {
});
});
});
+ describe('CancelActionRequestSchema', () => {
+ it('should validate valid cancel request with all base fields', () => {
+ expect(() => {
+ CancelActionRequestSchema.body.validate({
+ endpoint_ids: ['endpoint-123'],
+ comment: 'Cancelling action due to change in requirements',
+ agent_type: 'microsoft_defender_endpoint',
+ parameters: {
+ id: '12345678-1234-5678-9012-123456789012',
+ },
+ });
+ }).not.toThrow();
+ });
+
+ it('should validate minimal cancel request with only required fields', () => {
+ expect(() => {
+ CancelActionRequestSchema.body.validate({
+ parameters: {
+ id: '12345678-1234-5678-9012-123456789012',
+ },
+ endpoint_ids: ['endpoint-123'],
+ });
+ }).not.toThrow();
+ });
+
+ it('should reject empty id', () => {
+ expect(() => {
+ CancelActionRequestSchema.body.validate({
+ parameters: {
+ id: '',
+ },
+ endpoint_ids: ['endpoint-123'],
+ });
+ }).toThrow();
+ });
+
+ it('should reject whitespace-only id', () => {
+ expect(() => {
+ CancelActionRequestSchema.body.validate({
+ parameters: {
+ id: ' ',
+ },
+ endpoint_ids: ['endpoint-123'],
+ });
+ }).toThrow();
+ });
+
+ it('should reject missing id', () => {
+ expect(() => {
+ CancelActionRequestSchema.body.validate({
+ endpoint_ids: ['endpoint-123'],
+ comment: 'Cancel reason',
+ parameters: {},
+ });
+ }).toThrow();
+ });
+
+ it('should accept request with optional comment', () => {
+ expect(() => {
+ CancelActionRequestSchema.body.validate({
+ parameters: {
+ id: '12345678-1234-5678-9012-123456789012',
+ },
+ endpoint_ids: ['endpoint-123'],
+ comment: 'Cancelling due to policy change',
+ });
+ }).not.toThrow();
+ });
+
+ it('should accept request without comment', () => {
+ expect(() => {
+ CancelActionRequestSchema.body.validate({
+ parameters: {
+ id: '12345678-1234-5678-9012-123456789012',
+ },
+ endpoint_ids: ['endpoint-123'],
+ });
+ }).not.toThrow();
+ });
+
+ it('should accept request with alert_ids and case_ids', () => {
+ expect(() => {
+ CancelActionRequestSchema.body.validate({
+ parameters: {
+ id: '12345678-1234-5678-9012-123456789012',
+ },
+ endpoint_ids: ['endpoint-123'],
+ alert_ids: ['alert-456'],
+ case_ids: ['case-789'],
+ comment: 'Cancel with related alerts and cases',
+ });
+ }).not.toThrow();
+ });
+ });
});
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.test.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.test.ts
index 1c80989b3e163..5ca8b0e0ea91c 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.test.ts
@@ -26,15 +26,16 @@ describe('Endpoint Authz service', () => {
let fleetAuthz: FleetAuthz;
let userRoles: string[];
- const responseConsolePrivileges = CONSOLE_RESPONSE_ACTION_COMMANDS.slice().reduce<
- ResponseConsoleRbacControls[]
- >((acc, e) => {
- const item = RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL[e];
- if (!acc.includes(item)) {
- acc.push(item);
- }
- return acc;
- }, []);
+ const responseConsolePrivileges = CONSOLE_RESPONSE_ACTION_COMMANDS.slice()
+ .filter((cmd) => cmd !== 'cancel') // Exclude cancel as it uses dynamic permission checking
+ .reduce((acc, e) => {
+ const item =
+ RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL[e as Exclude];
+ if (!acc.includes(item)) {
+ acc.push(item);
+ }
+ return acc;
+ }, []);
beforeEach(() => {
licenseService = createLicenseServiceMock();
@@ -228,6 +229,7 @@ describe('Endpoint Authz service', () => {
['canManageGlobalArtifacts', ['writeGlobalArtifacts']],
// all dependent privileges are false and so it should be false
['canAccessResponseConsole', responseConsolePrivileges],
+ ['canCancelAction', responseConsolePrivileges],
])('%s should be false if `packagePrivilege.%s` is `false`', (auth, privileges) => {
privileges.forEach((privilege) => {
fleetAuthz.packagePrivileges!.endpoint.actions[privilege].executePackageAction = false;
@@ -282,6 +284,7 @@ describe('Endpoint Authz service', () => {
['canManageGlobalArtifacts', ['writeGlobalArtifacts']],
// all dependent privileges are false and so it should be false
['canAccessResponseConsole', responseConsolePrivileges],
+ ['canCancelAction', responseConsolePrivileges],
])(
'%s should be false if `packagePrivilege.%s` is `false` and user roles is undefined',
(auth, privileges) => {
@@ -362,6 +365,7 @@ describe('Endpoint Authz service', () => {
canSuspendProcess: false,
canGetRunningProcesses: false,
canAccessResponseConsole: false,
+ canCancelAction: false,
canWriteExecuteOperations: false,
canWriteScanOperations: false,
canWriteFileOperations: false,
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.ts
index d7d2f02cf34e2..411ab3120305f 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/authz.ts
@@ -10,7 +10,10 @@ import type { ENDPOINT_PRIVILEGES, FleetAuthz } from '@kbn/fleet-plugin/common';
import { omit } from 'lodash';
import type { Capabilities } from '@kbn/core-capabilities-common';
import type { ProductFeaturesService } from '../../../../server/lib/product_features_service';
-import { RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ } from '../response_actions/constants';
+import {
+ RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ,
+ CANCELLABLE_RESPONSE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ,
+} from '../response_actions/constants';
import type { LicenseService } from '../../../license';
import type { EndpointAuthz } from '../../types/authz';
import type { MaybeImmutable } from '../../types';
@@ -146,6 +149,7 @@ export const calculateEndpointAuthz = (
canSuspendProcess: canWriteProcessOperations && isEnterpriseLicense,
canGetRunningProcesses: canWriteProcessOperations && isEnterpriseLicense,
canAccessResponseConsole: false, // set further below
+ canCancelAction: false, // set further below
canWriteExecuteOperations: canWriteExecuteOperations && isEnterpriseLicense,
canWriteFileOperations: canWriteFileOperations && isEnterpriseLicense,
canWriteScanOperations: canWriteScanOperations && isEnterpriseLicense,
@@ -177,7 +181,7 @@ export const calculateEndpointAuthz = (
};
// Response console is only accessible when license is Enterprise and user has access to any
- // of the response actions except `release`. Sole access to `release` is something
+ // of the response actions except `release``. Sole access to `release` is something
// that is supported for a user in a license downgrade scenario, and in that case, we don't want
// to allow access to Response Console.
authz.canAccessResponseConsole =
@@ -188,6 +192,15 @@ export const calculateEndpointAuthz = (
}
);
+ // Cancel actions are accessible when user has access to any cancellable response action.
+ // This follows the same pattern as canAccessResponseConsole, ensuring users can only cancel
+ // actions they have permission to execute.
+ authz.canCancelAction = Object.values(
+ CANCELLABLE_RESPONSE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ
+ ).some((responseActionAuthzKey) => {
+ return authz[responseActionAuthzKey];
+ });
+
return authz;
};
@@ -215,6 +228,7 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => {
canSuspendProcess: false,
canGetRunningProcesses: false,
canAccessResponseConsole: false,
+ canCancelAction: false,
canWriteFileOperations: false,
canWriteExecuteOperations: false,
canWriteScanOperations: false,
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/cancel_authz_utils.test.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/cancel_authz_utils.test.ts
new file mode 100644
index 0000000000000..699925a91f026
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/cancel_authz_utils.test.ts
@@ -0,0 +1,331 @@
+/*
+ * 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 { EndpointAuthz } from '../../types/authz';
+import type { ExperimentalFeatures } from '../../../experimental_features';
+import type {
+ ResponseActionAgentType,
+ ResponseActionsApiCommandNames,
+} from '../response_actions/constants';
+import {
+ canUserCancelCommand,
+ isCancelFeatureAvailable,
+ checkCancelPermission,
+} from './cancel_authz_utils';
+import { isActionSupportedByAgentType } from '../response_actions/is_response_action_supported';
+import { getEndpointAuthzInitialState } from './authz';
+import { allowedExperimentalValues } from '../../../experimental_features';
+
+describe('cancel authorization utilities', () => {
+ let mockAuthz: EndpointAuthz;
+ let mockExperimentalFeatures: ExperimentalFeatures;
+
+ beforeEach(() => {
+ mockAuthz = {
+ ...getEndpointAuthzInitialState(),
+ canReadSecuritySolution: true,
+ canWriteSecuritySolution: true,
+ canIsolateHost: true,
+ canUnIsolateHost: true,
+ canKillProcess: true,
+ canSuspendProcess: true,
+ canGetRunningProcesses: true,
+ canWriteFileOperations: true,
+ canWriteExecuteOperations: true,
+ canWriteScanOperations: true,
+ canAccessResponseConsole: true, // Response action permissions
+ canCancelAction: true, // Response action permissions
+ };
+
+ mockExperimentalFeatures = {
+ ...allowedExperimentalValues,
+ microsoftDefenderEndpointCancelEnabled: true,
+ } as ExperimentalFeatures;
+ });
+
+ describe('isCancelFeatureAvailable', () => {
+ it('should return true when all conditions are met', () => {
+ const result = isCancelFeatureAvailable(
+ mockAuthz,
+ mockExperimentalFeatures,
+ 'microsoft_defender_endpoint'
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return false when user lacks base response console access', () => {
+ // Remove all response action permissions to make canCancelAction false
+ const restrictedAuthz = {
+ ...getEndpointAuthzInitialState(),
+ canReadSecuritySolution: true, // Basic read access only
+ // No response action permissions means canCancelAction would be false
+ };
+ const result = isCancelFeatureAvailable(
+ restrictedAuthz,
+ mockExperimentalFeatures,
+ 'microsoft_defender_endpoint'
+ );
+ expect(result).toBe(false);
+ });
+
+ it('should return false when Microsoft Defender Endpoint cancel feature is disabled', () => {
+ const disabledFeatures = {
+ ...allowedExperimentalValues,
+ microsoftDefenderEndpointCancelEnabled: false,
+ } as ExperimentalFeatures;
+ const result = isCancelFeatureAvailable(
+ mockAuthz,
+ disabledFeatures,
+ 'microsoft_defender_endpoint'
+ );
+ expect(result).toBe(false);
+ });
+
+ it('should return false for unsupported agent types', () => {
+ const unsupportedAgentTypes: ResponseActionAgentType[] = [
+ 'endpoint',
+ 'sentinel_one',
+ 'crowdstrike',
+ ];
+ unsupportedAgentTypes.forEach((agentType) => {
+ const result = isCancelFeatureAvailable(mockAuthz, mockExperimentalFeatures, agentType);
+ expect(result).toBe(false);
+ });
+ });
+ });
+
+ describe('checkCancelPermission', () => {
+ it('should return true when all conditions are met', () => {
+ const result = checkCancelPermission(
+ mockAuthz,
+ mockExperimentalFeatures,
+ 'microsoft_defender_endpoint',
+ 'isolate'
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return false when feature is not available', () => {
+ const disabledFeatures = {
+ ...allowedExperimentalValues,
+ microsoftDefenderEndpointCancelEnabled: false,
+ } as ExperimentalFeatures;
+ const result = checkCancelPermission(
+ mockAuthz,
+ disabledFeatures,
+ 'microsoft_defender_endpoint',
+ 'isolate'
+ );
+ expect(result).toBe(false);
+ });
+
+ it('should return false when user lacks command permission', () => {
+ mockAuthz.canIsolateHost = false;
+ const result = checkCancelPermission(
+ mockAuthz,
+ mockExperimentalFeatures,
+ 'microsoft_defender_endpoint',
+ 'isolate'
+ );
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('agent type support (via isActionSupportedByAgentType)', () => {
+ it('should return true for microsoft_defender_endpoint', () => {
+ const result = isActionSupportedByAgentType(
+ 'microsoft_defender_endpoint',
+ 'cancel',
+ 'manual'
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return false for endpoint agent', () => {
+ const result = isActionSupportedByAgentType('endpoint', 'cancel', 'manual');
+ expect(result).toBe(false);
+ });
+
+ it('should return false for sentinel_one agent', () => {
+ const result = isActionSupportedByAgentType('sentinel_one', 'cancel', 'manual');
+ expect(result).toBe(false);
+ });
+
+ it('should return false for crowdstrike agent', () => {
+ const result = isActionSupportedByAgentType('crowdstrike', 'cancel', 'manual');
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('canUserCancelCommand', () => {
+ const commandPermissionTests: Array<{
+ command: ResponseActionsApiCommandNames;
+ permission: keyof EndpointAuthz;
+ }> = [
+ { command: 'isolate', permission: 'canIsolateHost' },
+ { command: 'unisolate', permission: 'canUnIsolateHost' },
+ { command: 'kill-process', permission: 'canKillProcess' },
+ { command: 'suspend-process', permission: 'canSuspendProcess' },
+ { command: 'running-processes', permission: 'canGetRunningProcesses' },
+ { command: 'get-file', permission: 'canWriteFileOperations' },
+ { command: 'execute', permission: 'canWriteExecuteOperations' },
+ { command: 'upload', permission: 'canWriteFileOperations' },
+ { command: 'scan', permission: 'canWriteScanOperations' },
+ { command: 'runscript', permission: 'canWriteExecuteOperations' },
+ ];
+
+ commandPermissionTests.forEach(({ command, permission }) => {
+ it(`should return true when user has ${permission} for ${command}`, () => {
+ const result = canUserCancelCommand(mockAuthz, command);
+ expect(result).toBe(true);
+ });
+
+ it(`should return false when user lacks ${permission} for ${command}`, () => {
+ const restrictedAuthz = {
+ ...mockAuthz,
+ [permission]: false,
+ };
+
+ const result = canUserCancelCommand(restrictedAuthz, command);
+ expect(result).toBe(false);
+ });
+ });
+ });
+
+ describe('integration scenarios', () => {
+ it('should handle complete workflow for valid cancel request', () => {
+ // Scenario: User wants to cancel an isolate command for MDE agent
+ const agentType = 'microsoft_defender_endpoint';
+ const command = 'isolate';
+
+ // Check overall permission
+ const result = checkCancelPermission(mockAuthz, mockExperimentalFeatures, agentType, command);
+ const canCancel = result;
+
+ // Verify individual checks
+ const hasBaseAccess = mockAuthz.canCancelAction;
+ const featureEnabled = mockExperimentalFeatures.microsoftDefenderEndpointCancelEnabled;
+ const agentSupportsCancel = isActionSupportedByAgentType(agentType, 'cancel', 'manual');
+ const hasCommandPermission = canUserCancelCommand(mockAuthz, command);
+
+ expect(hasBaseAccess).toBe(true);
+ expect(featureEnabled).toBe(true);
+ expect(agentSupportsCancel).toBe(true);
+ expect(hasCommandPermission).toBe(true);
+ expect(canCancel).toBe(true);
+ });
+
+ it('should deny cancel when any condition fails', () => {
+ const scenarios = [
+ {
+ name: 'no response console access',
+ setup: () => {
+ // Remove all response action permissions to simulate no console access
+ mockAuthz = {
+ ...getEndpointAuthzInitialState(),
+ canReadSecuritySolution: true, // Basic read access only
+ canCancelAction: false,
+ };
+ },
+ },
+ {
+ name: 'feature disabled',
+ setup: () => {
+ mockExperimentalFeatures = {
+ ...allowedExperimentalValues,
+ microsoftDefenderEndpointCancelEnabled: false,
+ } as ExperimentalFeatures;
+ },
+ },
+ {
+ name: 'unsupported agent type',
+ setup: () => {
+ // Will use 'endpoint' instead of 'microsoft_defender_endpoint'
+ },
+ },
+ {
+ name: 'insufficient command permission',
+ setup: () => {
+ mockAuthz.canIsolateHost = false;
+ },
+ },
+ ];
+
+ scenarios.forEach(({ name, setup }) => {
+ // Reset to clean state with minimal permissions for console access
+ mockAuthz = {
+ ...getEndpointAuthzInitialState(),
+ canIsolateHost: true, // Provides base response action capability
+ canCancelAction: true, // Would be calculated as true
+ };
+ mockExperimentalFeatures = {
+ ...allowedExperimentalValues,
+ microsoftDefenderEndpointCancelEnabled: true,
+ };
+
+ setup();
+
+ const agentType =
+ name === 'unsupported agent type' ? 'endpoint' : 'microsoft_defender_endpoint';
+ const result = checkCancelPermission(
+ mockAuthz,
+ mockExperimentalFeatures,
+ agentType as ResponseActionAgentType,
+ 'isolate'
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ it('should handle feature availability check separately', () => {
+ const result = isCancelFeatureAvailable(
+ mockAuthz,
+ mockExperimentalFeatures,
+ 'microsoft_defender_endpoint'
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('should handle edge case with minimal permissions', () => {
+ // Test with just enough permissions to enable response console access
+ const minimalAuthz: EndpointAuthz = {
+ ...getEndpointAuthzInitialState(),
+ canIsolateHost: true, // Minimal response action permission
+ canCancelAction: true, // Would be calculated as true due to canIsolateHost + Enterprise license
+ };
+
+ const result = isCancelFeatureAvailable(
+ minimalAuthz,
+ mockExperimentalFeatures,
+ 'microsoft_defender_endpoint'
+ );
+
+ expect(result).toBe(true); // Feature should be available
+ });
+
+ it('should deny specific command cancel with minimal permissions', () => {
+ // Test user with response console access but lacking specific command permission
+ const minimalAuthz: EndpointAuthz = {
+ ...getEndpointAuthzInitialState(),
+ canKillProcess: true, // Has some response action permission (for console access)
+ canCancelAction: true, // Would be true due to canKillProcess
+ canIsolateHost: false, // No isolate permission
+ };
+
+ const result = checkCancelPermission(
+ minimalAuthz,
+ mockExperimentalFeatures,
+ 'microsoft_defender_endpoint',
+ 'isolate'
+ );
+
+ expect(result).toBe(false); // Should fail for isolate command
+ });
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/cancel_authz_utils.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/cancel_authz_utils.ts
new file mode 100644
index 0000000000000..76709bb8fc79c
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/authz/cancel_authz_utils.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 { EndpointAuthz } from '../../types/authz';
+import type { ExperimentalFeatures } from '../../../experimental_features';
+import type {
+ ResponseActionAgentType,
+ ResponseActionsApiCommandNames,
+} from '../response_actions/constants';
+import {
+ RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ,
+ RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP,
+} from '../response_actions/constants';
+import { isActionSupportedByAgentType } from '../response_actions/is_response_action_supported';
+
+/**
+ * Checks if cancel operations are available for the given agent type and environment.
+ * This is a general capability check, not command-specific.
+ *
+ * Uses `canCancelAction` as the base permission because cancel operations require
+ * meaningful response action capabilities (not just read access). This permission is calculated
+ * based on having least one non-release/non-cancel response action permission.
+ *
+ * @param authz - The user's endpoint authorization permissions
+ * @param featureFlags - Experimental features configuration
+ * @param agentType - The agent type (endpoint, sentinel_one, crowdstrike, microsoft_defender_endpoint)
+ * @returns true if cancel feature is available for the agent type
+ */
+export const isCancelFeatureAvailable = (
+ authz: EndpointAuthz,
+ featureFlags: ExperimentalFeatures,
+ agentType: ResponseActionAgentType
+): boolean => {
+ // Check base access to security solution
+ if (!authz.canCancelAction) {
+ return false;
+ }
+
+ // Check agent type
+ if (agentType !== 'microsoft_defender_endpoint') {
+ return false;
+ }
+
+ // Check if Microsoft Defender Endpoint cancel feature is enabled
+ if (!featureFlags.microsoftDefenderEndpointCancelEnabled) {
+ return false;
+ }
+
+ // Check if agent type supports cancel operations
+ return isActionSupportedByAgentType(agentType, 'cancel', 'manual');
+};
+
+/**
+ * Checks if user can cancel a specific command.
+ * Assumes cancel feature is available (checked separately).
+ *
+ * @param authz - The user's endpoint authorization permissions
+ * @param command - The response action command being cancelled
+ * @returns true if the user has permission to cancel the specified command
+ */
+export const canUserCancelCommand = (
+ authz: EndpointAuthz,
+ command: ResponseActionsApiCommandNames
+): boolean => {
+ const consoleCommand = RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP[command];
+ const requiredAuthzKey = RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ[consoleCommand];
+ return authz[requiredAuthzKey];
+};
+
+/**
+ * Complete check for cancel permission
+ * Combines feature availability and command-specific permission.
+ *
+ * @param authz - The user's endpoint authorization permissions
+ * @param featureFlags - Experimental features configuration
+ * @param agentType - The agent type
+ * @param command - The response action command being cancelled
+ * @returns true if the user has permission to cancel the command
+ */
+export const checkCancelPermission = (
+ authz: EndpointAuthz,
+ featureFlags: ExperimentalFeatures,
+ agentType: ResponseActionAgentType,
+ command: ResponseActionsApiCommandNames
+): boolean => {
+ return (
+ isCancelFeatureAvailable(authz, featureFlags, agentType) && canUserCancelCommand(authz, command)
+ );
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts
index 7e725b3921ba1..d6f631cef9e5e 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/constants.ts
@@ -35,6 +35,7 @@ export const RESPONSE_ACTION_API_COMMANDS_NAMES = [
'upload',
'scan',
'runscript',
+ 'cancel',
] as const;
export type ResponseActionsApiCommandNames = (typeof RESPONSE_ACTION_API_COMMANDS_NAMES)[number];
@@ -62,6 +63,7 @@ export const ENDPOINT_CAPABILITIES = [
'upload_file',
'scan',
'runscript',
+ 'cancel',
] as const;
export type EndpointCapabilities = (typeof ENDPOINT_CAPABILITIES)[number];
@@ -81,6 +83,7 @@ export const CONSOLE_RESPONSE_ACTION_COMMANDS = [
'upload',
'scan',
'runscript',
+ 'cancel',
] as const;
export type ConsoleResponseActionCommands = (typeof CONSOLE_RESPONSE_ACTION_COMMANDS)[number];
@@ -95,9 +98,10 @@ export type ResponseConsoleRbacControls =
/**
* maps the console command to the RBAC control (kibana feature control) that is required to access it via console
+ * Note: 'cancel' command is excluded as it uses dynamic permission checking via utility functions
*/
export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL: Record<
- ConsoleResponseActionCommands,
+ Exclude,
ResponseConsoleRbacControls
> = Object.freeze({
isolate: 'writeHostIsolation',
@@ -125,6 +129,7 @@ export const RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP = Object.freeze<
upload: 'upload',
scan: 'scan',
runscript: 'runscript',
+ cancel: 'cancel',
});
export const RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP = Object.freeze<
@@ -140,6 +145,7 @@ export const RESPONSE_CONSOLE_COMMAND_TO_API_COMMAND_MAP = Object.freeze<
upload: 'upload',
scan: 'scan',
runscript: 'runscript',
+ cancel: 'cancel',
});
export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_ENDPOINT_CAPABILITY = Object.freeze<
@@ -155,6 +161,7 @@ export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_ENDPOINT_CAPABILITY = Object.fr
upload: 'upload_file',
scan: 'scan',
runscript: 'runscript',
+ cancel: 'cancel',
});
/**
@@ -173,6 +180,27 @@ export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ = Object.freeze<
'suspend-process': 'canSuspendProcess',
scan: 'canWriteScanOperations',
runscript: 'canWriteExecuteOperations',
+ cancel: 'canCancelAction', // Cancel uses specific cancel permission
+});
+
+/**
+ * The list of actions that can be cancelled, mapped to their required authorization.
+ * Used to calculate if a user has permission to cancel any response actions.
+ */
+export const CANCELLABLE_RESPONSE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ = Object.freeze<
+ Record
+>({
+ isolate: 'canIsolateHost',
+ release: 'canUnIsolateHost',
+ execute: 'canWriteExecuteOperations',
+ 'get-file': 'canWriteFileOperations',
+ upload: 'canWriteFileOperations',
+ processes: 'canGetRunningProcesses',
+ 'kill-process': 'canKillProcess',
+ 'suspend-process': 'canSuspendProcess',
+ scan: 'canWriteScanOperations',
+ runscript: 'canWriteExecuteOperations',
+ cancel: 'canCancelAction',
});
// 4 hrs in seconds
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/is_response_action_supported.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/is_response_action_supported.ts
index 74bea51b09774..314e73d3c8dc5 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/is_response_action_supported.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/service/response_actions/is_response_action_supported.ts
@@ -158,6 +158,20 @@ const RESPONSE_ACTIONS_SUPPORT_MAP: SupportMap = {
microsoft_defender_endpoint: true,
},
},
+ cancel: {
+ automated: {
+ endpoint: false,
+ sentinel_one: false,
+ crowdstrike: false,
+ microsoft_defender_endpoint: false,
+ },
+ manual: {
+ endpoint: false,
+ sentinel_one: false,
+ crowdstrike: false,
+ microsoft_defender_endpoint: true,
+ },
+ },
};
/**
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/actions.ts
index 76b415aa3ec04..a843c97a78d39 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/actions.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/actions.ts
@@ -103,6 +103,10 @@ export interface ResponseActionRunScriptOutputContent {
code: string;
}
+export interface ResponseActionCancelOutputContent {
+ code: string;
+}
+
export const ActivityLogItemTypes = {
ACTION: 'action' as const,
RESPONSE: 'response' as const,
@@ -253,6 +257,10 @@ export interface ResponseActionScanParameters {
path: string;
}
+export interface ResponseActionCancelParameters {
+ id: string;
+}
+
export type ResponseActionRunScriptParameters = RunScriptActionRequestBody['parameters'];
export type EndpointActionDataParameterTypes =
@@ -262,7 +270,8 @@ export type EndpointActionDataParameterTypes =
| ResponseActionGetFileParameters
| ResponseActionUploadParameters
| ResponseActionScanParameters
- | ResponseActionRunScriptParameters;
+ | ResponseActionRunScriptParameters
+ | ResponseActionCancelParameters;
/** Output content of the different response actions */
export type EndpointActionResponseDataOutput =
@@ -274,7 +283,8 @@ export type EndpointActionResponseDataOutput =
| SuspendProcessActionOutputContent
| KillProcessActionOutputContent
| ResponseActionScanOutputContent
- | ResponseActionRunScriptOutputContent;
+ | ResponseActionRunScriptOutputContent
+ | ResponseActionCancelOutputContent;
/**
* The data stored with each Response Action under `EndpointActions.data` property
diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/authz.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/authz.ts
index bdc25b8b1940a..e6baa89a29eb3 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/authz.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/authz.ts
@@ -54,6 +54,8 @@ export interface EndpointAuthz {
canGetRunningProcesses: boolean;
/** If the user has permissions to use the Response Actions Console */
canAccessResponseConsole: boolean;
+ /** If the user has permissions to use the Cancel Response Action */
+ canCancelAction: boolean;
/** If the user has write permissions to use execute action */
canWriteExecuteOperations: boolean;
/** If the user has write permissions to use file operations */
diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts
index 936af31e2af9d..e95972a2834bb 100644
--- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts
+++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts
@@ -298,7 +298,7 @@ export const allowedExperimentalValues = Object.freeze({
newDataViewPickerEnabled: false,
/**
- * Enables Microsoft Defender for Endpoint's RunScript RTR command
+ * Enables Microsoft Defender for Endpoint's RunScript command
* Release: 8.19/9.1
*/
microsoftDefenderEndpointRunScriptEnabled: true,
@@ -323,6 +323,11 @@ export const allowedExperimentalValues = Object.freeze({
* Enables the SIEM Readiness Dashboard feature
*/
siemReadinessDashboard: false,
+ /**
+ * Enables Microsoft Defender for Endpoint's Cancel command
+ * Release: 9.2.0
+ */
+ microsoftDefenderEndpointCancelEnabled: false,
});
type ExperimentalConfigKeys = Array;
diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml
index 31e276d0485a1..7bb7b90e8408f 100644
--- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml
+++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml
@@ -187,6 +187,40 @@ paths:
summary: Download a file
tags:
- Security Endpoint Management API
+ /api/endpoint/action/cancel:
+ post:
+ description: >-
+ Cancel a running or pending response action (Applies only to some agent
+ types).
+ operationId: CancelAction
+ requestBody:
+ content:
+ application/json:
+ examples:
+ MicrosoftDefenderEndpoint:
+ summary: >-
+ Cancel a response action on a Microsoft Defender for Endpoint
+ host
+ value:
+ agent_type: microsoft_defender_endpoint
+ comment: Cancelling action due to change in requirements
+ endpoint_ids:
+ - ed518850-681a-4d60-bb98-e22640cae2a8
+ parameters:
+ id: 7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d
+ schema:
+ $ref: '#/components/schemas/CancelRouteRequestBody'
+ required: true
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ResponseActionCreateSuccessResponse'
+ description: Successfully cancelled the response action
+ summary: Cancel a response action
+ tags:
+ - Security Endpoint Management API
/api/endpoint/action/execute:
post:
description: Run a shell command on an endpoint.
@@ -742,6 +776,57 @@ components:
- microsoft_defender_endpoint
example: endpoint
type: string
+ CancelRouteRequestBody:
+ allOf:
+ - type: object
+ properties:
+ agent_type:
+ $ref: '#/components/schemas/AgentTypes'
+ alert_ids:
+ description: >-
+ If this action is associated with any alerts, they can be
+ specified here. The action will be logged in any cases
+ associated with the specified alerts.
+ example:
+ - alert-id-1
+ - alert-id-2
+ items:
+ minLength: 1
+ type: string
+ minItems: 1
+ type: array
+ case_ids:
+ description: The IDs of cases where the action taken will be logged.
+ example:
+ - case-id-1
+ - case-id-2
+ items:
+ minLength: 1
+ type: string
+ minItems: 1
+ type: array
+ comment:
+ $ref: '#/components/schemas/Comment'
+ endpoint_ids:
+ $ref: '#/components/schemas/EndpointIds'
+ parameters:
+ $ref: '#/components/schemas/Parameters'
+ required:
+ - endpoint_ids
+ - type: object
+ properties:
+ parameters:
+ type: object
+ properties:
+ id:
+ description: ID of the response action to cancel
+ example: 7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d
+ minLength: 1
+ type: string
+ required:
+ - id
+ required:
+ - parameters
CloudFileScriptParameters:
type: object
properties:
diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml
index 46eed01c4057d..f38b2b30af1d8 100644
--- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml
+++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_endpoint_management_api_2023_10_31.bundled.schema.yaml
@@ -187,6 +187,40 @@ paths:
summary: Download a file
tags:
- Security Endpoint Management API
+ /api/endpoint/action/cancel:
+ post:
+ description: >-
+ Cancel a running or pending response action (Applies only to some agent
+ types).
+ operationId: CancelAction
+ requestBody:
+ content:
+ application/json:
+ examples:
+ MicrosoftDefenderEndpoint:
+ summary: >-
+ Cancel a response action on a Microsoft Defender for Endpoint
+ host
+ value:
+ agent_type: microsoft_defender_endpoint
+ comment: Cancelling action due to change in requirements
+ endpoint_ids:
+ - ed518850-681a-4d60-bb98-e22640cae2a8
+ parameters:
+ id: 7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d
+ schema:
+ $ref: '#/components/schemas/CancelRouteRequestBody'
+ required: true
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ResponseActionCreateSuccessResponse'
+ description: Successfully cancelled the response action
+ summary: Cancel a response action
+ tags:
+ - Security Endpoint Management API
/api/endpoint/action/execute:
post:
description: Run a shell command on an endpoint.
@@ -742,6 +776,57 @@ components:
- microsoft_defender_endpoint
example: endpoint
type: string
+ CancelRouteRequestBody:
+ allOf:
+ - type: object
+ properties:
+ agent_type:
+ $ref: '#/components/schemas/AgentTypes'
+ alert_ids:
+ description: >-
+ If this action is associated with any alerts, they can be
+ specified here. The action will be logged in any cases
+ associated with the specified alerts.
+ example:
+ - alert-id-1
+ - alert-id-2
+ items:
+ minLength: 1
+ type: string
+ minItems: 1
+ type: array
+ case_ids:
+ description: The IDs of cases where the action taken will be logged.
+ example:
+ - case-id-1
+ - case-id-2
+ items:
+ minLength: 1
+ type: string
+ minItems: 1
+ type: array
+ comment:
+ $ref: '#/components/schemas/Comment'
+ endpoint_ids:
+ $ref: '#/components/schemas/EndpointIds'
+ parameters:
+ $ref: '#/components/schemas/Parameters'
+ required:
+ - endpoint_ids
+ - type: object
+ properties:
+ parameters:
+ type: object
+ properties:
+ id:
+ description: ID of the response action to cancel
+ example: 7f8c9b2a-4d3e-4f5a-8b1c-2e3f4a5b6c7d
+ minLength: 1
+ type: string
+ required:
+ - id
+ required:
+ - parameters
CloudFileScriptParameters:
type: object
properties:
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/__mocks__/experimental_features_service.ts b/x-pack/solutions/security/plugins/security_solution/public/common/__mocks__/experimental_features_service.ts
index 4e8a6b6ba4987..e8849ef766a5f 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/__mocks__/experimental_features_service.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/__mocks__/experimental_features_service.ts
@@ -17,6 +17,7 @@ const ExperimentalFeaturesServiceMock = {
// add new experimental features set to `true` here
// e.g.
// responseActionDownloadFileEnabled: true,
+ microsoftDefenderEndpointCancelEnabled: true,
};
return ff;
diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts
index 116df0d93d260..c53306ae807b1 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts
@@ -85,7 +85,9 @@ describe('When using useEndpointPrivileges hook', () => {
(useCurrentUser as jest.Mock).mockReturnValue(authenticatedUser);
rerender();
- expect(result.current).toEqual(getEndpointPrivilegesInitialStateMock());
+ expect(result.current).toEqual({
+ ...getEndpointPrivilegesInitialStateMock(),
+ });
});
it('should return initial state when no user authz', async () => {
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/common/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/management/common/translations.ts
index 8cd5f13380fe5..a0ebf730c11b5 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/common/translations.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/common/translations.ts
@@ -59,6 +59,58 @@ export const getLoadPoliciesError = (error: Error) => {
});
};
+export const ERROR_LOADING_CUSTOM_SCRIPTS = i18n.translate(
+ 'xpack.securitySolution.consoleArgumentSelectors.customScripts.errorLoading',
+ {
+ defaultMessage: 'Error loading custom scripts',
+ }
+);
+
+export const ERROR_LOADING_PENDING_ACTIONS = i18n.translate(
+ 'xpack.securitySolution.consoleArgumentSelectors.pendingActions.errorLoading',
+ {
+ defaultMessage: 'Error loading pending actions',
+ }
+);
+
+export const getGenericErrorMessage = (errorTitlePrefix: string, code: string) => {
+ return i18n.translate('xpack.securitySolution.consoleArgumentSelectors.genericError', {
+ defaultMessage: '{prefix}Error {code}',
+ values: { prefix: errorTitlePrefix ? `${errorTitlePrefix}: ` : '', code },
+ });
+};
+
+export const getCancelPermissionDeniedMessage = (displayCommand: string) => {
+ return i18n.translate('xpack.securitySolution.consoleArgumentSelectors.cancel.permissionDenied', {
+ defaultMessage: "You don't have permission to run {displayCommand} action.",
+ values: { displayCommand },
+ });
+};
+
+export const getPendingActionDescription = (
+ action: string,
+ createdBy: string,
+ timeStamp: string
+) => {
+ return i18n.translate(
+ 'xpack.securitySolution.consoleArgumentSelectors.pendingAction.description',
+ {
+ defaultMessage: 'Action id {action} submitted by {createdBy} on {timeStamp}',
+ values: { action, createdBy, timeStamp },
+ }
+ );
+};
+
+export const getPermissionVerificationErrorMessage = (displayCommand: string) => {
+ return i18n.translate(
+ 'xpack.securitySolution.consoleArgumentSelectors.cancel.permissionVerificationError',
+ {
+ defaultMessage: 'Unable to verify permissions for {displayCommand} action cancellation.',
+ values: { displayCommand },
+ }
+ );
+};
+
export const CONSOLE_COMMANDS = {
isolate: {
title: i18n.translate('xpack.securitySolution.endpointConsoleCommands.isolate.title', {
@@ -233,6 +285,11 @@ export const CONSOLE_COMMANDS = {
defaultMessage: 'Run a script on the host',
}),
},
+ cancel: {
+ about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.cancel.about', {
+ defaultMessage: 'Cancel a pending action on the host',
+ }),
+ },
};
export const CROWDSTRIKE_CONSOLE_COMMANDS = {
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/custom_script_selector.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/custom_script_selector.test.tsx
index ab250f69c9172..bf3de5f182dd3 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/custom_script_selector.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/custom_script_selector.test.tsx
@@ -12,7 +12,12 @@ import type { KibanaReactContextValue } from '@kbn/kibana-react-plugin/public';
import type { CustomScriptSelectorState } from './custom_script_selector';
import { CustomScriptSelector } from './custom_script_selector';
import { useGetCustomScripts } from '../../../hooks/custom_scripts/use_get_custom_scripts';
-import { useCustomScriptsErrorToast } from './use_custom_scripts_error_toast';
+import {
+ useGenericErrorToast,
+ useBaseSelectorHandlers,
+ useFocusManagement,
+ useRenderDelay,
+} from '../shared/hooks';
import { useKibana } from '../../../../common/lib/kibana';
import type { ResponseActionScript } from '../../../../../common/endpoint/types';
import type {
@@ -26,20 +31,36 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
jest.mock('../../../hooks/custom_scripts/use_get_custom_scripts');
jest.mock('../../console/hooks/state_selectors/use_console_state_dispatch');
-jest.mock('./use_custom_scripts_error_toast');
+jest.mock('../shared/hooks', () => ({
+ useGenericErrorToast: jest.fn(),
+ useBaseSelectorHandlers: jest.fn(() => ({
+ handleOpenPopover: jest.fn(),
+ handleClosePopover: jest.fn(),
+ setIsPopoverOpen: jest.fn(),
+ })),
+ useBaseSelectorState: jest.fn((store, value) => store ?? { isPopoverOpen: !value }),
+ useRenderDelay: jest.fn(() => false),
+ useFocusManagement: jest.fn(),
+}));
jest.mock('../../../../common/lib/kibana');
-// Mock setTimeout to execute immediately in tests
jest.useFakeTimers();
describe('CustomScriptSelector', () => {
const mockUseGetCustomScripts = useGetCustomScripts as jest.MockedFunction<
typeof useGetCustomScripts
>;
- const mockUseCustomScriptsErrorToast = useCustomScriptsErrorToast as jest.MockedFunction<
- typeof useCustomScriptsErrorToast
+ const mockUseGenericErrorToast = useGenericErrorToast as jest.MockedFunction<
+ typeof useGenericErrorToast
+ >;
+ const mockUseBaseSelectorHandlers = useBaseSelectorHandlers as jest.MockedFunction<
+ typeof useBaseSelectorHandlers
+ >;
+ const mockUseFocusManagement = useFocusManagement as jest.MockedFunction<
+ typeof useFocusManagement
>;
const mockUseKibana = useKibana as jest.MockedFunction;
+ const mockUseRenderDelay = useRenderDelay as jest.MockedFunction;
const mockOnChange = jest.fn();
const mockRequestFocus = jest.fn();
const mockScripts: ResponseActionScript[] = [
@@ -92,10 +113,42 @@ describe('CustomScriptSelector', () => {
error: null,
} as unknown as ReturnType);
+ // Default behavior: don't show render delay
+ mockUseRenderDelay.mockReturnValue(false);
+
// Mock the error toast hook
- mockUseCustomScriptsErrorToast.mockImplementation(() => {});
+ mockUseGenericErrorToast.mockImplementation(() => {});
+
+ // Mock the base selector handlers hook with working implementations
+ const mockHandleOpenPopover = jest.fn(() => {
+ mockOnChange({
+ value: defaultProps.value,
+ valueText: defaultProps.valueText,
+ store: { isPopoverOpen: true },
+ });
+ });
+
+ const mockHandleClosePopover = jest.fn(() => {
+ mockOnChange({
+ value: defaultProps.value,
+ valueText: defaultProps.valueText,
+ store: { isPopoverOpen: false },
+ });
+ });
+ mockUseBaseSelectorHandlers.mockReturnValue({
+ handleOpenPopover: mockHandleOpenPopover,
+ handleClosePopover: mockHandleClosePopover,
+ setIsPopoverOpen: jest.fn(),
+ });
+
+ mockUseFocusManagement.mockImplementation((isPopoverOpen, requestFocus) => {
+ if (!isPopoverOpen && requestFocus) {
+ setTimeout(() => {
+ requestFocus();
+ }, 0);
+ }
+ });
- // Mock useKibana
mockUseKibana.mockReturnValue({
services: {
notifications: {
@@ -128,6 +181,9 @@ describe('CustomScriptSelector', () => {
};
test('renders loading spinner when fetching data', () => {
+ // Ensure render delay doesn't block the spinner
+ mockUseRenderDelay.mockReturnValueOnce(true);
+
mockUseGetCustomScripts.mockReturnValueOnce({
data: undefined,
isLoading: true,
@@ -157,7 +213,6 @@ describe('CustomScriptSelector', () => {
test('opens popover when clicked', async () => {
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
@@ -343,7 +398,7 @@ describe('CustomScriptSelector', () => {
);
expect(
- getByTestId(`scriptSelector-${defaultProps.command.commandDefinition.name}-noMultipleArgs`)
+ getByTestId(`scriptSelector-${defaultProps.command.commandDefinition.name}-1-noMultipleArgs`)
.textContent
).toEqual(' Argument is only supported once per command');
});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/custom_script_selector.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/custom_script_selector.tsx
index 4282baba235fd..86a970747cac0 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/custom_script_selector.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/custom_script_selector.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
+import React, { memo, useCallback, useEffect, useMemo } from 'react';
import {
EuiPopover,
EuiFlexGroup,
@@ -16,39 +16,26 @@ import {
EuiText,
EuiIcon,
} from '@elastic/eui';
-import { css } from '@emotion/react';
-import { i18n } from '@kbn/i18n';
-import { FormattedMessage } from '@kbn/i18n-react';
import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
-import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
+import { FormattedMessage } from '@kbn/i18n-react';
import type { CustomScriptsRequestQueryParams } from '../../../../../common/api/endpoint/custom_scripts/get_custom_scripts_route';
import type { EndpointCommandDefinitionMeta } from '../../endpoint_responder/types';
import type { ResponseActionScript } from '../../../../../common/endpoint/types';
import type { CommandArgumentValueSelectorProps } from '../../console/types';
import { useGetCustomScripts } from '../../../hooks/custom_scripts/use_get_custom_scripts';
+import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
import { useKibana } from '../../../../common/lib/kibana';
-import { useCustomScriptsErrorToast } from './use_custom_scripts_error_toast';
-
-// 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' }
-);
+import {
+ useBaseSelectorState,
+ useBaseSelectorHandlers,
+ useRenderDelay,
+ useFocusManagement,
+} from '../shared/hooks';
-const TOOLTIP_TEXT = i18n.translate(
- 'xpack.securitySolution.consoleArgumentSelectors.customScriptSelector.tooltipText',
- { defaultMessage: 'Click to choose script' }
-);
+import { CUSTOM_SCRIPTS_CONFIG, SHARED_TRUNCATION_STYLE } from '../shared/constants';
+import { useGenericErrorToast, transformCustomScriptsToOptions } from '../shared';
+import { createSelectionHandler, createKeyDownHandler } from '../shared/utils';
+import { ERROR_LOADING_CUSTOM_SCRIPTS } from '../../../common/translations';
/**
* State for the custom script selector component
@@ -58,9 +45,9 @@ export interface CustomScriptSelectorState {
selectedOption: ResponseActionScript | undefined;
}
-type SelectableOption = EuiSelectableOption<
- Partial<{ description: ResponseActionScript['description'] }>
->;
+/**
+ * A Console Argument Selector component that enables the user to select from available custom scripts
+ */
export const CustomScriptSelector = memo<
CommandArgumentValueSelectorProps<
string,
@@ -68,36 +55,9 @@ export const CustomScriptSelector = memo<
EndpointCommandDefinitionMeta
>
>(({ value, valueText, argName, argIndex, onChange, store: _store, command, requestFocus }) => {
- const testId = useTestIdGenerator(`scriptSelector-${command.commandDefinition.name}`);
+ const testId = useTestIdGenerator(`scriptSelector-${command.commandDefinition.name}-${argIndex}`);
const { agentType, platform } = command.commandDefinition.meta ?? {};
- const {
- services: { notifications },
- } = useKibana();
-
- const state = useMemo(() => {
- const { isPopoverOpen = !value, selectedOption } = _store ?? {};
-
- return {
- isPopoverOpen,
- selectedOption,
- };
- }, [_store, value]);
-
- const setIsPopoverOpen = useCallback(
- (newValue: boolean) => {
- onChange({
- value,
- valueText,
- store: {
- ...state,
- isPopoverOpen: newValue,
- },
- });
- },
- [onChange, state, value, valueText]
- );
-
const scriptsApiQueryParams: Omit = useMemo(() => {
if (agentType === 'sentinel_one' && platform) {
return { osType: platform };
@@ -119,18 +79,12 @@ export const CustomScriptSelector = memo<
error: scriptsError,
} = useGetCustomScripts(agentType, scriptsApiQueryParams, { enabled: shouldRender });
- const scriptsOptions: SelectableOption[] = useMemo(() => {
- return data.map((script: ResponseActionScript) => {
- const isChecked = script.name === value;
- return {
- label: script.name,
- description: script.description,
- checked: isChecked ? 'on' : undefined,
- data: script,
- };
- });
+ const scriptsOptions = useMemo(() => {
+ return transformCustomScriptsToOptions(data, value);
}, [data, value]);
+ const state = useBaseSelectorState(_store, value);
+
useEffect(() => {
// If the argument selector should not be rendered, then at least set the `value` to a string
// so that the normal com,and argument validations can be invoked if the user still ENTERs the command
@@ -172,82 +126,66 @@ export const CustomScriptSelector = memo<
}
}, [agentType, data, onChange, shouldRender, state, value, valueText]);
- // 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 {
+ services: { notifications },
+ } = useKibana();
- const renderOption = (option: SelectableOption) => {
- return (
-
-
- {option.label}
-
- {option?.description && (
-
-
- {option.description}
-
-
- )}
-
- );
- };
+ const { handleOpenPopover, handleClosePopover } = useBaseSelectorHandlers(
+ state,
+ onChange,
+ value || '',
+ valueText || ''
+ );
- const handleOpenPopover = useCallback(() => {
- setIsPopoverOpen(true);
- }, [setIsPopoverOpen]);
+ const isAwaitingRenderDelay = useRenderDelay();
- const handleClosePopover = useCallback(() => {
- setIsPopoverOpen(false);
- }, [setIsPopoverOpen]);
+ useFocusManagement(state.isPopoverOpen, requestFocus);
- // Focus on the console's input element when the popover closes
- useEffect(() => {
- if (!state.isPopoverOpen && requestFocus) {
- // Use setTimeout to ensure focus happens after the popover closes
- setTimeout(() => {
- requestFocus();
- }, 0);
- }
- }, [state.isPopoverOpen, requestFocus]);
+ useGenericErrorToast(scriptsError, notifications, ERROR_LOADING_CUSTOM_SCRIPTS);
- const handleScriptSelection = useCallback(
+ const handleSelection = useCallback(
(newOptions: EuiSelectableOption[], _event: unknown, changedOption: EuiSelectableOption) => {
- if (changedOption.checked === 'on') {
- onChange({
- value: changedOption.label,
- valueText: changedOption.label,
- store: {
- ...state,
- isPopoverOpen: false,
- selectedOption: changedOption.data as ResponseActionScript,
- },
- });
- } else {
- onChange({
- value: '',
- valueText: '',
- store: {
- ...state,
- isPopoverOpen: false,
- selectedOption: undefined,
- },
- });
- }
+ const handler = createSelectionHandler(onChange, state);
+ handler(newOptions, _event, changedOption);
},
[onChange, state]
);
- // notifications comes from useKibana() and is of type NotificationsStart
- // which is compatible with our updated useCustomScriptsErrorToast function
- useCustomScriptsErrorToast(scriptsError, notifications);
+ const renderOption = useCallback(
+ (option: EuiSelectableOption) => {
+ const hasDescription = 'description' in option && option.description;
+ const hasToolTipContent = 'toolTipContent' in option && option.toolTipContent;
+ const descriptionText = hasDescription ? String(option.description) : '';
+ const toolTipText = hasToolTipContent ? String(option.toolTipContent) : '';
+
+ const content = (
+
+
+ {option.label}
+
+ {hasDescription ? (
+
+
+ {descriptionText}
+
+
+ ) : null}
+
+ );
+
+ // If the option has toolTipContent (typically for disabled options), wrap in tooltip
+ if (hasToolTipContent) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return content;
+ },
+ [testId]
+ );
if (isAwaitingRenderDelay || (isLoadingScripts && !scriptsError)) {
return ;
@@ -259,16 +197,16 @@ export const CustomScriptSelector = memo<
offset={10}
panelStyle={{
padding: 0,
- minWidth: 400,
+ minWidth: CUSTOM_SCRIPTS_CONFIG.minWidth,
}}
data-test-subj={testId()}
closePopover={handleClosePopover}
panelProps={{ 'data-test-subj': testId('popoverPanel') }}
button={
-
+
- {valueText || INITIAL_DISPLAY_LABEL}
+ {valueText || CUSTOM_SCRIPTS_CONFIG.initialLabel}
@@ -276,24 +214,19 @@ export const CustomScriptSelector = memo<
>
{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();
- }
- },
+ onKeyDown: createKeyDownHandler,
}}
listProps={{
- rowHeight: 60,
+ rowHeight: CUSTOM_SCRIPTS_CONFIG.rowHeight,
showIcons: true,
textWrap: 'truncate',
}}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/use_custom_scripts_error_toast.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/use_custom_scripts_error_toast.test.tsx
deleted file mode 100644
index c63f4b98399e7..0000000000000
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/use_custom_scripts_error_toast.test.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * 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 type { NotificationsStart } from '@kbn/core-notifications-browser';
-import type { IHttpFetchError } from '@kbn/core/public';
-import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
-import { useCustomScriptsErrorToast } from './use_custom_scripts_error_toast';
-import type { CustomScriptsErrorType } from '../../../hooks/custom_scripts/use_get_custom_scripts';
-
-describe('useCustomScriptsErrorToast', () => {
- const mockToastDanger = jest.fn();
- const mockNotifications = {
- toasts: {
- addDanger: mockToastDanger,
- addSuccess: jest.fn(),
- addWarning: jest.fn(),
- add: jest.fn(),
- },
- } as unknown as NotificationsStart;
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- test('does not show toast when no error is provided', () => {
- renderHook(() => useCustomScriptsErrorToast(null, mockNotifications));
-
- expect(mockToastDanger).not.toHaveBeenCalled();
- });
-
- test('shows toast with full error message from err.body.message', () => {
- const mockError: IHttpFetchError = {
- name: 'HttpFetchError',
- message: 'HTTP Error',
- body: {
- statusCode: 403,
- message: 'Response body: {"error":{"code":"Forbidden","message":"Access denied"}}',
- meta: {} as ActionTypeExecutorResult,
- },
- request: {} as unknown as Request,
- response: {} as unknown as Response,
- };
-
- renderHook(() => useCustomScriptsErrorToast(mockError, mockNotifications));
-
- expect(mockToastDanger).toHaveBeenCalledWith({
- title: 'Error: 403',
- text: expect.any(String),
- });
- });
-
- test('shows toast with status code when no parsed error is available', () => {
- const mockError: IHttpFetchError = {
- name: 'HttpFetchError',
- message: 'HTTP Error',
- body: {
- statusCode: 500,
- message: 'Internal server error',
- meta: {} as ActionTypeExecutorResult,
- },
- request: {} as unknown as Request,
- response: {} as unknown as Response,
- };
-
- renderHook(() => useCustomScriptsErrorToast(mockError, mockNotifications));
-
- expect(mockToastDanger).toHaveBeenCalledWith({
- title: 'Error: 500',
- text: expect.any(String),
- });
- });
-
- test('shows toast with error message when no body is available', () => {
- const mockError: IHttpFetchError = {
- name: 'HttpFetchError',
- message: 'Network error',
- body: undefined,
- request: {} as unknown as Request,
- response: {} as unknown as Response,
- };
-
- renderHook(() => useCustomScriptsErrorToast(mockError, mockNotifications));
-
- expect(mockToastDanger).toHaveBeenCalledWith({
- title: 'Error: Error',
- text: expect.any(String),
- });
- });
-
- test('only shows toast once per error instance', () => {
- const mockError: IHttpFetchError = {
- name: 'HttpFetchError',
- message: 'HTTP Error',
- body: {
- statusCode: 403,
- message: 'Access denied',
- meta: {} as ActionTypeExecutorResult,
- },
- request: {} as unknown as Request,
- response: {} as unknown as Response,
- };
-
- const { rerender } = renderHook(() => useCustomScriptsErrorToast(mockError, mockNotifications));
-
- // Rerender with the same error - should not show toast again
- rerender();
-
- expect(mockToastDanger).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/use_custom_scripts_error_toast.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/use_custom_scripts_error_toast.tsx
deleted file mode 100644
index 2831ce7a0a728..0000000000000
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/custom_scripts_selector/use_custom_scripts_error_toast.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * 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 { useEffect } from 'react';
-import type { IHttpFetchError } from '@kbn/core/public';
-import type { NotificationsStart } from '@kbn/core-notifications-browser';
-import type { CustomScriptsErrorType } from '../../../hooks/custom_scripts/use_get_custom_scripts';
-
-/**
- * Shows a danger toast with details if scriptsError is present.
- * @param scriptsError Error object from custom scripts fetch
- * @param notifications Kibana notifications service
- */
-export const useCustomScriptsErrorToast = (
- scriptsError: IHttpFetchError | null,
- notifications: NotificationsStart
-) => {
- useEffect(() => {
- if (scriptsError) {
- let code = 'Error';
- let message: string | undefined;
-
- const err = scriptsError;
- if (err?.body?.message) {
- message = err.body.message;
- code = String(err.body.statusCode ?? code);
- } else {
- message = err?.message || String(err);
- }
-
- if (message) {
- notifications.toasts.addDanger({
- title: `Error: ${code}`,
- text: message,
- });
- }
- }
- }, [scriptsError, notifications]);
-};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/index.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/index.ts
index 515352120b171..ff5312bf04a80 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/index.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/index.ts
@@ -5,3 +5,4 @@
* 2.0.
*/
export * from './file_selector';
+export * from './pending_actions_selector';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/index.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/index.ts
new file mode 100644
index 0000000000000..51eaab63e66a8
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export * from './pending_actions_selector';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/pending_actions_selector.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/pending_actions_selector.test.tsx
new file mode 100644
index 0000000000000..4ed8161c5b40e
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/pending_actions_selector.test.tsx
@@ -0,0 +1,682 @@
+/*
+ * 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 type { KibanaReactContextValue } from '@kbn/kibana-react-plugin/public';
+import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
+
+import type { PendingActionsSelectorState } from './pending_actions_selector';
+import { PendingActionsSelector } from './pending_actions_selector';
+import { useGetEndpointActionList } from '../../../hooks/response_actions/use_get_endpoint_action_list';
+import {
+ useGenericErrorToast,
+ useBaseSelectorHandlers,
+ useFocusManagement,
+ usePendingActionsOptions,
+} from '../shared/hooks';
+import { useKibana } from '../../../../common/lib/kibana';
+import type { ActionDetails, ActionListApiResponse } from '../../../../../common/endpoint/types';
+import { useUserPrivileges } from '../../../../common/components/user_privileges';
+import { getEndpointPrivilegesInitialStateMock } from '../../../../common/components/user_privileges/endpoint/mocks';
+import type {
+ CommandArgumentValueSelectorProps,
+ Command,
+ CommandArgDefinition,
+} from '../../console/types';
+import type { ParsedCommandInterface } from '../../console/service/types';
+import type { EndpointCommandDefinitionMeta } from '../../endpoint_responder/types';
+
+type PendingActionOption = EuiSelectableOption> & {
+ data?: ActionDetails;
+};
+
+jest.mock('../../../hooks/response_actions/use_get_endpoint_action_list');
+jest.mock('../../console/hooks/state_selectors/use_console_state_dispatch');
+jest.mock('../shared/hooks', () => ({
+ useGenericErrorToast: jest.fn(),
+ useBaseSelectorHandlers: jest.fn(() => ({
+ handleOpenPopover: jest.fn(),
+ handleClosePopover: jest.fn(),
+ setIsPopoverOpen: jest.fn(),
+ })),
+ useBaseSelectorState: jest.fn((store, value) => store ?? { isPopoverOpen: !value }),
+ useRenderDelay: jest.fn(() => false),
+ useFocusManagement: jest.fn(),
+ usePendingActionsOptions: jest.fn(() => []),
+}));
+jest.mock('../../../../common/lib/kibana');
+jest.mock('../../../../common/components/user_privileges');
+jest.mock('../../../../common/experimental_features_service');
+
+jest.useFakeTimers();
+
+describe('PendingActionsSelector', () => {
+ const mockUseGetEndpointActionList = useGetEndpointActionList as jest.MockedFunction<
+ typeof useGetEndpointActionList
+ >;
+ const mockUseGenericErrorToast = useGenericErrorToast as jest.MockedFunction<
+ typeof useGenericErrorToast
+ >;
+ const mockUseBaseSelectorHandlers = useBaseSelectorHandlers as jest.MockedFunction<
+ typeof useBaseSelectorHandlers
+ >;
+ const mockUseFocusManagement = useFocusManagement as jest.MockedFunction<
+ typeof useFocusManagement
+ >;
+ const mockUsePendingActionsOptions = usePendingActionsOptions as jest.MockedFunction<
+ typeof usePendingActionsOptions
+ >;
+ const mockUseKibana = useKibana as jest.MockedFunction;
+ const mockUseUserPrivileges = useUserPrivileges as jest.MockedFunction;
+ const mockOnChange = jest.fn();
+ const mockRequestFocus = jest.fn();
+
+ const mockActionDetails: ActionDetails = {
+ id: 'action-123-abc',
+ command: 'isolate',
+ agents: ['agent-1'],
+ hosts: {
+ 'agent-1': { name: 'test-host' },
+ },
+ agentState: {
+ 'agent-1': {
+ isCompleted: false,
+ wasSuccessful: false,
+ errors: undefined,
+ completedAt: undefined,
+ },
+ },
+ isExpired: false,
+ isCompleted: false,
+ wasSuccessful: false,
+ startedAt: '2023-11-01T10:00:00.000Z',
+ completedAt: undefined,
+ status: 'pending',
+ createdBy: 'test-user',
+ agentType: 'endpoint',
+ errors: undefined,
+ };
+
+ const mockApiResponse: ActionListApiResponse = {
+ page: 1,
+ pageSize: 10,
+ total: 1,
+ data: [mockActionDetails],
+ agentTypes: [],
+ elasticAgentIds: undefined,
+ endDate: undefined,
+ startDate: undefined,
+ userIds: undefined,
+ commands: undefined,
+ statuses: undefined,
+ };
+
+ const mockCommand: Command = {
+ input: 'cancel-action --actionId',
+ inputDisplay: 'cancel-action --actionId',
+ args: {} as ParsedCommandInterface>,
+ commandDefinition: {
+ name: 'cancel-action',
+ about: 'Cancel a pending action',
+ RenderComponent: () => {'Mock render'}
,
+ meta: {
+ agentType: 'endpoint',
+ endpointId: '',
+ capabilities: [],
+ privileges: getEndpointPrivilegesInitialStateMock(),
+ platform: 'linux',
+ } as EndpointCommandDefinitionMeta,
+ },
+ };
+
+ const defaultProps: CommandArgumentValueSelectorProps = {
+ value: undefined,
+ valueText: '',
+ argName: 'actionId',
+ argIndex: 0,
+ store: { isPopoverOpen: false },
+ onChange: mockOnChange,
+ command: mockCommand,
+ requestFocus: mockRequestFocus,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseGetEndpointActionList.mockReturnValue({
+ data: mockApiResponse,
+ isLoading: false,
+ isError: false,
+ error: null,
+ } as unknown as ReturnType);
+
+ // Mock the error toast hook
+ mockUseGenericErrorToast.mockImplementation(() => {});
+
+ // Mock the base selector handlers hook with working implementations
+ const mockHandleOpenPopover = jest.fn(() => {
+ mockOnChange({
+ value: defaultProps.value,
+ valueText: defaultProps.valueText,
+ store: { isPopoverOpen: true },
+ });
+ });
+
+ const mockHandleClosePopover = jest.fn(() => {
+ mockOnChange({
+ value: defaultProps.value,
+ valueText: defaultProps.valueText,
+ store: { isPopoverOpen: false },
+ });
+ });
+ mockUseBaseSelectorHandlers.mockReturnValue({
+ handleOpenPopover: mockHandleOpenPopover,
+ handleClosePopover: mockHandleClosePopover,
+ setIsPopoverOpen: jest.fn(),
+ });
+
+ mockUseFocusManagement.mockImplementation((isPopoverOpen, requestFocus) => {
+ if (!isPopoverOpen && requestFocus) {
+ setTimeout(() => {
+ requestFocus();
+ }, 0);
+ }
+ });
+
+ mockUseKibana.mockReturnValue({
+ services: {
+ notifications: {
+ toasts: {
+ add: jest.fn(),
+ addSuccess: jest.fn(),
+ addWarning: jest.fn(),
+ addDanger: jest.fn(),
+ },
+ },
+ },
+ } as unknown as KibanaReactContextValue);
+
+ // Mock user privileges - default to having all permissions
+ mockUseUserPrivileges.mockReturnValue({
+ endpointPrivileges: {
+ canWriteSecuritySolution: true,
+ canIsolateHost: true,
+ canUnIsolateHost: true,
+ canKillProcess: true,
+ canSuspendProcess: true,
+ canGetRunningProcesses: true,
+ canWriteExecuteOperations: true,
+ canWriteFileOperations: true,
+ canWriteScanOperations: true,
+ canReadActionsLogManagement: true,
+ loading: false,
+ },
+ } as ReturnType);
+
+ // Mock the pending actions options hook
+ mockUsePendingActionsOptions.mockReturnValue([
+ {
+ label: 'isolate',
+ description:
+ 'Action id action-123-abc submitted by test-user on Nov 1, 2023 @ 10:00:00.000',
+ data: mockActionDetails,
+ checked: undefined,
+ disabled: false,
+ } as PendingActionOption,
+ ]);
+ });
+
+ 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', () => {
+ mockUseGetEndpointActionList.mockReturnValueOnce({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ error: null,
+ } as unknown as ReturnType);
+
+ render();
+
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+
+ test('renders initial display label when no action is selected', async () => {
+ await renderAndWaitForComponent();
+
+ expect(screen.getByText('Click to select action')).toBeInTheDocument();
+ });
+
+ test('renders selected action with command-actionId format when action is selected', async () => {
+ await renderAndWaitForComponent(
+
+ );
+
+ expect(screen.getByText('isolate - action-123-abc')).toBeInTheDocument();
+ });
+
+ test('opens popover when clicked', async () => {
+ await renderAndWaitForComponent();
+
+ fireEvent.click(screen.getByText('Click to select action'));
+
+ // Check that onChange was called with isPopoverOpen set to true
+ expect(mockOnChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ store: { isPopoverOpen: true },
+ })
+ );
+ });
+
+ test('displays action in dropdown with command format', async () => {
+ await renderAndWaitForComponent(
+
+ );
+
+ // The action should be displayed with the command name
+ expect(screen.getByText('isolate')).toBeInTheDocument();
+ // The description should also be displayed
+ expect(
+ screen.getByText(
+ 'Action id action-123-abc submitted by test-user on Nov 1, 2023 @ 10:00:00.000'
+ )
+ ).toBeInTheDocument();
+ });
+
+ test('displays action description as tooltip', async () => {
+ await renderAndWaitForComponent(
+
+ );
+
+ // Check that the description contains expected elements
+ const descriptionText = screen.getByText(/Action id action-123-abc submitted by test-user on/);
+ expect(descriptionText).toBeInTheDocument();
+ });
+
+ test('handles multiple pending actions with different commands', async () => {
+ const multipleActionsData: ActionDetails[] = [
+ mockActionDetails,
+ {
+ ...mockActionDetails,
+ id: 'action-456-def',
+ command: 'unisolate',
+ },
+ {
+ ...mockActionDetails,
+ id: 'action-789-ghi',
+ command: 'get-file',
+ },
+ ];
+
+ const multipleActionsResponse: ActionListApiResponse = {
+ ...mockApiResponse,
+ data: multipleActionsData,
+ total: 3,
+ };
+
+ mockUseGetEndpointActionList.mockReturnValue({
+ data: multipleActionsResponse,
+ isLoading: false,
+ isError: false,
+ error: null,
+ } as unknown as ReturnType);
+
+ // Mock the options to return multiple actions
+ mockUsePendingActionsOptions.mockReturnValue([
+ {
+ label: 'isolate',
+ description:
+ 'Action id action-123-abc submitted by test-user on Nov 1, 2023 @ 10:00:00.000',
+ data: mockActionDetails,
+ checked: undefined,
+ disabled: false,
+ } as PendingActionOption,
+ {
+ label: 'release',
+ description:
+ 'Action id action-456-def submitted by test-user on Nov 1, 2023 @ 10:00:00.000',
+ data: { ...mockActionDetails, id: 'action-456-def', command: 'unisolate' },
+ checked: undefined,
+ disabled: false,
+ } as PendingActionOption,
+ {
+ label: 'get-file',
+ description:
+ 'Action id action-789-ghi submitted by test-user on Nov 1, 2023 @ 10:00:00.000',
+ data: { ...mockActionDetails, id: 'action-789-ghi', command: 'get-file' },
+ checked: undefined,
+ disabled: false,
+ } as PendingActionOption,
+ ]);
+
+ await renderAndWaitForComponent(
+
+ );
+
+ // Verify all actions are displayed with correct labels
+ expect(screen.getByText('isolate')).toBeInTheDocument();
+ expect(screen.getByText('release')).toBeInTheDocument();
+ expect(screen.getByText('get-file')).toBeInTheDocument();
+
+ // Verify all actions have descriptions
+ expect(
+ screen.getByText(
+ 'Action id action-123-abc submitted by test-user on Nov 1, 2023 @ 10:00:00.000'
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Action id action-456-def submitted by test-user on Nov 1, 2023 @ 10:00:00.000'
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Action id action-789-ghi submitted by test-user on Nov 1, 2023 @ 10:00:00.000'
+ )
+ ).toBeInTheDocument();
+ });
+
+ test('calls useGetEndpointActionList with correct parameters from command meta', async () => {
+ const commandWithEndpointId = {
+ ...mockCommand,
+ commandDefinition: {
+ ...mockCommand.commandDefinition,
+ meta: {
+ ...mockCommand.commandDefinition.meta,
+ agentType: 'microsoft_defender_endpoint',
+ endpointId: 'test-endpoint-id',
+ } as EndpointCommandDefinitionMeta,
+ },
+ };
+
+ await renderAndWaitForComponent(
+
+ );
+
+ expect(mockUseGetEndpointActionList).toHaveBeenCalledWith(
+ {
+ agentTypes: 'microsoft_defender_endpoint',
+ agentIds: 'test-endpoint-id',
+ page: 1,
+ pageSize: 200,
+ statuses: ['pending'],
+ },
+ {
+ enabled: true,
+ refetchInterval: false,
+ }
+ );
+ });
+
+ test('enables refetch interval when popover is open and disables when closed', async () => {
+ // Test with popover closed (default state)
+ await renderAndWaitForComponent();
+
+ expect(mockUseGetEndpointActionList).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ refetchInterval: false, // Should be false when popover is closed
+ enabled: true,
+ })
+ );
+
+ // Clear previous calls
+ mockUseGetEndpointActionList.mockClear();
+
+ // Test with popover open
+ await renderAndWaitForComponent(
+
+ );
+
+ expect(mockUseGetEndpointActionList).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ refetchInterval: 3000, // Should be 3000ms when popover is open
+ enabled: true,
+ })
+ );
+ });
+
+ describe('Privilege validation', () => {
+ test('disables actions when user lacks permission to cancel specific command', async () => {
+ // Mock privileges without isolate permission
+ mockUseUserPrivileges.mockReturnValue({
+ endpointPrivileges: {
+ canWriteSecuritySolution: true,
+ canIsolateHost: false, // User cannot cancel isolate actions
+ canUnIsolateHost: true,
+ canKillProcess: true,
+ canSuspendProcess: true,
+ canGetRunningProcesses: true,
+ canWriteExecuteOperations: true,
+ canWriteFileOperations: true,
+ canWriteScanOperations: true,
+ canReadActionsLogManagement: true,
+ loading: false,
+ },
+ } as ReturnType);
+
+ // Mock disabled options
+ mockUsePendingActionsOptions.mockReturnValue([
+ {
+ label: 'isolate',
+ description:
+ 'Action id action-123-abc submitted by test-user on Nov 1, 2023 @ 10:00:00.000',
+ data: mockActionDetails,
+ checked: undefined,
+ disabled: true,
+ } as PendingActionOption,
+ ]);
+
+ await renderAndWaitForComponent(
+
+ );
+
+ // The isolate action should be displayed with label and description
+ expect(screen.getByText('isolate')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Action id action-123-abc submitted by test-user on Nov 1, 2023 @ 10:00:00.000'
+ )
+ ).toBeInTheDocument();
+ });
+
+ test('shows disabled styling for actions without permissions', async () => {
+ // Mock privileges without kill process permission
+ const killProcessAction: ActionDetails = {
+ ...mockActionDetails,
+ id: 'action-kill-123',
+ command: 'kill-process',
+ };
+
+ const responseWithKillProcess: ActionListApiResponse = {
+ ...mockApiResponse,
+ data: [killProcessAction],
+ };
+
+ mockUseGetEndpointActionList.mockReturnValue({
+ data: responseWithKillProcess,
+ isLoading: false,
+ isError: false,
+ error: null,
+ } as unknown as ReturnType);
+
+ mockUseUserPrivileges.mockReturnValue({
+ endpointPrivileges: {
+ canIsolateHost: true,
+ canUnIsolateHost: true,
+ canKillProcess: false, // User cannot cancel kill-process actions
+ canSuspendProcess: true,
+ canGetRunningProcesses: true,
+ canWriteExecuteOperations: true,
+ canWriteFileOperations: true,
+ canWriteScanOperations: true,
+ canReadActionsLogManagement: true,
+ loading: false,
+ },
+ } as ReturnType);
+
+ // Mock disabled kill-process option
+ mockUsePendingActionsOptions.mockReturnValue([
+ {
+ label: 'kill-process',
+ description:
+ 'Action id action-kill-123 submitted by test-user on Nov 1, 2023 @ 10:00:00.000',
+ data: killProcessAction,
+ checked: undefined,
+ disabled: true,
+ } as PendingActionOption,
+ ]);
+
+ await renderAndWaitForComponent(
+
+ );
+
+ // The action should be displayed with label and description
+ expect(screen.getByText('kill-process')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Action id action-kill-123 submitted by test-user on Nov 1, 2023 @ 10:00:00.000'
+ )
+ ).toBeInTheDocument();
+ });
+
+ test('enables actions when user has all required permissions', async () => {
+ // All permissions are enabled by default in beforeEach
+ await renderAndWaitForComponent(
+
+ );
+
+ // The action should be displayed and selectable with label and description
+ expect(screen.getByText('isolate')).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Action id action-123-abc submitted by test-user on Nov 1, 2023 @ 10:00:00.000'
+ )
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('Text wrapping functionality', () => {
+ test('displays full description text without truncation', async () => {
+ const longDescription =
+ 'This is a very long description that would normally be truncated but should now wrap to multiple lines without being cut off with ellipsis';
+
+ mockUsePendingActionsOptions.mockReturnValue([
+ {
+ label: 'isolate',
+ description: longDescription,
+ data: mockActionDetails,
+ checked: undefined,
+ disabled: false,
+ toolTipContent: undefined,
+ } as PendingActionOption,
+ ]);
+
+ await renderAndWaitForComponent(
+
+ );
+
+ // Verify the full description is displayed
+ expect(screen.getByText(longDescription)).toBeInTheDocument();
+
+ // Verify the description element exists and has proper styling for wrapping
+ const descriptionElement = screen.getByTestId('isolate-description');
+ expect(descriptionElement).toBeInTheDocument();
+ });
+
+ test('command names are still truncated when too long', async () => {
+ await renderAndWaitForComponent(
+
+ );
+
+ // Verify command label has truncation styling by checking the CSS class applied
+ const commandElement = screen.getByTestId('isolate-label');
+ expect(commandElement).toBeInTheDocument();
+ // The truncation style is applied via CSS-in-JS emotion, verify the element exists
+ // and has some CSS class (emotion generates class names dynamically)
+ expect(commandElement.parentElement).toHaveAttribute('class');
+ expect(commandElement.parentElement?.className).toContain('css-');
+ });
+ });
+
+ describe('Unified tooltip for disabled options', () => {
+ test('shows lock icon for disabled options', async () => {
+ mockUsePendingActionsOptions.mockReturnValue([
+ {
+ label: 'isolate',
+ description: 'Action description',
+ data: mockActionDetails,
+ checked: undefined,
+ disabled: true,
+ } as PendingActionOption,
+ ]);
+
+ await renderAndWaitForComponent(
+
+ );
+
+ // Verify lock icon is displayed for disabled option
+ const lockIcon = screen.getByTestId('isolate-disabled-icon');
+ expect(lockIcon).toBeInTheDocument();
+ // Check the icon type instead of aria-label as EUI may handle accessibility differently
+ expect(lockIcon.getAttribute('data-test-subj')).toBe('isolate-disabled-icon');
+ });
+
+ test('applies proper cursor styling for disabled options', async () => {
+ mockUsePendingActionsOptions.mockReturnValue([
+ {
+ label: 'isolate',
+ description: 'Action description',
+ data: mockActionDetails,
+ checked: undefined,
+ disabled: true,
+ } as PendingActionOption,
+ ]);
+
+ await renderAndWaitForComponent(
+
+ );
+
+ // Verify the disabled option shows the lock icon (which indicates proper disabled state)
+ const lockIcon = screen.getByTestId('isolate-disabled-icon');
+ expect(lockIcon).toBeInTheDocument();
+
+ // Verify the tooltip functionality by checking that the item has the proper structure
+ const optionScript = screen.getByTestId('cancel-action-actionId-arg-0-script');
+ expect(optionScript).toBeInTheDocument();
+ });
+
+ test('does not show lock icon for enabled options', async () => {
+ await renderAndWaitForComponent(
+
+ );
+
+ // Verify no lock icon is displayed for enabled option
+ expect(screen.queryByTestId('isolate-disabled-icon')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/pending_actions_selector.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/pending_actions_selector.tsx
new file mode 100644
index 0000000000000..9697d1e5a58e8
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/pending_actions_selector/pending_actions_selector.tsx
@@ -0,0 +1,238 @@
+/*
+ * 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 } from 'react';
+import {
+ EuiPopover,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSelectable,
+ EuiToolTip,
+ EuiLoadingSpinner,
+ EuiText,
+ EuiIcon,
+} from '@elastic/eui';
+import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
+import { FormattedMessage } from '@kbn/i18n-react';
+import type { EndpointCommandDefinitionMeta } from '../../endpoint_responder/types';
+import type { CommandArgumentValueSelectorProps } from '../../console/types';
+import { useGetEndpointActionList } from '../../../hooks/response_actions/use_get_endpoint_action_list';
+import { PENDING_ACTIONS_CONFIG, SHARED_TRUNCATION_STYLE } from '../shared/constants';
+import { useGenericErrorToast, checkActionCancelPermission } from '../shared';
+import { useUserPrivileges } from '../../../../common/components/user_privileges';
+import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
+import { useKibana } from '../../../../common/lib/kibana';
+import {
+ useBaseSelectorState,
+ useBaseSelectorHandlers,
+ useRenderDelay,
+ useFocusManagement,
+ usePendingActionsOptions,
+} from '../shared/hooks';
+import { createSelectionHandler, createKeyDownHandler } from '../shared/utils';
+import { ERROR_LOADING_PENDING_ACTIONS } from '../../../common/translations';
+
+/**
+ * State for the pending actions selector component
+ */
+export interface PendingActionsSelectorState {
+ isPopoverOpen: boolean;
+}
+
+/**
+ * A Console Argument Selector component that enables the user to select from available pending actions
+ */
+export const PendingActionsSelector = memo<
+ CommandArgumentValueSelectorProps<
+ string,
+ PendingActionsSelectorState,
+ EndpointCommandDefinitionMeta
+ >
+>(({ value, valueText, onChange, store, command, requestFocus, argName, argIndex }) => {
+ const testId = useTestIdGenerator(`${command.commandDefinition.name}-${argName}-arg-${argIndex}`);
+
+ const agentType = command.commandDefinition.meta?.agentType;
+ const endpointId = command.commandDefinition.meta?.endpointId;
+
+ const userPrivileges = useUserPrivileges();
+
+ const state = useBaseSelectorState(store, value);
+
+ const { data, isLoading, error } = useGetEndpointActionList(
+ {
+ agentTypes: agentType,
+ agentIds: endpointId,
+ page: 1,
+ pageSize: 200,
+ statuses: ['pending'],
+ },
+ {
+ refetchInterval: state.isPopoverOpen ? 3000 : false, // Only refetch when popover is open
+ enabled: true, // Always keep query enabled for initial load
+ }
+ );
+
+ const privilegeChecker = useCallback(
+ (actionCommand: string) => {
+ return checkActionCancelPermission(actionCommand, userPrivileges.endpointPrivileges);
+ },
+ [userPrivileges.endpointPrivileges]
+ );
+
+ const options = usePendingActionsOptions({
+ response: data ? [data] : null,
+ selectedValue: value,
+ privilegeChecker,
+ });
+
+ const {
+ services: { notifications },
+ } = useKibana();
+
+ const { handleOpenPopover, handleClosePopover } = useBaseSelectorHandlers(
+ state,
+ onChange,
+ value || '',
+ valueText || ''
+ );
+
+ const isAwaitingRenderDelay = useRenderDelay();
+
+ useFocusManagement(state.isPopoverOpen, requestFocus);
+
+ useGenericErrorToast(error, notifications, ERROR_LOADING_PENDING_ACTIONS);
+
+ const handleSelection = useCallback(
+ (newOptions: EuiSelectableOption[], _event: unknown, changedOption: EuiSelectableOption) => {
+ const handler = createSelectionHandler(onChange, state);
+ handler(newOptions, _event, changedOption);
+ },
+ [onChange, state]
+ );
+
+ const renderOption = useCallback(
+ (option: EuiSelectableOption) => {
+ const hasDescription = 'description' in option && option.description;
+ const hasToolTipContent = 'toolTipContent' in option && option.toolTipContent;
+ const descriptionText = hasDescription ? String(option.description) : '';
+ const toolTipText = hasToolTipContent ? String(option.toolTipContent) : '';
+
+ const content = (
+
+
+
+
+ {option.label}
+
+ {hasDescription ? (
+
+ {descriptionText}
+
+ ) : null}
+
+ {option.disabled && (
+
+
+
+ )}
+
+
+ );
+
+ // Single tooltip for entire disabled item
+ if (option.disabled) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return content;
+ },
+ [testId]
+ );
+
+ if (isAwaitingRenderDelay || (isLoading && !error)) {
+ return ;
+ }
+
+ return (
+
+
+
+ {valueText || PENDING_ACTIONS_CONFIG.initialLabel}
+
+
+
+ }
+ >
+ {state.isPopoverOpen && (
+
+ ) : undefined
+ }
+ >
+ {(list, search) => (
+ <>
+ {search}
+ {list}
+ >
+ )}
+
+ )}
+
+ );
+});
+
+PendingActionsSelector.displayName = 'PendingActionsSelector';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/constants.ts
new file mode 100644
index 0000000000000..3b892ea448d59
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/constants.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 { css } from '@emotion/react';
+import { i18n } from '@kbn/i18n';
+import type { BaseSelectorConfig } from './types';
+
+/**
+ * Shared CSS style for text truncation with ellipsis
+ */
+export const SHARED_TRUNCATION_STYLE = css({
+ display: '-webkit-box',
+ overflow: 'hidden',
+ WebkitBoxOrient: 'vertical',
+ WebkitLineClamp: 1,
+ lineClamp: 1, // standardized fallback for modern Firefox
+ textOverflow: 'ellipsis',
+ whiteSpace: 'normal',
+});
+
+/**
+ * Configuration for CustomScriptSelector
+ */
+export const CUSTOM_SCRIPTS_CONFIG: BaseSelectorConfig = {
+ initialLabel: i18n.translate(
+ 'xpack.securitySolution.consoleArgumentSelectors.customScriptSelector.initialDisplayLabel',
+ { defaultMessage: 'Click to select script' }
+ ),
+ tooltipText: i18n.translate(
+ 'xpack.securitySolution.consoleArgumentSelectors.customScriptSelector.tooltipText',
+ { defaultMessage: 'Click to choose script' }
+ ),
+ minWidth: 400,
+ rowHeight: 60,
+ selectableId: 'options-combobox',
+};
+
+/**
+ * Configuration for PendingActionsSelector
+ */
+export const PENDING_ACTIONS_CONFIG: BaseSelectorConfig = {
+ initialLabel: i18n.translate(
+ 'xpack.securitySolution.consoleArgumentSelectors.pendingActionsSelector.initialDisplayLabel',
+ { defaultMessage: 'Click to select action' }
+ ),
+ tooltipText: i18n.translate(
+ 'xpack.securitySolution.consoleArgumentSelectors.pendingActionsSelector.tooltipText',
+ { defaultMessage: 'Click to choose pending action to cancel' }
+ ),
+ minWidth: 500,
+ rowHeight: 70,
+ selectableId: 'pending-actions-combobox',
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/hooks.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/hooks.test.ts
new file mode 100644
index 0000000000000..8e35d06047f48
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/hooks.test.ts
@@ -0,0 +1,623 @@
+/*
+ * 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 type { EuiSelectableOption } from '@elastic/eui';
+import { usePendingActionsOptions } from './hooks';
+import { useDateFormat, useTimeZone } from '../../../../common/lib/kibana';
+import type { ActionListApiResponse, ActionDetails } from '../../../../../common/endpoint/types';
+
+const getOptionValue = (option: EuiSelectableOption): string => {
+ // Since we know our options have a value property, we can safely access it
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (option as any).value as string;
+};
+
+// Mock the date format and timezone hooks
+jest.mock('../../../../common/lib/kibana');
+const mockUseDateFormat = useDateFormat as jest.Mock;
+const mockUseTimeZone = useTimeZone as jest.Mock;
+
+describe('usePendingActionsOptions hook', () => {
+ const mockDateFormat = 'MMM D, YYYY @ HH:mm:ss.SSS';
+ const mockTimeZone = 'UTC';
+
+ beforeEach(() => {
+ mockUseDateFormat.mockReturnValue(mockDateFormat);
+ mockUseTimeZone.mockReturnValue(mockTimeZone);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const createMockActionDetails = (overrides: Partial = {}): ActionDetails => ({
+ id: 'action-123-abc',
+ command: 'isolate',
+ agents: ['agent-1'],
+ hosts: {
+ 'agent-1': { name: 'test-host' },
+ },
+ agentState: {
+ 'agent-1': {
+ isCompleted: false,
+ wasSuccessful: false,
+ errors: undefined,
+ completedAt: undefined,
+ },
+ },
+ isExpired: false,
+ isCompleted: false,
+ wasSuccessful: false,
+ startedAt: '2023-11-01T10:00:00.000Z',
+ completedAt: undefined,
+ status: 'pending',
+ createdBy: 'test-user',
+ agentType: 'endpoint',
+ errors: undefined,
+ ...overrides,
+ });
+
+ const createMockApiResponse = (
+ data: ActionDetails[] = [createMockActionDetails()]
+ ): ActionListApiResponse => ({
+ page: 1,
+ pageSize: 10,
+ total: data.length,
+ data,
+ agentTypes: [],
+ elasticAgentIds: undefined,
+ endDate: undefined,
+ startDate: undefined,
+ userIds: undefined,
+ commands: undefined,
+ statuses: undefined,
+ });
+
+ describe('basic functionality', () => {
+ it('should return empty array when response is null', () => {
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: null,
+ })
+ );
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('should return empty array when response is empty array', () => {
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [],
+ })
+ );
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('should return empty array when response has no data', () => {
+ const emptyResponse = createMockApiResponse([]);
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [emptyResponse],
+ })
+ );
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('should return empty array when response data is not an array', () => {
+ const invalidResponse = createMockApiResponse();
+ // Intentionally make data invalid for testing error handling
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (invalidResponse as any).data = null;
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [invalidResponse],
+ })
+ );
+
+ expect(result.current).toEqual([]);
+ });
+ });
+
+ describe('option formatting', () => {
+ it('should transform single pending action to correct option format', () => {
+ const mockAction = createMockActionDetails();
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(result.current).toHaveLength(1);
+ const option = result.current[0];
+ expect(option).toMatchObject({
+ label: 'isolate',
+ data: mockAction,
+ checked: undefined,
+ disabled: true, // Default when no privilege checker
+ });
+ // Type assertion for accessing value property
+ expect(getOptionValue(option)).toBe('action-123-abc');
+ });
+
+ it('should include properly formatted description with timestamp', () => {
+ const mockAction = createMockActionDetails({
+ startedAt: '2023-11-01T10:00:00.000Z',
+ });
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(result.current[0].description).toContain(
+ 'Action id action-123-abc submitted by test-user on'
+ );
+ // Should include the formatted timestamp
+ expect(result.current[0].description).toMatch(/Nov 1, 2023 @ 10:00:00\.000/);
+ });
+
+ it('should include action ID in description', () => {
+ const mockAction = createMockActionDetails({
+ id: 'custom-action-id-456',
+ agents: ['unknown-agent'],
+ hosts: {},
+ });
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(result.current[0].description).toContain(
+ 'Action id custom-action-id-456 submitted by test-user'
+ );
+ });
+
+ it('should map unisolate command to release display name', () => {
+ const mockAction = createMockActionDetails({
+ id: 'action-unisolate-123',
+ command: 'unisolate',
+ });
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ const option = result.current[0];
+ expect(option.label).toBe('release');
+ expect(getOptionValue(option)).toBe('action-unisolate-123');
+ expect(result.current[0].description).toContain(
+ 'Action id action-unisolate-123 submitted by test-user'
+ );
+ });
+
+ it('should handle various command types correctly', () => {
+ const actions = [
+ createMockActionDetails({ id: 'action-1', command: 'isolate' }),
+ createMockActionDetails({ id: 'action-2', command: 'unisolate' }),
+ createMockActionDetails({ id: 'action-3', command: 'get-file' }),
+ createMockActionDetails({ id: 'action-4', command: 'execute' }),
+ createMockActionDetails({ id: 'action-5', command: 'kill-process' }),
+ ];
+ const mockResponse = createMockApiResponse(actions);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(result.current).toHaveLength(5);
+ expect(result.current[0].label).toBe('isolate');
+ expect(result.current[1].label).toBe('release'); // unisolate -> release
+ expect(result.current[2].label).toBe('get-file');
+ expect(result.current[3].label).toBe('execute');
+ expect(result.current[4].label).toBe('kill-process');
+ });
+
+ it('should handle edge cases with very long action IDs', () => {
+ const longId =
+ 'very-long-action-id-that-might-cause-ui-issues-123456789012345678901234567890';
+ const mockAction = createMockActionDetails({
+ id: longId,
+ command: 'execute',
+ });
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ const option = result.current[0];
+ expect(option.label).toBe('execute');
+ expect(getOptionValue(option)).toBe(longId);
+ });
+ });
+
+ describe('selected value handling', () => {
+ it('should mark correct option as checked when selectedValue matches', () => {
+ const mockAction = createMockActionDetails({ id: 'selected-action' });
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ selectedValue: 'selected-action',
+ })
+ );
+
+ const option = result.current[0];
+ expect(option.checked).toBe('on');
+ expect(getOptionValue(option)).toBe('selected-action');
+ });
+
+ it('should not mark any options as checked when selectedValue does not match', () => {
+ const mockAction = createMockActionDetails({ id: 'action-1' });
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ selectedValue: 'non-matching-id',
+ })
+ );
+
+ const option = result.current[0];
+ expect(option.checked).toBeUndefined();
+ expect(getOptionValue(option)).toBe('action-1');
+ });
+
+ it('should handle multiple actions with only one selected', () => {
+ const actions = [
+ createMockActionDetails({ id: 'action-1' }),
+ createMockActionDetails({ id: 'action-2' }),
+ createMockActionDetails({ id: 'action-3' }),
+ ];
+ const mockResponse = createMockApiResponse(actions);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ selectedValue: 'action-2',
+ })
+ );
+
+ expect(result.current).toHaveLength(3);
+ expect(result.current[0].checked).toBeUndefined();
+ expect(result.current[1].checked).toBe('on');
+ expect(result.current[2].checked).toBeUndefined();
+ });
+ });
+
+ const createPrivilegeChecker = (canCancel: boolean, reason?: string) => (command: string) => ({
+ canCancel,
+ reason: canCancel ? undefined : reason || `No permission to cancel ${command}`,
+ });
+
+ describe('privilege checking', () => {
+ it('should enable actions when privilege checker returns canCancel: true', () => {
+ const mockAction = createMockActionDetails();
+ const mockResponse = createMockApiResponse([mockAction]);
+ const privilegeChecker = createPrivilegeChecker(true);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ privilegeChecker,
+ })
+ );
+
+ const option = result.current[0];
+ expect(option.disabled).toBe(false);
+ });
+
+ it('should disable actions when privilege checker returns canCancel: false', () => {
+ const mockAction = createMockActionDetails({ command: 'isolate' });
+ const mockResponse = createMockApiResponse([mockAction]);
+ const privilegeChecker = createPrivilegeChecker(false, 'Permission denied for isolate');
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ privilegeChecker,
+ })
+ );
+
+ const option = result.current[0];
+ expect(option.disabled).toBe(true);
+ });
+
+ it('should pass command to privilege checker correctly', () => {
+ const mockAction = createMockActionDetails({ command: 'execute' });
+ const mockResponse = createMockApiResponse([mockAction]);
+ const privilegeChecker = jest.fn().mockReturnValue({ canCancel: true });
+
+ renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ privilegeChecker,
+ })
+ );
+
+ expect(privilegeChecker).toHaveBeenCalledWith('execute');
+ });
+
+ it('should handle different permissions for different commands', () => {
+ const actions = [
+ createMockActionDetails({ id: 'action-1', command: 'isolate' }),
+ createMockActionDetails({ id: 'action-2', command: 'execute' }),
+ ];
+ const mockResponse = createMockApiResponse(actions);
+ const privilegeChecker = jest.fn().mockImplementation((command: string) => ({
+ canCancel: command === 'isolate',
+ reason: command === 'execute' ? 'No execute permission' : undefined,
+ }));
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ privilegeChecker,
+ })
+ );
+
+ expect(result.current).toHaveLength(2);
+ const option1 = result.current[0];
+ const option2 = result.current[1];
+ expect(option1.disabled).toBe(false);
+ expect(option2.disabled).toBe(true);
+ });
+
+ it('should default to disabled when no privilege checker is provided', () => {
+ const mockAction = createMockActionDetails();
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ const option = result.current[0];
+ expect(option.disabled).toBe(true);
+ });
+ });
+
+ describe('date formatting integration', () => {
+ it('should use correct date format from hook', () => {
+ const customDateFormat = 'YYYY-MM-DD HH:mm';
+ mockUseDateFormat.mockReturnValue(customDateFormat);
+
+ const mockAction = createMockActionDetails({
+ startedAt: '2023-11-01T10:00:00.000Z',
+ });
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(result.current[0].description).toContain('2023-11-01 10:00');
+ });
+
+ it('should use correct timezone from hook', () => {
+ const customTimeZone = 'America/New_York';
+ mockUseTimeZone.mockReturnValue(customTimeZone);
+
+ const mockAction = createMockActionDetails({
+ startedAt: '2023-11-01T10:00:00.000Z',
+ });
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ // Should format the time in EST (UTC-4/5 depending on DST)
+ expect(result.current[0].description).toContain('Nov 1, 2023 @ 06:00:00.000');
+ });
+
+ it('should call date format and timezone hooks', () => {
+ const mockAction = createMockActionDetails();
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(mockUseDateFormat).toHaveBeenCalled();
+ expect(mockUseTimeZone).toHaveBeenCalled();
+ });
+ });
+
+ describe('memoization and performance', () => {
+ it('should use memoization correctly', () => {
+ const mockAction = createMockActionDetails();
+ const mockResponse = createMockApiResponse([mockAction]);
+ const privilegeChecker = createPrivilegeChecker(true);
+
+ const { result, rerender } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ selectedValue: 'action-123-abc',
+ privilegeChecker,
+ })
+ );
+
+ const firstResult = result.current;
+
+ // Rerender with same inputs should return same structure
+ rerender();
+
+ expect(result.current).toEqual(firstResult);
+ const option = result.current[0];
+ expect(getOptionValue(option)).toBe('action-123-abc');
+ expect(option.checked).toBe('on');
+ });
+
+ it('should return new reference when response changes', () => {
+ const mockAction1 = createMockActionDetails({ id: 'action-1' });
+ const mockAction2 = createMockActionDetails({ id: 'action-2' });
+ const mockResponse1 = createMockApiResponse([mockAction1]);
+ const mockResponse2 = createMockApiResponse([mockAction2]);
+
+ const { result, rerender } = renderHook(
+ ({ response }) => usePendingActionsOptions({ response }),
+ {
+ initialProps: { response: [mockResponse1] },
+ }
+ );
+
+ const firstResult = result.current;
+
+ rerender({ response: [mockResponse2] });
+
+ expect(result.current).not.toBe(firstResult);
+ expect(getOptionValue(result.current[0])).toBe('action-2');
+ });
+
+ it('should return new reference when selectedValue changes', () => {
+ const mockAction = createMockActionDetails();
+ const mockResponse = createMockApiResponse([mockAction]);
+
+ const { result, rerender } = renderHook(
+ ({ selectedValue }: { selectedValue?: string }) =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ selectedValue,
+ }),
+ {
+ initialProps: { selectedValue: undefined as string | undefined },
+ }
+ );
+
+ const firstResult = result.current;
+
+ rerender({ selectedValue: 'action-123-abc' });
+
+ expect(result.current).not.toBe(firstResult);
+ expect(result.current[0].checked).toBe('on');
+ });
+
+ it('should return new reference when privilegeChecker changes', () => {
+ const mockAction = createMockActionDetails();
+ const mockResponse = createMockApiResponse([mockAction]);
+ const privilegeChecker1 = createPrivilegeChecker(true);
+ const privilegeChecker2 = createPrivilegeChecker(false);
+
+ const { result, rerender } = renderHook(
+ ({
+ privilegeChecker,
+ }: {
+ privilegeChecker?: (command: string) => { canCancel: boolean; reason?: string };
+ }) =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ privilegeChecker,
+ }),
+ {
+ initialProps: { privilegeChecker: privilegeChecker1 },
+ }
+ );
+
+ const firstResult = result.current;
+
+ rerender({ privilegeChecker: privilegeChecker2 });
+
+ expect(result.current).not.toBe(firstResult);
+ expect(result.current[0].disabled).toBe(true);
+ });
+ });
+
+ describe('edge cases and error handling', () => {
+ it('should handle malformed action data gracefully', () => {
+ const malformedAction = createMockActionDetails();
+ // Intentionally make data malformed for testing error handling
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (malformedAction as any).hosts = null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (malformedAction as any).agents = null;
+ const mockResponse = createMockApiResponse([malformedAction]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0].description).toContain(
+ 'Action id action-123-abc submitted by test-user'
+ );
+ });
+
+ it('should handle missing timestamp gracefully', () => {
+ const actionWithoutTimestamp = {
+ ...createMockActionDetails(),
+ startedAt: '',
+ };
+ const mockResponse = createMockApiResponse([actionWithoutTimestamp]);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0].description).toBeDefined();
+ });
+
+ it('should handle empty or invalid command names', () => {
+ const actions = [
+ (() => {
+ const action1 = createMockActionDetails({ id: 'action-1', command: 'isolate' });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (action1 as any).command = ''; // Intentionally invalid
+ return action1;
+ })(),
+ (() => {
+ const action2 = createMockActionDetails({ id: 'action-2', command: 'isolate' });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (action2 as any).command = null; // Intentionally invalid
+ return action2;
+ })(),
+ ];
+ const mockResponse = createMockApiResponse(actions);
+
+ const { result } = renderHook(() =>
+ usePendingActionsOptions({
+ response: [mockResponse],
+ })
+ );
+
+ expect(result.current).toHaveLength(2);
+ // Should still generate valid options even with invalid command names
+ expect(getOptionValue(result.current[0])).toBe('action-1');
+ expect(getOptionValue(result.current[1])).toBe('action-2');
+ });
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/hooks.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/hooks.ts
new file mode 100644
index 0000000000000..aad5845938083
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/hooks.ts
@@ -0,0 +1,199 @@
+/*
+ * 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 { useCallback, useEffect, useMemo, useState } from 'react';
+import { useIsMounted } from '@kbn/securitysolution-hook-utils';
+import moment from 'moment-timezone';
+import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
+import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
+import type { NotificationsStart } from '@kbn/core-notifications-browser';
+import type { ArgSelectorState } from '../../console/types';
+import type { BaseSelectorState } from './types';
+import type { ActionListApiResponse, ActionDetails } from '../../../../../common/endpoint/types';
+import type { ResponseActionsApiCommandNames } from '../../../../../common/endpoint/service/response_actions/constants';
+import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP } from '../../../../../common/endpoint/service/response_actions/constants';
+import { useDateFormat, useTimeZone } from '../../../../common/lib/kibana';
+import { getGenericErrorMessage, getPendingActionDescription } from '../../../common/translations';
+
+/**
+ * Generic error toast hook that handles both custom scripts and pending actions errors
+ */
+export const useGenericErrorToast = (
+ error: IHttpFetchError | null,
+ notifications: NotificationsStart,
+ errorTitlePrefix?: string
+): void => {
+ useEffect(() => {
+ if (error) {
+ let code = 'Error';
+ let message: string | undefined;
+
+ const err = error;
+ if (err?.body && typeof err.body === 'object' && 'message' in err.body) {
+ const errorBody = err.body as ResponseErrorBody;
+ message = errorBody.message;
+ code = String(errorBody.statusCode ?? code);
+ } else {
+ message = err?.message || String(err);
+ }
+
+ if (message) {
+ notifications.toasts.addDanger({
+ title: getGenericErrorMessage(errorTitlePrefix || '', code),
+ text: message,
+ });
+ }
+ }
+ }, [error, notifications, errorTitlePrefix]);
+};
+
+/**
+ * Hook to manage base selector state
+ */
+export const useBaseSelectorState = (
+ store: T | undefined,
+ value: string | undefined
+): T => {
+ return useMemo(() => {
+ return (store ?? { isPopoverOpen: !value }) as T;
+ }, [store, value]);
+};
+
+/**
+ * Hook to create base selector handlers
+ */
+export const useBaseSelectorHandlers = (
+ state: T,
+ onChange: (newData: ArgSelectorState) => void,
+ value: string,
+ valueText: string
+): {
+ setIsPopoverOpen: (newValue: boolean) => void;
+ handleOpenPopover: () => void;
+ handleClosePopover: () => void;
+} => {
+ const setIsPopoverOpen = useCallback(
+ (newValue: boolean) => {
+ onChange({
+ value,
+ valueText,
+ store: {
+ ...state,
+ isPopoverOpen: newValue,
+ },
+ });
+ },
+ [onChange, state, value, valueText]
+ );
+
+ const handleOpenPopover = useCallback(() => {
+ setIsPopoverOpen(true);
+ }, [setIsPopoverOpen]);
+
+ const handleClosePopover = useCallback(() => {
+ setIsPopoverOpen(false);
+ }, [setIsPopoverOpen]);
+
+ return {
+ setIsPopoverOpen,
+ handleOpenPopover,
+ handleClosePopover,
+ };
+};
+
+/**
+ * Hook to manage render delay state (handles race condition with parent input)
+ */
+export const useRenderDelay = (): boolean => {
+ const [isAwaitingRenderDelay, setIsAwaitingRenderDelay] = useState(true);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setIsAwaitingRenderDelay(false);
+ }, 0);
+
+ return () => clearTimeout(timer);
+ }, []);
+
+ return isAwaitingRenderDelay;
+};
+
+/**
+ * Hook to handle focus management when popover closes
+ */
+export const useFocusManagement = (isPopoverOpen: boolean, requestFocus?: () => void): void => {
+ const getIsMounted = useIsMounted();
+
+ useEffect(() => {
+ if (!isPopoverOpen && requestFocus) {
+ // Use setTimeout to ensure focus happens after the popover closes
+ setTimeout(() => {
+ if (getIsMounted() && requestFocus) {
+ requestFocus();
+ }
+ }, 0);
+ }
+ }, [isPopoverOpen, requestFocus, getIsMounted]);
+};
+
+/**
+ * Format timestamp using user's preferred date format and timezone settings
+ */
+const formatTimestamp = (timestamp: string, dateFormat: string, timeZone: string): string => {
+ return moment.tz(timestamp, timeZone).format(dateFormat);
+};
+
+/**
+ * Hook to transform pending actions response to selectable options with user's preferred date formatting
+ */
+export const usePendingActionsOptions = ({
+ response,
+ selectedValue,
+ privilegeChecker,
+}: {
+ response: ActionListApiResponse[] | null;
+ selectedValue?: string;
+ privilegeChecker?: (command: string) => { canCancel: boolean; reason?: string };
+}): EuiSelectableOption>[] => {
+ const dateFormat = useDateFormat();
+ const timeZone = useTimeZone();
+
+ return useMemo(() => {
+ const data = response?.[0]?.data;
+ if (!Array.isArray(data)) {
+ return [];
+ }
+
+ return data.map((action: ActionDetails) => {
+ const isChecked = action.id === selectedValue;
+ const timestamp = formatTimestamp(action.startedAt, dateFormat, timeZone);
+ const command = action.command;
+ const createdBy = action.createdBy;
+
+ // Use the console command name for display (e.g., 'release' instead of 'unisolate')
+ const displayCommand =
+ RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP[
+ command as ResponseActionsApiCommandNames
+ ] || command;
+
+ const description = getPendingActionDescription(action.id, createdBy, timestamp);
+
+ // Check if user has permission to cancel this action
+ const permissionCheck = privilegeChecker ? privilegeChecker(command) : { canCancel: false };
+ const isDisabled = !permissionCheck.canCancel;
+
+ return {
+ label: displayCommand,
+ value: action.id,
+ description,
+ data: action,
+ checked: isChecked ? 'on' : undefined,
+ disabled: isDisabled,
+ };
+ });
+ }, [response, selectedValue, privilegeChecker, dateFormat, timeZone]);
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/index.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/index.ts
new file mode 100644
index 0000000000000..57e7f0b5ad283
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/index.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export type { BaseSelectorState, BaseSelectorConfig } from './types';
+export {
+ SHARED_TRUNCATION_STYLE,
+ CUSTOM_SCRIPTS_CONFIG,
+ PENDING_ACTIONS_CONFIG,
+} from './constants';
+export {
+ useGenericErrorToast,
+ useBaseSelectorState,
+ useBaseSelectorHandlers,
+ useRenderDelay,
+ useFocusManagement,
+} from './hooks';
+export {
+ transformCustomScriptsToOptions,
+ createSelectionHandler,
+ createKeyDownHandler,
+ checkActionCancelPermission,
+} from './utils';
+export type { PendingActionItem } from './utils';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/types.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/types.ts
new file mode 100644
index 0000000000000..d78e8e995d482
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/types.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.
+ */
+
+/**
+ * Base state interface for all argument selectors
+ */
+export interface BaseSelectorState {
+ isPopoverOpen: boolean;
+ selectedOption?: unknown;
+}
+
+/**
+ * Configuration object for argument selectors
+ */
+export interface BaseSelectorConfig {
+ initialLabel: string;
+ tooltipText: string;
+ minWidth: number;
+ rowHeight: number;
+ selectableId: string;
+}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/utils.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/utils.test.ts
new file mode 100644
index 0000000000000..707601ed6a9f0
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/utils.test.ts
@@ -0,0 +1,165 @@
+/*
+ * 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 { ResponseActionScript } from '../../../../../common/endpoint/types';
+import { transformCustomScriptsToOptions, checkActionCancelPermission } from './utils';
+import type { EndpointAuthz } from '../../../../../common/endpoint/types/authz';
+
+describe('utils', () => {
+ describe('transformCustomScriptsToOptions', () => {
+ const mockScript: ResponseActionScript = {
+ id: 'script-1',
+ name: 'Test Script',
+ description: 'A test script for validation',
+ };
+
+ it('should transform scripts to options correctly', () => {
+ const result = transformCustomScriptsToOptions([mockScript]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ label: 'Test Script',
+ description: 'A test script for validation',
+ data: mockScript,
+ checked: undefined,
+ });
+ });
+
+ it('should mark selected script as checked', () => {
+ const result = transformCustomScriptsToOptions([mockScript], 'Test Script');
+
+ expect(result[0]).toMatchObject({
+ label: 'Test Script',
+ checked: 'on',
+ });
+ });
+
+ it('should handle empty scripts array', () => {
+ const result = transformCustomScriptsToOptions([]);
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('checkActionCancelPermission', () => {
+ const mockEndpointPrivileges: EndpointAuthz = {
+ canWriteSecuritySolution: true,
+ canReadSecuritySolution: true,
+ canAccessFleet: true,
+ canReadFleetAgentPolicies: true,
+ canReadFleetAgents: true,
+ canWriteFleetAgents: true,
+ canWriteIntegrationPolicies: true,
+ canAccessEndpointManagement: true,
+ canAccessEndpointActionsLogManagement: true,
+ canCreateArtifactsByPolicy: true,
+ canWriteEndpointList: true,
+ canReadEndpointList: true,
+ canWritePolicyManagement: true,
+ canReadPolicyManagement: true,
+ canWriteActionsLogManagement: true,
+ canReadActionsLogManagement: true,
+ canIsolateHost: true,
+ canUnIsolateHost: true,
+ canKillProcess: true,
+ canSuspendProcess: true,
+ canGetRunningProcesses: true,
+ canAccessResponseConsole: true,
+ canCancelAction: true,
+ canWriteExecuteOperations: true,
+ canWriteFileOperations: true,
+ canWriteScanOperations: true,
+ canWriteTrustedApplications: true,
+ canReadTrustedApplications: true,
+ canWriteTrustedDevices: true,
+ canReadTrustedDevices: true,
+ canWriteHostIsolationExceptions: true,
+ canReadHostIsolationExceptions: true,
+ canAccessHostIsolationExceptions: true,
+ canDeleteHostIsolationExceptions: true,
+ canWriteBlocklist: true,
+ canReadBlocklist: true,
+ canWriteEventFilters: true,
+ canReadEventFilters: true,
+ canReadEndpointExceptions: true,
+ canWriteEndpointExceptions: true,
+ canManageGlobalArtifacts: true,
+ canWriteWorkflowInsights: true,
+ canReadWorkflowInsights: true,
+ canReadAdminData: true,
+ canWriteAdminData: true,
+ };
+
+ describe('with valid permissions', () => {
+ test('returns canCancel: true for isolate command when user has canIsolateHost permission', () => {
+ const result = checkActionCancelPermission('isolate', mockEndpointPrivileges);
+ expect(result).toEqual({ canCancel: true });
+ });
+
+ test('returns canCancel: true for unisolate command when user has canUnIsolateHost permission', () => {
+ const result = checkActionCancelPermission('unisolate', mockEndpointPrivileges);
+ expect(result).toEqual({ canCancel: true });
+ });
+
+ test('returns canCancel: true for kill-process command when user has canKillProcess permission', () => {
+ const result = checkActionCancelPermission('kill-process', mockEndpointPrivileges);
+ expect(result).toEqual({ canCancel: true });
+ });
+
+ test('returns canCancel: true for execute command when user has canWriteExecuteOperations permission', () => {
+ const result = checkActionCancelPermission('execute', mockEndpointPrivileges);
+ expect(result).toEqual({ canCancel: true });
+ });
+ });
+
+ describe('without required permissions', () => {
+ test('returns canCancel: false for isolate command when user lacks canIsolateHost permission', () => {
+ const privilegesWithoutIsolate = {
+ ...mockEndpointPrivileges,
+ canIsolateHost: false,
+ };
+
+ const result = checkActionCancelPermission('isolate', privilegesWithoutIsolate);
+
+ expect(result.canCancel).toBe(false);
+ expect(result.reason).toContain("You don't have permission to run isolate action");
+ });
+
+ test('returns canCancel: false for kill-process command when user lacks canKillProcess permission', () => {
+ const privilegesWithoutKillProcess = {
+ ...mockEndpointPrivileges,
+ canKillProcess: false,
+ };
+
+ const result = checkActionCancelPermission('kill-process', privilegesWithoutKillProcess);
+
+ expect(result.canCancel).toBe(false);
+ expect(result.reason).toContain("You don't have permission to run kill-process action");
+ });
+
+ test('returns canCancel: false for get-file command when user lacks canWriteFileOperations permission', () => {
+ const privilegesWithoutFileOps = {
+ ...mockEndpointPrivileges,
+ canWriteFileOperations: false,
+ };
+
+ const result = checkActionCancelPermission('get-file', privilegesWithoutFileOps);
+
+ expect(result.canCancel).toBe(false);
+ expect(result.reason).toContain("You don't have permission to run get-file action");
+ });
+ });
+
+ describe('with unknown commands', () => {
+ test('returns canCancel: false for unknown command', () => {
+ const result = checkActionCancelPermission('unknown-command', mockEndpointPrivileges);
+
+ expect(result.canCancel).toBe(false);
+ expect(result.reason).toContain("You don't have permission to run unknown-command action.");
+ });
+ });
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/utils.ts b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/utils.ts
new file mode 100644
index 0000000000000..86409e0f429a0
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/console_argument_selectors/shared/utils.ts
@@ -0,0 +1,123 @@
+/*
+ * 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 { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
+import type { ActionDetails, ResponseActionScript } from '../../../../../common/endpoint/types';
+import type { EndpointAuthz } from '../../../../../common/endpoint/types/authz';
+import type { BaseSelectorState } from './types';
+import type { ResponseActionsApiCommandNames } from '../../../../../common/endpoint/service/response_actions/constants';
+import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP } from '../../../../../common/endpoint/service/response_actions/constants';
+import { canUserCancelCommand } from '../../../../../common/endpoint/service/authz/cancel_authz_utils';
+import {
+ getCancelPermissionDeniedMessage,
+ getPermissionVerificationErrorMessage,
+} from '../../../common/translations';
+
+/**
+ * Type representing a pending action item for cancellation
+ */
+export type PendingActionItem = ActionDetails;
+
+/**
+ * Generic handler for option selection in selectable components
+ */
+export const createSelectionHandler = (
+ onChange: (newData: { value: string; valueText: string; store: T }) => void,
+ state: T
+) => {
+ return (
+ _newOptions: EuiSelectableOption[],
+ _event: unknown,
+ changedOption: EuiSelectableOption
+ ) => {
+ if (changedOption.checked === 'on' && 'value' in changedOption) {
+ const newState = {
+ ...state,
+ isPopoverOpen: false,
+ selectedOption: changedOption.data,
+ };
+ onChange({
+ value: String(changedOption.value || ''),
+ valueText: String(changedOption.value || ''),
+ store: newState,
+ });
+ } else {
+ const newState = {
+ ...state,
+ isPopoverOpen: false,
+ selectedOption: undefined,
+ };
+ onChange({
+ value: '',
+ valueText: '',
+ store: newState,
+ });
+ }
+ };
+};
+
+/**
+ * Generic keyboard event handler for search input
+ */
+export const createKeyDownHandler = (event: React.KeyboardEvent) => {
+ // Only stop propagation for typing keys, not for navigation keys - otherwise input lose focus
+ if (!['Enter', 'ArrowUp', 'ArrowDown', 'Escape'].includes(event.key)) {
+ event.stopPropagation();
+ }
+};
+
+/**
+ * Transform custom scripts data to selectable options
+ */
+export const transformCustomScriptsToOptions = (
+ data: ResponseActionScript[],
+ selectedValue?: string
+): EuiSelectableOption>[] => {
+ return data.map((script: ResponseActionScript) => {
+ const isChecked = script.name === selectedValue;
+ return {
+ label: script.name,
+ value: script.name,
+ description: script.description,
+ checked: isChecked ? 'on' : undefined,
+ data: script,
+ };
+ });
+};
+
+/**
+ * Check if user has permission to cancel a specific action
+ */
+export const checkActionCancelPermission = (
+ command: string,
+ endpointPrivileges: EndpointAuthz
+): { canCancel: boolean; reason?: string } => {
+ const displayCommand =
+ RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP[command as ResponseActionsApiCommandNames] ||
+ command;
+
+ try {
+ const canCancel = canUserCancelCommand(
+ endpointPrivileges,
+ command as ResponseActionsApiCommandNames
+ );
+
+ if (!canCancel) {
+ return {
+ canCancel: false,
+ reason: getCancelPermissionDeniedMessage(displayCommand),
+ };
+ }
+
+ return { canCancel: true };
+ } catch (error) {
+ return {
+ canCancel: false,
+ reason: getPermissionVerificationErrorMessage(displayCommand),
+ };
+ }
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/cancel_action.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/cancel_action.tsx
new file mode 100644
index 0000000000000..6d860cdde710b
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/cancel_action.tsx
@@ -0,0 +1,58 @@
+/*
+ * 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 { memo, useMemo } from 'react';
+import { i18n } from '@kbn/i18n';
+import { useSendCancelRequest } from '../../../hooks/response_actions/use_send_cancel_request';
+import type { CancelActionRequestBody } from '../../../../../common/api/endpoint';
+import { useConsoleActionSubmitter } from '../hooks/use_console_action_submitter';
+import type { ActionRequestComponentProps } from '../types';
+
+export const CancelActionResult = memo<
+ ActionRequestComponentProps<{
+ action: string[];
+ }>
+>(({ command, setStore, store, status, setStatus, ResultComponent }) => {
+ const actionCreator = useSendCancelRequest();
+
+ const actionRequestBody = useMemo(() => {
+ const endpointId = command.commandDefinition?.meta?.endpointId;
+ const { action, comment } = command.args.args;
+ const agentType = command.commandDefinition?.meta?.agentType;
+
+ return endpointId
+ ? {
+ agent_type: agentType,
+ endpoint_ids: [endpointId],
+ comment: comment?.[0],
+ parameters: {
+ id: action[0],
+ },
+ }
+ : undefined;
+ }, [
+ command.args.args,
+ command.commandDefinition?.meta?.agentType,
+ command.commandDefinition?.meta?.endpointId,
+ ]);
+
+ return useConsoleActionSubmitter({
+ ResultComponent,
+ setStore,
+ store,
+ status,
+ setStatus,
+ actionCreator,
+ actionRequestBody,
+ dataTestSubj: 'cancel',
+ pendingMessage: i18n.translate('xpack.securitySolution.cancelAction.pendingMessage', {
+ defaultMessage: 'Cancel action in progress.',
+ }),
+ }).result;
+});
+
+CancelActionResult.displayName = 'CancelActionResult';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/cancel_action.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/cancel_action.test.tsx
new file mode 100644
index 0000000000000..e12b66414f2e5
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/cancel_action.test.tsx
@@ -0,0 +1,269 @@
+/*
+ * 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 { AppContextTestRender } from '../../../../../common/mock/endpoint';
+import { createAppRootMockRenderer } from '../../../../../common/mock/endpoint';
+import {
+ ConsoleManagerTestComponent,
+ getConsoleManagerMockRenderResultQueriesAndActions,
+} from '../../../console/components/console_manager/mocks';
+import React from 'react';
+import { getEndpointConsoleCommands } from '../../lib/console_commands_definition';
+import { responseActionsHttpMocks } from '../../../../mocks/response_actions_http_mocks';
+import { enterConsoleCommand } from '../../../console/mocks';
+import { waitFor } from '@testing-library/react';
+import userEvent, { type UserEvent } from '@testing-library/user-event';
+import type { getEndpointAuthzInitialState } from '../../../../../../common/endpoint/service/authz';
+import { getEndpointAuthzInitialStateMock } from '../../../../../../common/endpoint/service/authz/mocks';
+import { ENDPOINT_CAPABILITIES } from '../../../../../../common/endpoint/service/response_actions/constants';
+import type { PendingActionsResponse } from '../../../../../../common/endpoint/types';
+import { ExperimentalFeaturesService } from '../../../../../common/experimental_features_service';
+
+jest.mock('../../../../../common/experimental_features_service');
+
+const mockedExperimentalFeaturesService = ExperimentalFeaturesService as jest.Mocked<
+ typeof ExperimentalFeaturesService
+>;
+
+// Test data factories
+const createMockPendingActions = (
+ actions: Record = { isolate: 1 }
+): PendingActionsResponse => ({
+ data: [
+ {
+ agent_id: 'a.b.c',
+ pending_actions: actions,
+ },
+ ],
+});
+
+// Agent types for parameterized tests
+const UNSUPPORTED_AGENT_TYPES = ['endpoint', 'sentinel_one', 'crowdstrike'] as const;
+type AgentType = 'endpoint' | 'microsoft_defender_endpoint' | 'sentinel_one' | 'crowdstrike';
+
+describe('When using cancel action from response actions console', () => {
+ let mockedContext: AppContextTestRender;
+ let user: UserEvent;
+ let apiMocks: ReturnType;
+
+ // Simplified render function with minimal setup
+ const renderConsole = async (
+ agentType: AgentType = 'microsoft_defender_endpoint',
+ privileges: Partial> = {}
+ ) => {
+ const renderResult = mockedContext.render(
+ ({
+ consoleProps: {
+ 'data-test-subj': 'test',
+ commands: getEndpointConsoleCommands({
+ agentType,
+ endpointAgentId: 'a.b.c',
+ endpointCapabilities: [...ENDPOINT_CAPABILITIES],
+ endpointPrivileges: {
+ ...getEndpointAuthzInitialStateMock(),
+ loading: false,
+ ...privileges,
+ },
+ platform: 'windows',
+ }),
+ },
+ })}
+ />
+ );
+
+ const consoleManagerMockAccess = getConsoleManagerMockRenderResultQueriesAndActions(
+ user,
+ renderResult
+ );
+
+ await consoleManagerMockAccess.clickOnRegisterNewConsole();
+ await consoleManagerMockAccess.openRunningConsole();
+
+ return renderResult;
+ };
+
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
+ beforeEach(() => {
+ user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ mockedContext = createAppRootMockRenderer();
+
+ // Set default experimental flags
+ mockedContext.setExperimentalFlag({ microsoftDefenderEndpointCancelEnabled: true });
+
+ // Reset the ExperimentalFeaturesService mock to default enabled state
+ // @ts-expect-error - we do not need to specify whole object for testing purposes
+ mockedExperimentalFeaturesService.get.mockReturnValue({
+ microsoftDefenderEndpointCancelEnabled: true,
+ responseActionsMSDefenderEndpointEnabled: true,
+ microsoftDefenderEndpointRunScriptEnabled: true,
+ });
+
+ apiMocks = responseActionsHttpMocks(mockedContext.coreStart.http);
+ });
+
+ describe('Microsoft Defender Endpoint - Command visibility', () => {
+ it('should show cancel command in help when user has write permissions', async () => {
+ const renderResult = await renderConsole('microsoft_defender_endpoint');
+
+ await enterConsoleCommand(renderResult, user, 'help');
+
+ expect(renderResult.getByTestId('test-helpOutput').textContent).toContain('cancel');
+ });
+
+ it('should not show cancel command in help when user lacks write permissions', async () => {
+ const renderResult = await renderConsole('microsoft_defender_endpoint', {
+ canCancelAction: false,
+ });
+
+ await enterConsoleCommand(renderResult, user, 'help');
+
+ expect(renderResult.getByTestId('test-helpOutput').textContent).not.toContain('cancel');
+ });
+ });
+
+ describe('Microsoft Defender Endpoint - Command execution', () => {
+ it('should show error when trying to cancel without providing action ID', async () => {
+ const renderResult = await renderConsole('microsoft_defender_endpoint');
+
+ await enterConsoleCommand(renderResult, user, 'cancel');
+
+ expect(renderResult.getByTestId('test-badArgument-message')).toBeTruthy();
+ });
+
+ it('should show pending actions selector when using cancel command with --action flag', async () => {
+ apiMocks.responseProvider.agentPendingActionsSummary.mockReturnValue(
+ createMockPendingActions({ isolate: 1, 'kill-process': 1 })
+ );
+
+ const renderResult = await renderConsole('microsoft_defender_endpoint');
+
+ await enterConsoleCommand(renderResult, user, 'cancel --action', { inputOnly: true });
+
+ expect(renderResult.getByTestId('cancel-action-arg-0')).toBeTruthy();
+ });
+
+ it('should handle cancel API response correctly', async () => {
+ apiMocks.responseProvider.agentPendingActionsSummary.mockReturnValue(
+ createMockPendingActions()
+ );
+
+ const renderResult = await renderConsole('microsoft_defender_endpoint', {
+ canCancelAction: true,
+ });
+
+ await enterConsoleCommand(renderResult, user, 'cancel --action', { inputOnly: true });
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('cancel-action-arg-0')).toBeTruthy();
+ });
+ });
+
+ it('should validate user permissions for specific command types', async () => {
+ apiMocks.responseProvider.agentPendingActionsSummary.mockReturnValue(
+ createMockPendingActions({ 'kill-process': 1 })
+ );
+
+ const renderResult = await renderConsole('microsoft_defender_endpoint', {
+ canKillProcess: false,
+ canCancelAction: true,
+ });
+
+ await enterConsoleCommand(renderResult, user, 'cancel --action', { inputOnly: true });
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId('cancel-action-arg-0')).toBeTruthy();
+ });
+ });
+ });
+
+ describe.each(UNSUPPORTED_AGENT_TYPES)('Unsupported agent type: %s', (agentType) => {
+ it('should not show cancel command in help', async () => {
+ const renderResult = await renderConsole(agentType);
+
+ await enterConsoleCommand(renderResult, user, 'help');
+
+ expect(renderResult.getByTestId('test-helpOutput').textContent).not.toContain('cancel');
+ });
+
+ it('should show error when trying to use cancel command', async () => {
+ const renderResult = await renderConsole(agentType, {
+ canCancelAction: true,
+ });
+
+ await enterConsoleCommand(renderResult, user, 'cancel');
+
+ await waitFor(() => {
+ const historyItems = renderResult.container.querySelectorAll(
+ '[data-test-subj*="historyItem"]'
+ );
+ expect(historyItems.length).toBeGreaterThan(0);
+ });
+ });
+
+ it('should reject cancel command with action ID', async () => {
+ const renderResult = await renderConsole(agentType, {
+ canCancelAction: true,
+ });
+
+ await enterConsoleCommand(renderResult, user, 'cancel --action test-action-id');
+
+ await waitFor(() => {
+ const historyItems = renderResult.container.querySelectorAll(
+ '[data-test-subj*="historyItem"]'
+ );
+ expect(historyItems.length).toBeGreaterThan(0);
+ });
+ });
+ });
+
+ describe('Feature flag behavior', () => {
+ it('should enable cancel command for Microsoft Defender Endpoint when feature flag is enabled', async () => {
+ const renderResult = await renderConsole('microsoft_defender_endpoint');
+
+ await enterConsoleCommand(renderResult, user, 'help');
+
+ expect(renderResult.getByTestId('test-helpOutput').textContent).toContain('cancel');
+ });
+
+ it('should disable cancel command for Microsoft Defender Endpoint when feature flag is disabled', async () => {
+ // @ts-expect-error - we do not need to specify whole object for testing purposes
+ mockedExperimentalFeaturesService.get.mockReturnValue({
+ microsoftDefenderEndpointCancelEnabled: false,
+ responseActionsMSDefenderEndpointEnabled: true,
+ microsoftDefenderEndpointRunScriptEnabled: true,
+ });
+ mockedContext.setExperimentalFlag({ microsoftDefenderEndpointCancelEnabled: false });
+
+ const renderResult = await renderConsole('microsoft_defender_endpoint');
+
+ await enterConsoleCommand(renderResult, user, 'help');
+
+ expect(renderResult.getByTestId('test-helpOutput').textContent).not.toContain('cancel');
+ });
+
+ describe.each(UNSUPPORTED_AGENT_TYPES)(
+ 'Agent type %s with feature flag enabled',
+ (agentType) => {
+ it('should not show cancel command regardless of feature flag', async () => {
+ const renderResult = await renderConsole(agentType);
+
+ await enterConsoleCommand(renderResult, user, 'help');
+
+ expect(renderResult.getByTestId('test-helpOutput').textContent).not.toContain('cancel');
+ });
+ }
+ );
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/runscript_action.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/runscript_action.test.tsx
index f75c1cbfe2836..d5f74c3037793 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/runscript_action.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/runscript_action.test.tsx
@@ -150,7 +150,7 @@ describe('When using runscript action from response console', () => {
});
await waitFor(() => {
- expect(renderResult.getAllByTestId('scriptSelector-runscript-script')).toHaveLength(2);
+ expect(renderResult.getAllByTestId('scriptSelector-runscript-0-script')).toHaveLength(2);
});
});
@@ -167,10 +167,10 @@ describe('When using runscript action from response console', () => {
await render();
await enterConsoleCommand(renderResult, user, 'runscript --script', { inputOnly: true });
await waitFor(() =>
- user.click(renderResult.getAllByTestId('scriptSelector-runscript-script')[0])
+ user.click(renderResult.getAllByTestId('scriptSelector-runscript-0-script')[0])
);
await waitFor(() => {
- expect(renderResult.queryByTestId('scriptSelector-runscript-popupPanel')).toBeNull();
+ expect(renderResult.queryByTestId('scriptSelector-runscript-0-popoverPanel')).toBeNull();
});
consoleMockUtils.submitCommand();
@@ -183,10 +183,10 @@ describe('When using runscript action from response console', () => {
await render();
await enterConsoleCommand(renderResult, user, 'runscript --script', { inputOnly: true });
await waitFor(() =>
- user.click(renderResult.getAllByTestId('scriptSelector-runscript-script')[0])
+ user.click(renderResult.getAllByTestId('scriptSelector-runscript-0-script')[0])
);
await waitFor(() => {
- expect(renderResult.queryByTestId('scriptSelector-runscript-popupPanel')).toBeNull();
+ expect(renderResult.queryByTestId('scriptSelector-runscript-0-popoverPanel')).toBeNull();
});
expect(renderResult.getByTestId('test-footer')).toHaveTextContent(
@@ -198,10 +198,10 @@ describe('When using runscript action from response console', () => {
await render();
await enterConsoleCommand(renderResult, user, 'runscript --script', { inputOnly: true });
await waitFor(() =>
- user.click(renderResult.getAllByTestId('scriptSelector-runscript-script')[1])
+ user.click(renderResult.getAllByTestId('scriptSelector-runscript-0-script')[1])
);
await waitFor(() => {
- expect(renderResult.queryByTestId('scriptSelector-runscript-popupPanel')).toBeNull();
+ expect(renderResult.queryByTestId('scriptSelector-runscript-0-popoverPanel')).toBeNull();
});
consoleMockUtils.submitCommand();
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 69bb2ff1cf0f8..97ce1f139554f 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,11 +6,14 @@
*/
import { i18n } from '@kbn/i18n';
+import { CancelActionResult } from '../command_render_components/cancel_action';
import { isActionSupportedByAgentType } from '../../../../../common/endpoint/service/response_actions/is_response_action_supported';
+import { isCancelFeatureAvailable } from '../../../../../common/endpoint/service/authz/cancel_authz_utils';
import type { SupportedHostOsType } from '../../../../../common/endpoint/constants';
import type { EndpointCommandDefinitionMeta } from '../types';
import type { CustomScriptSelectorState } from '../../console_argument_selectors/custom_scripts_selector/custom_script_selector';
import { CustomScriptSelector } from '../../console_argument_selectors/custom_scripts_selector/custom_script_selector';
+import { PendingActionsSelector } from '../../console_argument_selectors/pending_actions_selector/pending_actions_selector';
import type { SentinelOneRunScriptActionParameters } from '../command_render_components/run_script_action';
import { RunScriptActionResult } from '../command_render_components/run_script_action';
import type { CommandArgDefinition } from '../../console/types';
@@ -184,6 +187,7 @@ export const getEndpointConsoleCommands = ({
responseActionUploadEnabled: isUploadEnabled,
crowdstrikeRunScriptEnabled,
microsoftDefenderEndpointRunScriptEnabled,
+ microsoftDefenderEndpointCancelEnabled,
} = featureFlags;
const commandMeta: EndpointCommandDefinitionMeta = {
agentType,
@@ -209,6 +213,10 @@ export const getEndpointConsoleCommands = ({
return false;
};
+ const canCancelForCurrentContext = () => {
+ return isCancelFeatureAvailable(endpointPrivileges, featureFlags, agentType);
+ };
+
const consoleCommands: CommandDefinition[] = [
{
name: 'isolate',
@@ -522,6 +530,45 @@ export const getEndpointConsoleCommands = ({
});
}
+ if (microsoftDefenderEndpointCancelEnabled) {
+ consoleCommands.push({
+ name: 'cancel',
+ about: getCommandAboutInfo({
+ aboutInfo: CONSOLE_COMMANDS.cancel.about,
+ isSupported: canCancelForCurrentContext(),
+ }),
+ RenderComponent: CancelActionResult,
+ meta: commandMeta,
+ exampleUsage: 'cancel --action="action-123-456-789"',
+ exampleInstruction: i18n.translate(
+ 'xpack.securitySolution.endpointConsoleCommands.cancel.exampleInstruction',
+ { defaultMessage: 'Select a pending action to cancel' }
+ ),
+ mustHaveArgs: true,
+ args: {
+ action: {
+ required: true,
+ allowMultiples: false,
+ about: i18n.translate(
+ 'xpack.securitySolution.endpointConsoleCommands.cancel.action.about',
+ {
+ defaultMessage: 'The response action to cancel',
+ }
+ ),
+ mustHaveValue: 'truthy',
+ selectorShowTextValue: true,
+ SelectorComponent: PendingActionsSelector,
+ },
+ },
+ helpGroupLabel: HELP_GROUPS.responseActions.label,
+ helpGroupPosition: HELP_GROUPS.responseActions.position,
+ helpCommandPosition: 10,
+ helpDisabled: !canCancelForCurrentContext(),
+ helpHidden: !canCancelForCurrentContext(),
+ validate: capabilitiesAndPrivilegesValidator(agentType),
+ });
+ }
+
switch (agentType) {
case 'sentinel_one':
return adjustCommandsForSentinelOne({ commandList: consoleCommands, platform });
@@ -813,6 +860,8 @@ const adjustCommandsForMicrosoftDefenderEndpoint = ({
}): CommandDefinition[] => {
const featureFlags = ExperimentalFeaturesService.get();
const isMicrosoftDefenderEndpointEnabled = featureFlags.responseActionsMSDefenderEndpointEnabled;
+ const microsoftDefenderEndpointCancelEnabled =
+ featureFlags.microsoftDefenderEndpointCancelEnabled;
return commandList.map((command) => {
if (
@@ -827,6 +876,9 @@ const adjustCommandsForMicrosoftDefenderEndpoint = ({
disableCommand(command, 'microsoft_defender_endpoint');
}
+ if (command.name === 'cancel' && !microsoftDefenderEndpointCancelEnabled) {
+ disableCommand(command, 'microsoft_defender_endpoint');
+ }
if (command.name === 'runscript') {
if (!microsoftDefenderEndpointRunScriptEnabled) {
disableCommand(command, 'microsoft_defender_endpoint');
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx
index 926610cadc239..6bf9d3dfdf1e0 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx
@@ -74,7 +74,7 @@ describe('When displaying Endpoint Response Actions', () => {
);
const endpointCommands = CONSOLE_RESPONSE_ACTION_COMMANDS.filter(
- (command) => command !== 'runscript'
+ (command) => command !== 'runscript' && command !== 'cancel'
);
const expectedCommands: string[] = [...endpointCommands];
// add status to the list of expected commands in that order
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx
index 565341914fedd..46d4af1261ba3 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/hooks.tsx
@@ -351,6 +351,9 @@ export const useActionsLogFilter = ({
) {
return false;
}
+ if (commandName === 'cancel' && !featureFlags.microsoftDefenderEndpointCancelEnabled) {
+ return false;
+ }
return true;
}).map((commandName) => ({
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx
index f2a983bd2d972..4400fdd763d35 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/integration_tests/response_actions_log.test.tsx
@@ -1527,6 +1527,7 @@ describe('Response actions history', () => {
featureFlags = {
responseActionUploadEnabled: true,
crowdstrikeRunScriptEnabled: true,
+ microsoftDefenderEndpointCancelEnabled: true,
};
mockedContext.setExperimentalFlag(featureFlags);
@@ -1546,7 +1547,7 @@ describe('Response actions history', () => {
);
});
- it('should show a list of actions (with `runscript`) when opened', async () => {
+ it('should show a list of actions (with `runscript` and `cancel`) 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;
@@ -1557,6 +1558,38 @@ describe('Response actions history', () => {
expect(getAllByTestId(`${filterPrefix}-option`).length).toEqual(
RESPONSE_ACTION_API_COMMANDS_NAMES.length
);
+ expect(getAllByTestId(`${filterPrefix}-option`).map((option) => option.textContent)).toEqual([
+ 'isolate. To check this option, press Enter.',
+ 'release. To check this option, press Enter.',
+ 'kill-process. To check this option, press Enter.',
+ 'suspend-process. To check this option, press Enter.',
+ 'processes. To check this option, press Enter.',
+ 'get-file. To check this option, press Enter.',
+ '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.',
+ 'cancel. To check this option, press Enter.',
+ ]);
+ });
+
+ it('should show a list of actions (without `cancel`) when cancel feature flag is disabled', async () => {
+ // Set the cancel feature flag to false
+ const featureFlagsWithoutCancel = {
+ ...featureFlags,
+ microsoftDefenderEndpointCancelEnabled: false,
+ };
+ mockedContext.setExperimentalFlag(featureFlagsWithoutCancel);
+
+ render({ 'data-test-height': 350 });
+ const { getByTestId, getAllByTestId } = renderResult;
+
+ await user.click(getByTestId(`${testPrefix}-${filterPrefix}-popoverButton`));
+ const filterList = getByTestId(`${testPrefix}-${filterPrefix}-popoverList`);
+ expect(filterList).toBeTruthy();
+ expect(getAllByTestId(`${filterPrefix}-option`).length).toEqual(
+ RESPONSE_ACTION_API_COMMANDS_NAMES.length - 1
+ );
expect(getAllByTestId(`${filterPrefix}-option`).map((option) => option.textContent)).toEqual([
'isolate. To check this option, press Enter.',
'release. To check this option, press Enter.',
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts
index bc513fe6905b6..bf0955cbe13ce 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/complete.cy.ts
@@ -61,7 +61,7 @@ describe(
// No access to response actions (except `unisolate`)
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
- (apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
+ (apiName) => apiName !== 'unisolate' && apiName !== 'runscript' && apiName !== 'cancel'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);
@@ -86,7 +86,7 @@ describe(
// No access to response actions (except `unisolate`)
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
- (apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
+ (apiName) => apiName !== 'unisolate' && apiName !== 'runscript' && apiName !== 'cancel'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts
index 86ed4c37fa4fa..a87acce648e0e 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials.cy.ts
@@ -59,7 +59,7 @@ describe(
// No access to response actions (except `unisolate`)
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
- (apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
+ (apiName) => apiName !== 'unisolate' && apiName !== 'runscript' && apiName !== 'cancel'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);
@@ -84,7 +84,7 @@ describe(
// No access to response actions (except `unisolate`)
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
- (apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
+ (apiName) => apiName !== 'unisolate' && apiName !== 'runscript' && apiName !== 'cancel'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts
index 4d0835b6bae9e..8226ba306fac9 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/essentials_with_endpoint.cy.ts
@@ -70,7 +70,7 @@ describe(
}
for (const actionName of RESPONSE_ACTION_API_COMMANDS_NAMES.filter(
- (apiName) => apiName !== 'unisolate' && apiName !== 'runscript'
+ (apiName) => apiName !== 'unisolate' && apiName !== 'runscript' && apiName !== 'cancel'
)) {
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 !== 'runscript'
+ (apiName) => apiName !== 'unisolate' && apiName !== 'runscript' && apiName !== 'cancel'
)) {
it(`should not allow access to Response Action: ${actionName}`, () => {
ensureResponseActionAuthzAccess('none', actionName, username, password);
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts
index ca22891247698..78a32c9d918f1 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/e2e/serverless/roles/complete_with_endpoint_roles.cy.ts
@@ -126,6 +126,7 @@ describe(
'scan'
// TODO: currently not implemented for Endpoint
// 'runscript'
+ // 'cancel'
);
const deniedResponseActions = pick(consoleHelpPanelResponseActionsTestSubj, 'execute');
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/screens/responder.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/screens/responder.ts
index 7575b1ba7d1b5..a1ce1f6b9b4ba 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/screens/responder.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/screens/responder.ts
@@ -15,8 +15,8 @@ const TEST_SUBJ = Object.freeze({
});
export const getConsoleHelpPanelResponseActionTestSubj = (): Record<
- // TODO: currently runscript is not supported in Endpoint
- Exclude,
+ // TODO: currently runscript and cancel are not supported in Endpoint
+ Exclude,
string
> => {
return {
@@ -30,6 +30,7 @@ export const getConsoleHelpPanelResponseActionTestSubj = (): Record<
upload: 'endpointResponseActionsConsole-commandList-Responseactions-upload',
scan: 'endpointResponseActionsConsole-commandList-Responseactions-scan',
// Not implemented in Endpoint yet
+ // cancel: 'endpointResponseActionsConsole-commandList-Responseactions-cancel',
// runscript: 'endpointResponseActionsConsole-commandList-Responseactions-runscript',
};
};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/response_actions.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/response_actions.ts
index 3fd44e988b4ad..8bcfe743abf3f 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/response_actions.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/response_actions.ts
@@ -21,6 +21,7 @@ import {
SUSPEND_PROCESS_ROUTE,
UNISOLATE_HOST_ROUTE_V2,
UPLOAD_ROUTE,
+ CANCEL_ROUTE,
} from '../../../../common/endpoint/constants';
import type { ActionDetails, ActionDetailsApiResponse } from '../../../../common/endpoint/types';
import type { ResponseActionsApiCommandNames } from '../../../../common/endpoint/service/response_actions/constants';
@@ -280,6 +281,11 @@ export const ensureResponseActionAuthzAccess = (
Object.assign(apiPayload, { parameters: { Raw: 'ls' } });
break;
+ case 'cancel':
+ url = CANCEL_ROUTE;
+ Object.assign(apiPayload, { action_id: 'some-action-id' });
+ break;
+
default:
throw new Error(`Response action [${responseAction}] has no API payload defined`);
}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_get_endpoint_action_list.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_get_endpoint_action_list.test.ts
index 45fdfeaf68335..e41b8f0f02766 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_get_endpoint_action_list.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_get_endpoint_action_list.test.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import type { AppContextTestRender, ReactQueryHookRenderer } from '../../../common/mock/endpoint';
+import type { ReactQueryHookRenderer, AppContextTestRender } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
import { useGetEndpointActionList } from './use_get_endpoint_action_list';
import { BASE_ENDPOINT_ACTION_ROUTE } from '../../../../common/endpoint/constants';
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_get_pending_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_get_pending_actions.tsx
new file mode 100644
index 0000000000000..126517d7a66bb
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_get_pending_actions.tsx
@@ -0,0 +1,67 @@
+/*
+ * 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 { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
+import { BASE_ENDPOINT_ACTION_ROUTE } from '../../../../common/endpoint/constants';
+import { useHttp } from '../../../common/lib/kibana';
+import type { ActionListApiResponse } from '../../../../common/endpoint/types';
+
+/**
+ * Request parameters for pending actions API
+ */
+export interface PendingActionsRequestQueryParams {
+ agentType?: ResponseActionAgentType;
+ endpointId?: string;
+ page?: number;
+ pageSize?: number;
+ commands?: string[];
+}
+
+/**
+ * Error type for pending actions API errors
+ */
+export interface PendingActionsErrorType {
+ statusCode: number;
+ message: string;
+}
+
+/**
+ * Hook to retrieve pending response actions for cancellation
+ * Uses the standard actions list endpoint with pending status filter
+ * @param params - Query parameters including agentType, endpointId, etc.
+ * @param options - Additional options for the query
+ * @returns Query result containing pending actions data
+ */
+export const useGetPendingActions = (
+ params: PendingActionsRequestQueryParams,
+ options: Omit<
+ UseQueryOptions>,
+ 'queryKey' | 'queryFn'
+ > = {}
+): UseQueryResult> => {
+ const http = useHttp();
+
+ return useQuery>({
+ queryKey: ['get-pending-actions', params] as const,
+ queryFn: async (): Promise => {
+ return http.get(BASE_ENDPOINT_ACTION_ROUTE, {
+ version: '2023-10-31',
+ query: {
+ agentIds: params.endpointId ? [params.endpointId] : undefined,
+ agentTypes: params.agentType ? [params.agentType] : undefined,
+ commands: params.commands,
+ page: params.page,
+ pageSize: params.pageSize,
+ statuses: ['pending'], // Filter for pending actions only
+ },
+ });
+ },
+ ...options,
+ });
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_send_cancel_request.test.ts b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_send_cancel_request.test.ts
new file mode 100644
index 0000000000000..f3b9977f1e680
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_send_cancel_request.test.ts
@@ -0,0 +1,202 @@
+/*
+ * 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 { RenderHookResult } from '@testing-library/react';
+import type { AppContextTestRender } from '../../../common/mock/endpoint';
+import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
+import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mocks';
+import { CANCEL_ROUTE } from '../../../../common/endpoint/constants';
+import type { CancelActionRequestBody } from '../../../../common/api/endpoint';
+import type {
+ CancelRequestCustomOptions,
+ UseSendCancelRequestResult,
+} from './use_send_cancel_request';
+import { useSendCancelRequest } from './use_send_cancel_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 cancelPayload: CancelActionRequestBody = {
+ endpoint_ids: ['test-endpoint-id'],
+ agent_type: 'endpoint',
+ parameters: {
+ id: 'test-action-id',
+ },
+};
+
+describe('When using the `useSendCancelRequest()` hook', () => {
+ let customOptions: CancelRequestCustomOptions;
+ let http: AppContextTestRender['coreStart']['http'];
+ let apiMocks: ReturnType;
+ let renderHook: () => RenderHookResult;
+
+ beforeEach(() => {
+ const testContext = createAppRootMockRenderer();
+
+ http = testContext.coreStart.http;
+ apiMocks = responseActionsHttpMocks(http);
+ customOptions = {};
+
+ renderHook = () => {
+ return testContext.renderHook(() => useSendCancelRequest(customOptions));
+ };
+ });
+
+ it('should call the `cancel` API with correct payload', async () => {
+ const {
+ result: {
+ current: { mutateAsync },
+ },
+ } = renderHook();
+ await mutateAsync(cancelPayload);
+
+ expect(apiMocks.responseProvider.cancel).toHaveBeenCalledWith({
+ body: JSON.stringify(cancelPayload),
+ path: CANCEL_ROUTE,
+ version: '2023-10-31',
+ });
+ });
+
+ it('should allow custom options to be passed to ReactQuery', async () => {
+ customOptions.mutationKey = ['cancel-action-key'];
+ customOptions.cacheTime = 10;
+ renderHook();
+
+ expect(useMutationMock).toHaveBeenCalledWith(expect.any(Function), customOptions);
+ });
+
+ it('should handle successful cancel request', async () => {
+ const {
+ result: {
+ current: { mutateAsync },
+ },
+ } = renderHook();
+
+ const result = await mutateAsync(cancelPayload);
+
+ expect(result).toBeDefined();
+ expect(result.data).toBeDefined();
+ expect(apiMocks.responseProvider.cancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle cancel request with optional fields', async () => {
+ const payloadWithOptionalFields: CancelActionRequestBody = {
+ ...cancelPayload,
+ alert_ids: ['alert-1', 'alert-2'],
+ case_ids: ['case-1'],
+ comment: 'Cancelling this action',
+ };
+
+ const {
+ result: {
+ current: { mutateAsync },
+ },
+ } = renderHook();
+ await mutateAsync(payloadWithOptionalFields);
+
+ expect(apiMocks.responseProvider.cancel).toHaveBeenCalledWith({
+ body: JSON.stringify(payloadWithOptionalFields),
+ path: CANCEL_ROUTE,
+ version: '2023-10-31',
+ });
+ });
+
+ it('should handle different agent types', async () => {
+ const sentinelOnePayload: CancelActionRequestBody = {
+ ...cancelPayload,
+ agent_type: 'sentinel_one',
+ };
+
+ const {
+ result: {
+ current: { mutateAsync },
+ },
+ } = renderHook();
+ await mutateAsync(sentinelOnePayload);
+
+ expect(apiMocks.responseProvider.cancel).toHaveBeenCalledWith({
+ body: JSON.stringify(sentinelOnePayload),
+ path: CANCEL_ROUTE,
+ version: '2023-10-31',
+ });
+ });
+
+ it('should handle multiple endpoint IDs', async () => {
+ const multiEndpointPayload: CancelActionRequestBody = {
+ ...cancelPayload,
+ endpoint_ids: ['endpoint-1', 'endpoint-2', 'endpoint-3'],
+ };
+
+ const {
+ result: {
+ current: { mutateAsync },
+ },
+ } = renderHook();
+ await mutateAsync(multiEndpointPayload);
+
+ expect(apiMocks.responseProvider.cancel).toHaveBeenCalledWith({
+ body: JSON.stringify(multiEndpointPayload),
+ path: CANCEL_ROUTE,
+ version: '2023-10-31',
+ });
+ });
+
+ it('should pass custom mutation options correctly', () => {
+ const customMutationOptions: CancelRequestCustomOptions = {
+ mutationKey: ['custom-cancel-key'],
+ retry: 3,
+ retryDelay: 1000,
+ onSuccess: jest.fn(),
+ onError: jest.fn(),
+ onSettled: jest.fn(),
+ };
+
+ const testContext = createAppRootMockRenderer();
+ testContext.renderHook(() => useSendCancelRequest(customMutationOptions));
+
+ expect(useMutationMock).toHaveBeenCalledWith(expect.any(Function), customMutationOptions);
+ });
+
+ it('should use correct API version', async () => {
+ const {
+ result: {
+ current: { mutateAsync },
+ },
+ } = renderHook();
+ await mutateAsync(cancelPayload);
+
+ expect(apiMocks.responseProvider.cancel).toHaveBeenCalledWith(
+ expect.objectContaining({
+ version: '2023-10-31',
+ })
+ );
+ });
+
+ it('should serialize request body as JSON string', async () => {
+ const {
+ result: {
+ current: { mutateAsync },
+ },
+ } = renderHook();
+ await mutateAsync(cancelPayload);
+
+ expect(apiMocks.responseProvider.cancel).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: JSON.stringify(cancelPayload),
+ })
+ );
+ });
+});
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_send_cancel_request.ts b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_send_cancel_request.ts
new file mode 100644
index 0000000000000..88cb546e941d7
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/hooks/response_actions/use_send_cancel_request.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 { IHttpFetchError } from '@kbn/core-http-browser';
+import {
+ useMutation,
+ type UseMutationOptions,
+ type UseMutationResult,
+} from '@tanstack/react-query';
+import type { ResponseActionApiResponse } from '../../../../common/endpoint/types';
+import type { CancelActionRequestBody } from '../../../../common/api/endpoint';
+import { KibanaServices } from '../../../common/lib/kibana';
+import { CANCEL_ROUTE } from '../../../../common/endpoint/constants';
+
+export type CancelRequestCustomOptions = UseMutationOptions<
+ ResponseActionApiResponse,
+ IHttpFetchError,
+ CancelActionRequestBody
+>;
+
+export type UseSendCancelRequestResult = UseMutationResult<
+ ResponseActionApiResponse,
+ IHttpFetchError,
+ CancelActionRequestBody
+>;
+/**
+ * Create cancel request
+ * @param customOptions
+ */
+export const useSendCancelRequest = (
+ customOptions?: CancelRequestCustomOptions
+): UseSendCancelRequestResult => {
+ return useMutation(
+ (reqBody) => {
+ return KibanaServices.get().http.post(CANCEL_ROUTE, {
+ body: JSON.stringify(reqBody),
+ version: '2023-10-31',
+ });
+ },
+ customOptions
+ );
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts b/x-pack/solutions/security/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts
index d6043160236c6..4dfd9dff204b4 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts
@@ -12,6 +12,7 @@ import {
ACTION_DETAILS_ROUTE,
ACTION_STATUS_ROUTE,
BASE_ENDPOINT_ACTION_ROUTE,
+ CANCEL_ROUTE,
CUSTOM_SCRIPTS_ROUTE,
EXECUTE_ROUTE,
GET_FILE_ROUTE,
@@ -36,6 +37,7 @@ import type {
GetProcessesActionOutputContent,
PendingActionsResponse,
ResponseActionApiResponse,
+ ResponseActionCancelParameters,
ResponseActionExecuteOutputContent,
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters,
@@ -47,6 +49,7 @@ import type {
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters,
ResponseActionScriptsApiResponse,
+ ResponseActionCancelOutputContent,
} from '../../../common/endpoint/types';
export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
@@ -82,6 +85,11 @@ export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
fetchScriptList: () => ResponseActionScriptsApiResponse;
runscript: () => ActionDetailsApiResponse;
+
+ cancel: () => ActionDetailsApiResponse<
+ ResponseActionCancelOutputContent,
+ ResponseActionCancelParameters
+ >;
}>;
export const responseActionsHttpMocks = httpHandlerMockFactory([
@@ -311,6 +319,25 @@ export const responseActionsHttpMocks = httpHandlerMockFactory => {
+ const generator = new EndpointActionGenerator('seed');
+ const response = generator.generateActionDetails<
+ ResponseActionCancelOutputContent,
+ ResponseActionCancelParameters
+ >({
+ command: 'cancel',
+ });
+
return { data: response };
},
},
diff --git a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/response_actions.ts b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/response_actions.ts
index 1008ef162ffc4..009d9c2c4d3af 100644
--- a/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/response_actions.ts
+++ b/x-pack/solutions/security/plugins/security_solution/scripts/endpoint/common/response_actions.ts
@@ -151,6 +151,14 @@ export const sendEndpointActionResponse = async (
.content as unknown as ResponseActionRunScriptOutputContent
).stderr = 'runscript command timed out';
}
+
+ if (
+ endpointResponse.EndpointActions.data.command === 'cancel' &&
+ endpointResponse.EndpointActions.data.output
+ ) {
+ (endpointResponse.EndpointActions.data.output.content as unknown as { code: string }).code =
+ 'ra_cancel_error';
+ }
}
await esClient
@@ -346,6 +354,16 @@ const getOutputDataIfNeeded = (action: ActionDetails): ResponseOutput => {
}),
} as unknown as ResponseOutput;
+ case 'cancel':
+ return {
+ output: {
+ type: 'json',
+ content: {
+ code: 'ra_cancel_success_done',
+ },
+ },
+ } as unknown as ResponseOutput;
+
default:
return { output: undefined };
}
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
index ee635266164b7..6e22060269542 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
@@ -35,6 +35,7 @@ import {
SUSPEND_PROCESS_ROUTE,
UNISOLATE_HOST_ROUTE_V2,
UPLOAD_ROUTE,
+ CANCEL_ROUTE,
} from '../../../../common/endpoint/constants';
import type {
ActionDetails,
@@ -61,10 +62,13 @@ import * as ActionDetailsService from '../../services/actions/action_details_by_
import { CaseStatuses } from '@kbn/cases-components';
import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks';
import { getResponseActionsClient as _getResponseActionsClient } from '../../services';
+import type { ResponseActionsClient } from '../../services';
import type {
ResponseActionsRequestBody,
UploadActionApiRequestBody,
+ CancelActionRequestBody,
} from '../../../../common/api/endpoint';
+import * as fetchActionUtils from '../../services/actions/utils/fetch_action_request_by_id';
import type { FleetToHostFileClientInterface } from '@kbn/fleet-plugin/server';
import type { HapiReadableStream, SecuritySolutionRequestHandlerContext } from '../../../types';
import { createHapiReadableStreamMock } from '../../services/actions/mocks';
@@ -1038,6 +1042,290 @@ describe('Response actions', () => {
);
});
});
+
+ describe('Cancel Action Authorization', () => {
+ let fetchActionByIdSpy: jest.SpyInstance;
+ let responseActionsClientMockInstance: jest.Mocked>;
+ let originalGetResponseActionsClientMock: ((...args: any) => any) | undefined;
+ const mockIsolateAction: Partial = {
+ EndpointActions: {
+ action_id: 'test-action-id-1',
+ expiration: '2024-12-31T23:59:59.999Z',
+ type: 'INPUT_ACTION' as const,
+ input_type: 'microsoft_defender_endpoint' as const,
+ data: {
+ command: 'isolate',
+ },
+ },
+ };
+ const mockExecuteAction: Partial = {
+ EndpointActions: {
+ action_id: 'test-action-id-2',
+ expiration: '2024-12-31T23:59:59.999Z',
+ type: 'INPUT_ACTION' as const,
+ input_type: 'microsoft_defender_endpoint' as const,
+ data: {
+ command: 'runscript',
+ },
+ },
+ };
+
+ beforeEach(() => {
+ // Enable the experimental feature for cancel actions
+ endpointContext.experimentalFeatures = {
+ ...endpointContext.experimentalFeatures,
+ microsoftDefenderEndpointCancelEnabled: true,
+ };
+
+ // Store the original mock implementation
+ originalGetResponseActionsClientMock =
+ (getResponseActionsClientMock as jest.Mock).getMockImplementation() || jest.fn();
+
+ fetchActionByIdSpy = jest
+ .spyOn(fetchActionUtils, 'fetchActionRequestById')
+ .mockResolvedValue(mockIsolateAction as LogsEndpointAction);
+
+ // Mock the response actions client
+ responseActionsClientMockInstance = {
+ cancel: jest.fn().mockResolvedValue({
+ id: 'mock-cancel-action-id',
+ agents: ['agent-id'],
+ command: 'cancel',
+ isCompleted: true,
+ isExpired: false,
+ wasSuccessful: true,
+ status: 'successful',
+ outputs: {},
+ agentState: {},
+ createdBy: 'test-user',
+ startedAt: '2023-05-01T12:00:00Z',
+ completedAt: '2023-05-01T12:01:00Z',
+ comment: '',
+ action_id: 'test-action-id',
+ }),
+ };
+
+ (getResponseActionsClientMock as jest.Mock).mockReturnValue(
+ responseActionsClientMockInstance
+ );
+ });
+
+ afterEach(() => {
+ fetchActionByIdSpy.mockRestore();
+ // Restore the original mock implementation
+ if (originalGetResponseActionsClientMock) {
+ (getResponseActionsClientMock as jest.Mock).mockImplementation(
+ originalGetResponseActionsClientMock
+ );
+ } else {
+ (getResponseActionsClientMock as jest.Mock).mockRestore();
+ }
+ jest.clearAllMocks();
+ });
+
+ it('allows cancel action when user has baseline permissions for isolate command', async () => {
+ fetchActionByIdSpy.mockResolvedValue({
+ EndpointActions: {
+ data: { command: 'isolate' },
+ input_type: 'microsoft_defender_endpoint',
+ },
+ });
+
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ } as CancelActionRequestBody,
+ authz: { canIsolateHost: true },
+ version: '2023-10-31',
+ });
+ expect(mockResponse.ok).toBeCalled();
+ expect(fetchActionByIdSpy).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ 'test-action-id'
+ );
+ expect(responseActionsClientMockInstance.cancel).toHaveBeenCalledWith({
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ });
+ });
+
+ it('allows cancel action route access regardless of baseline permissions', async () => {
+ // Mock the command-specific validation to succeed
+ // (this simulates the user having the required permission for the specific command being cancelled)
+ fetchActionByIdSpy.mockResolvedValue({
+ EndpointActions: {
+ data: { command: 'isolate' },
+ input_type: 'microsoft_defender_endpoint',
+ },
+ });
+
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ } as CancelActionRequestBody,
+ authz: { canReadActionsLogManagement: false, canIsolateHost: true }, // Has permission for the command being cancelled
+ version: '2023-10-31',
+ });
+ expect(mockResponse.ok).toBeCalled();
+ });
+
+ it('prohibits cancel action when user lacks command-specific permission for isolate', async () => {
+ fetchActionByIdSpy.mockResolvedValue({
+ EndpointActions: {
+ data: { command: 'isolate' },
+ input_type: 'microsoft_defender_endpoint',
+ },
+ });
+
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ } as CancelActionRequestBody,
+ authz: { canReadActionsLogManagement: true, canIsolateHost: false },
+ version: '2023-10-31',
+ });
+ expect(mockResponse.forbidden).toBeCalled();
+ });
+
+ it('allows cancel action for runscript command when user has required permissions', async () => {
+ fetchActionByIdSpy.mockResolvedValue(mockExecuteAction);
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ } as CancelActionRequestBody,
+ authz: { canReadActionsLogManagement: true, canWriteExecuteOperations: true },
+ version: '2023-10-31',
+ });
+ expect(mockResponse.ok).toBeCalled();
+ expect(responseActionsClientMockInstance.cancel).toHaveBeenCalledWith({
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ });
+ });
+
+ it('prohibits cancel action for runscript command when user lacks execute permissions', async () => {
+ fetchActionByIdSpy.mockResolvedValue(mockExecuteAction);
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ } as CancelActionRequestBody,
+ authz: { canReadActionsLogManagement: true, canWriteExecuteOperations: false },
+ version: '2023-10-31',
+ });
+ expect(mockResponse.forbidden).toBeCalled();
+ });
+
+ it('returns 404 when action to cancel is not found', async () => {
+ fetchActionByIdSpy.mockResolvedValue(null);
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'non-existent-action' },
+ } as CancelActionRequestBody,
+ authz: { canReadActionsLogManagement: true },
+ version: '2023-10-31',
+ });
+ expect(mockResponse.customError).toHaveBeenCalledWith({
+ body: expect.objectContaining({
+ message: "Action with id 'non-existent-action' not found.",
+ }),
+ statusCode: 404,
+ });
+ });
+
+ it('returns 400 when action has missing command information', async () => {
+ fetchActionByIdSpy.mockResolvedValue({ EndpointActions: { data: {} } });
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ } as CancelActionRequestBody,
+ authz: {},
+ version: '2023-10-31',
+ });
+ expect(mockResponse.customError).toHaveBeenCalledWith({
+ body: expect.objectContaining({
+ message: "Unable to determine command type for action 'test-action-id'",
+ }),
+ statusCode: 500,
+ });
+ });
+
+ it('returns 404 when user tries to cancel action from different space', async () => {
+ // Simulate action not found in current space (user in 'other-space', action in 'default')
+ fetchActionByIdSpy.mockResolvedValue(null);
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'action-from-different-space' },
+ } as CancelActionRequestBody,
+ authz: { canReadActionsLogManagement: true },
+ version: '2023-10-31',
+ });
+ expect(mockResponse.customError).toHaveBeenCalledWith({
+ body: expect.objectContaining({
+ message: "Action with id 'action-from-different-space' not found.",
+ }),
+ statusCode: 404,
+ });
+ });
+
+ it('handles race condition when action is completed between validation and cancellation', async () => {
+ // Mock the action as existing during validation
+ fetchActionByIdSpy.mockResolvedValue(mockIsolateAction);
+
+ // Mock the response actions client to simulate action already completed
+ responseActionsClientMockInstance.cancel = jest
+ .fn()
+ .mockRejectedValue(
+ new ResponseActionsClientError(
+ 'Action cannot be cancelled as it has already completed',
+ 400
+ )
+ );
+
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'already-completed-action' },
+ } as CancelActionRequestBody,
+ authz: { canReadActionsLogManagement: true, canIsolateHost: true },
+ version: '2023-10-31',
+ });
+
+ expect(mockResponse.customError).toHaveBeenCalledWith({
+ body: expect.objectContaining({
+ message: 'Action cannot be cancelled as it has already completed',
+ }),
+ statusCode: 400,
+ });
+ });
+
+ it('returns 403 when cancel feature flag is disabled', async () => {
+ // Disable the experimental feature for cancel actions
+ endpointContext.experimentalFeatures = {
+ ...endpointContext.experimentalFeatures,
+ microsoftDefenderEndpointCancelEnabled: false,
+ };
+
+ await callRoute(CANCEL_ROUTE, {
+ body: {
+ endpoint_ids: ['test-endpoint-id'],
+ parameters: { id: 'test-action-id' },
+ } as CancelActionRequestBody,
+ authz: { canIsolateHost: true },
+ version: '2023-10-31',
+ });
+
+ expect(mockResponse.forbidden).toHaveBeenCalled();
+ });
+ });
});
describe('Upload response action handler', () => {
@@ -1089,6 +1377,20 @@ describe('Response actions', () => {
httpRequestMock = testSetup.createRequestMock({ body: reqBody });
registerResponseActionRoutes(testSetup.routerMock, testSetup.endpointAppContextMock);
+ // Helper function to set up authorization - similar to callRoute pattern
+ const setupAuthz = (authz: Partial) => {
+ const currentSecuritySolution = testSetup.httpHandlerContextMock.securitySolution;
+ testSetup.httpHandlerContextMock.securitySolution = currentSecuritySolution.then(
+ (resolved) => ({
+ ...resolved,
+ getEndpointAuthz: jest.fn().mockResolvedValue(getEndpointAuthzInitialStateMock(authz)),
+ })
+ );
+ };
+
+ // Set up authorization for upload operations
+ setupAuthz({ canWriteFileOperations: true });
+
const actionsGenerator = new EndpointActionGenerator('seed');
createdUploadAction = actionsGenerator.generateActionDetails({
command: 'upload',
@@ -1222,6 +1524,20 @@ describe('Response actions', () => {
});
registerResponseActionRoutes(testSetup.routerMock, testSetup.endpointAppContextMock);
+ // Helper function to set up authorization - similar to callRoute pattern
+ const setupAuthz = (authz: Partial) => {
+ const currentSecuritySolution = testSetup.httpHandlerContextMock.securitySolution;
+ testSetup.httpHandlerContextMock.securitySolution = currentSecuritySolution.then(
+ (resolved) => ({
+ ...resolved,
+ getEndpointAuthz: jest.fn().mockResolvedValue(getEndpointAuthzInitialStateMock(authz)),
+ })
+ );
+ };
+
+ // Set up authorization for isolate operations
+ setupAuthz({ canIsolateHost: true });
+
(testSetup.endpointAppContextMock.service.getEndpointMetadataService as jest.Mock) = jest
.fn()
.mockReturnValue({
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts
index b169438a9a856..8e7bee6e74038 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts
@@ -12,26 +12,21 @@ import type {
} from '../../../../common/endpoint/service/response_actions/constants';
import {
EndpointActionGetFileSchema,
- type ExecuteActionRequestBody,
ExecuteActionRequestSchema,
GetProcessesRouteRequestSchema,
IsolateRouteRequestSchema,
- type KillProcessRequestBody,
KillProcessRouteRequestSchema,
- type ResponseActionGetFileRequestBody,
type ResponseActionsRequestBody,
- type ScanActionRequestBody,
ScanActionRequestSchema,
- type SuspendProcessRequestBody,
SuspendProcessRouteRequestSchema,
UnisolateRouteRequestSchema,
- type UploadActionApiRequestBody,
UploadActionRequestSchema,
RunScriptActionRequestSchema,
- type RunScriptActionRequestBody,
+ CancelActionRequestSchema,
} from '../../../../common/api/endpoint';
import {
+ CANCEL_ROUTE,
EXECUTE_ROUTE,
GET_FILE_ROUTE,
GET_PROCESSES_ROUTE,
@@ -62,7 +57,11 @@ import { errorHandler } from '../error_handler';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
import type { ResponseActionsClient } from '../../services';
import { getResponseActionsClient, NormalizedExternalConnectorClient } from '../../services';
-import { responseActionsWithLegacyActionProperty } from '../../services/actions/constants';
+import {
+ executeResponseAction,
+ buildResponseActionResult,
+ createCancelActionAdditionalChecks,
+} from './utils';
export function registerResponseActionRoutes(
router: SecuritySolutionPluginRouter,
@@ -335,6 +334,32 @@ export function registerResponseActionRoutes(
)
)
);
+
+ router.versioned
+ .post({
+ access: 'public',
+ path: CANCEL_ROUTE,
+ security: {
+ authz: {
+ requiredPrivileges: ['securitySolution'],
+ },
+ },
+ options: { authRequired: true },
+ })
+ .addVersion(
+ {
+ version: '2023-10-31',
+ validate: {
+ request: CancelActionRequestSchema,
+ },
+ },
+ withEndpointAuthz(
+ { all: ['canCancelAction'] },
+ logger,
+ responseActionRequestHandler(endpointContext, 'cancel'),
+ createCancelActionAdditionalChecks(endpointContext)
+ )
+ );
}
function responseActionRequestHandler(
@@ -351,56 +376,46 @@ function responseActionRequestHandler {
logger.debug(() => `response action [${command}]:\n${stringify(req.body)}`);
- const experimentalFeatures = endpointContext.experimentalFeatures;
-
- // Note: because our API schemas are defined as module static variables (as opposed to a
- // `getter` function), we need to include this additional validation here, since
- // `agent_type` is included in the schema independent of the feature flag
- if (isThirdPartyFeatureDisabled(req.body.agent_type, command, experimentalFeatures)) {
- return errorHandler(
- logger,
- res,
- new CustomHttpRequestError(`[request body.agent_type]: feature is disabled`, 400)
- );
- }
+ try {
+ const experimentalFeatures = endpointContext.experimentalFeatures;
- const coreContext = await context.core;
- const user = coreContext.security.authc.getCurrentUser();
- const esClient = coreContext.elasticsearch.client.asInternalUser;
- const casesClient = await endpointContext.service.getCasesClient(req);
- const connectorActions = (await context.actions).getActionsClient();
- const spaceId = (await context.securitySolution).getSpaceId();
- const responseActionsClient: ResponseActionsClient = getResponseActionsClient(
- req.body.agent_type || 'endpoint',
- {
- esClient,
- casesClient,
- spaceId,
- endpointService: endpointContext.service,
- username: user?.username || 'unknown',
- connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger),
+ // Note: because our API schemas are defined as module static variables (as opposed to a
+ // `getter` function), we need to include this additional validation here, since
+ // `agent_type` is included in the schema independent of the feature flag
+ if (isThirdPartyFeatureDisabled(req.body.agent_type, command, experimentalFeatures)) {
+ return errorHandler(
+ logger,
+ res,
+ new CustomHttpRequestError(`[request body.agent_type]: feature is disabled`, 400)
+ );
}
- );
- try {
- const action: ActionDetails = await handleActionCreation(
+ const coreContext = await context.core;
+ const user = coreContext.security.authc.getCurrentUser();
+ const esClient = coreContext.elasticsearch.client.asInternalUser;
+ const casesClient = await endpointContext.service.getCasesClient(req);
+ const connectorActions = (await context.actions).getActionsClient();
+ const spaceId = (await context.securitySolution).getSpaceId();
+ const responseActionsClient: ResponseActionsClient = getResponseActionsClient(
+ req.body.agent_type || 'endpoint',
+ {
+ esClient,
+ casesClient,
+ spaceId,
+ endpointService: endpointContext.service,
+ username: user?.username || 'unknown',
+ connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger),
+ }
+ );
+
+ const action: ActionDetails = await executeResponseAction(
command,
req.body,
responseActionsClient
);
- const { action: actionId, ...data } = action;
- const legacyResponseData = responseActionsWithLegacyActionProperty.includes(command)
- ? {
- action: actionId ?? data.id ?? '',
- }
- : {};
- return res.ok({
- body: {
- ...legacyResponseData,
- data,
- },
- });
+ const result = buildResponseActionResult(command, action);
+ return res.ok(result);
} catch (err) {
return errorHandler(logger, res, err);
}
@@ -432,37 +447,3 @@ function isThirdPartyFeatureDisabled(
return false;
}
-
-async function handleActionCreation(
- command: ResponseActionsApiCommandNames,
- body: ResponseActionsRequestBody,
- responseActionsClient: ResponseActionsClient
-): Promise {
- switch (command) {
- case 'isolate':
- return responseActionsClient.isolate(body);
- case 'unisolate':
- return responseActionsClient.release(body);
- case 'running-processes':
- return responseActionsClient.runningProcesses(body);
- case 'execute':
- return responseActionsClient.execute(body as ExecuteActionRequestBody);
- case 'suspend-process':
- return responseActionsClient.suspendProcess(body as SuspendProcessRequestBody);
- case 'kill-process':
- return responseActionsClient.killProcess(body as KillProcessRequestBody);
- case 'get-file':
- return responseActionsClient.getFile(body as ResponseActionGetFileRequestBody);
- case 'upload':
- return responseActionsClient.upload(body as UploadActionApiRequestBody);
- case 'scan':
- return responseActionsClient.scan(body as ScanActionRequestBody);
- case 'runscript':
- return responseActionsClient.runscript(body as RunScriptActionRequestBody);
- default:
- throw new CustomHttpRequestError(
- `No handler found for response action command: [${command}]`,
- 501
- );
- }
-}
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/state.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/state.test.ts
index f244e22d36637..15d4b7a9114e3 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/state.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/state.test.ts
@@ -101,9 +101,11 @@ describe('when calling the Action state route handler', () => {
canSuspendProcess: false,
canGetRunningProcesses: false,
canAccessResponseConsole: false,
+ canCancelAction: false,
canWriteExecuteOperations: false,
canWriteFileOperations: false,
canWriteScanOperations: false,
+ canReadActionsLogManagement: false,
},
});
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/utils.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/utils.ts
index e43338fa4e506..de3b1491ea317 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/utils.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/routes/actions/utils.ts
@@ -5,18 +5,34 @@
* 2.0.
*/
-import type { KibanaRequest } from '@kbn/core-http-server';
import { deepFreeze } from '@kbn/std';
import { get } from 'lodash';
+import type { KibanaRequest } from '@kbn/core/server';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
import { isActionSupportedByAgentType } from '../../../../common/endpoint/service/response_actions/is_response_action_supported';
import { EndpointAuthorizationError } from '../../errors';
import { fetchActionRequestById } from '../../services/actions/utils/fetch_action_request_by_id';
+import { checkCancelPermission } from '../../../../common/endpoint/service/authz/cancel_authz_utils';
import type { SecuritySolutionRequestHandlerContext } from '../../../types';
+import type { EndpointAppContext } from '../../types';
+import type {
+ CancelActionRequestBody,
+ ResponseActionsRequestBody,
+ ExecuteActionRequestBody,
+ SuspendProcessRequestBody,
+ KillProcessRequestBody,
+ ResponseActionGetFileRequestBody,
+ UploadActionApiRequestBody,
+ ScanActionRequestBody,
+ RunScriptActionRequestBody,
+} from '../../../../common/api/endpoint';
import type {
ResponseActionAgentType,
ResponseActionsApiCommandNames,
} from '../../../../common/endpoint/service/response_actions/constants';
+import type { ActionDetails } from '../../../../common/endpoint/types';
+import type { ResponseActionsClient } from '../../services';
+import { responseActionsWithLegacyActionProperty } from '../../services/actions/constants';
type CommandsWithFileAccess = Readonly<
Record>>
@@ -85,6 +101,12 @@ const COMMANDS_WITH_ACCESS_TO_FILES: CommandsWithFileAccess = deepFreeze => {
+ switch (command) {
+ case 'isolate':
+ return responseActionsClient.isolate(requestBody);
+ case 'unisolate':
+ return responseActionsClient.release(requestBody);
+ case 'running-processes':
+ return responseActionsClient.runningProcesses(requestBody);
+ case 'execute':
+ return responseActionsClient.execute(requestBody as ExecuteActionRequestBody);
+ case 'suspend-process':
+ return responseActionsClient.suspendProcess(requestBody as SuspendProcessRequestBody);
+ case 'kill-process':
+ return responseActionsClient.killProcess(requestBody as KillProcessRequestBody);
+ case 'get-file':
+ return responseActionsClient.getFile(requestBody as ResponseActionGetFileRequestBody);
+ case 'upload':
+ return responseActionsClient.upload(requestBody as UploadActionApiRequestBody);
+ case 'scan':
+ return responseActionsClient.scan(requestBody as ScanActionRequestBody);
+ case 'runscript':
+ return responseActionsClient.runscript(requestBody as RunScriptActionRequestBody);
+ case 'cancel':
+ return responseActionsClient.cancel(requestBody as CancelActionRequestBody);
+ default:
+ throw new CustomHttpRequestError(
+ `No handler found for response action command: [${command}]`,
+ 501
+ );
+ }
+};
+
+/**
+ * Builds the standardized response object for response actions
+ */
+export const buildResponseActionResult = (
+ command: ResponseActionsApiCommandNames,
+ action: ActionDetails
+): { body: { action?: string; data: ActionDetails } } => {
+ const { action: actionId, ...data } = action;
+ const legacyResponseData = responseActionsWithLegacyActionProperty.includes(command)
+ ? {
+ action: actionId ?? data.id ?? '',
+ }
+ : {};
+
+ return {
+ body: {
+ ...legacyResponseData,
+ data,
+ },
+ };
+};
+
+/**
+ * Creates additional authorization checks function for cancel action.
+ * Business logic validation has been moved to the service layer (validateRequest).
+ * This function only handles HTTP-specific authorization checks.
+ */
+export const createCancelActionAdditionalChecks = (endpointContext: EndpointAppContext) => {
+ return async (
+ context: SecuritySolutionRequestHandlerContext,
+ request: KibanaRequest
+ ): Promise => {
+ const { parameters } = request.body as CancelActionRequestBody;
+ const actionId = parameters.id;
+ const logger = endpointContext.logFactory.get('cancelActionAdditionalChecks');
+ // Get space ID from context
+ const spaceId = (await context.securitySolution).getSpaceId();
+
+ // Fetch original action to determine command type and agent type
+ const originalAction = await fetchActionRequestById(endpointContext.service, spaceId, actionId);
+
+ if (!originalAction) {
+ throw new CustomHttpRequestError(`Action with id '${actionId}' not found.`, 404);
+ }
+
+ // Extract command and agent type from original action
+ const command = originalAction.EndpointActions?.data?.command;
+ const agentType = originalAction.EndpointActions?.input_type;
+
+ if (!command) {
+ logger.error(`Action ${actionId} missing command information for ${agentType}`);
+ throw new CustomHttpRequestError(
+ `Unable to determine command type for action '${actionId}'`,
+ 500
+ );
+ }
+
+ if (!agentType) {
+ logger.error(`Action ${actionId} missing agent type information for ${command}`);
+ throw new CustomHttpRequestError(
+ `Unable to determine agent type for action '${actionId}'`,
+ 500
+ );
+ }
+
+ // Use utility to check if cancellation is allowed
+ const endpointAuthz = await (await context.securitySolution).getEndpointAuthz();
+ const canCancel = checkCancelPermission(
+ endpointAuthz,
+ endpointContext.experimentalFeatures,
+ agentType,
+ command
+ );
+
+ if (!canCancel) {
+ throw new EndpointAuthorizationError();
+ }
+ };
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts
index 794b187281b7e..28956960451be 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts
@@ -80,17 +80,9 @@ describe('When using `getActionDetailsById()', () => {
outputs: {
'agent-a': {
content: {
- code: 'ra_execute_success_done',
- cwd: '/some/path',
- output_file_id: 'some-output-file-id',
- output_file_stderr_truncated: false,
- output_file_stdout_truncated: true,
- shell: 'bash',
- shell_code: 0,
- stderr: expect.any(String),
- stderr_truncated: true,
- stdout: expect.any(String),
- stdout_truncated: true,
+ code: 'ra_upload_file-success',
+ disk_free_space: 4825566125475,
+ path: '/disk1/file/saved/here',
},
type: 'json',
},
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_list.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_list.test.ts
index d46760f16b1d2..09c1142596411 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_list.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/action_list.test.ts
@@ -113,17 +113,9 @@ describe('action list services', () => {
outputs: {
'agent-a': {
content: {
- code: 'ra_execute_success_done',
- cwd: '/some/path',
- output_file_id: 'some-output-file-id',
- output_file_stderr_truncated: false,
- output_file_stdout_truncated: true,
- shell: 'bash',
- shell_code: 0,
- stderr: expect.any(String),
- stderr_truncated: true,
- stdout: expect.any(String),
- stdout_truncated: true,
+ code: 'ra_upload_file-success',
+ disk_free_space: 4825566125475,
+ path: '/disk1/file/saved/here',
},
type: 'json',
},
@@ -172,17 +164,9 @@ describe('action list services', () => {
outputs: {
'agent-a': {
content: {
- code: 'ra_execute_success_done',
- cwd: '/some/path',
- output_file_id: 'some-output-file-id',
- output_file_stderr_truncated: false,
- output_file_stdout_truncated: true,
- shell: 'bash',
- shell_code: 0,
- stderr: expect.any(String),
- stderr_truncated: true,
- stdout: expect.any(String),
- stdout_truncated: true,
+ code: 'ra_upload_file-success',
+ disk_free_space: 4825566125475,
+ path: '/disk1/file/saved/here',
},
type: 'json',
},
@@ -273,14 +257,7 @@ describe('action list services', () => {
errors: undefined,
},
},
- outputs: {
- 'agent-a': {
- content: {
- code: '200',
- },
- type: 'json',
- },
- },
+ outputs: {},
},
],
total: 1,
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.test.ts
index 614c58cca8974..2b066c04fa69f 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/crowdstrike/crowdstrike_actions_client.test.ts
@@ -91,6 +91,7 @@ describe('CrowdstrikeActionsClient class', () => {
'getFile',
'execute',
'upload',
+ 'cancel',
] as Array)(
'should throw an un-supported error for %s',
async (methodName) => {
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 93800330d86f7..1624f78a55300 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
@@ -395,7 +395,12 @@ describe('EndpointActionsClient', () => {
type ResponseActionsMethodsOnly = keyof Omit<
ResponseActionsClient,
- 'processPendingActions' | 'getFileDownload' | 'getFileInfo' | 'runscript' | 'getCustomScripts'
+ | 'processPendingActions'
+ | 'getFileDownload'
+ | 'getFileInfo'
+ | 'runscript'
+ | 'getCustomScripts'
+ | 'cancel'
>;
// 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 c5049c9562d26..689eb0f5431cd 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
@@ -82,6 +82,8 @@ import type {
ResponseActionScanParameters,
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
+ ResponseActionCancelOutputContent,
+ ResponseActionCancelParameters,
SuspendProcessActionOutputContent,
UploadedFileInfo,
WithAllKeys,
@@ -99,6 +101,7 @@ import type {
SuspendProcessRequestBody,
UnisolationRouteRequestBody,
UploadActionApiRequestBody,
+ CancelActionRequestBody,
} from '../../../../../../common/api/endpoint';
import { stringify } from '../../../../utils/stringify';
import { CASE_ATTACHMENT_ENDPOINT_TYPE_ID } from '../../../../../../common/constants';
@@ -1045,6 +1048,13 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
throw new ResponseActionsNotSupportedError('runscript');
}
+ public async cancel(
+ actionRequest: OmitUnsupportedAttributes,
+ options?: CommonResponseActionMethodOptions
+ ): Promise> {
+ throw new ResponseActionsNotSupportedError('cancel');
+ }
+
public async getCustomScripts(
options?: Omit
): Promise {
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 fc80af191ebd0..d58583021f66b 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
@@ -25,6 +25,8 @@ import type {
ResponseActionsExecuteParameters,
ResponseActionUploadOutputContent,
ResponseActionUploadParameters,
+ ResponseActionCancelOutputContent,
+ ResponseActionCancelParameters,
SuspendProcessActionOutputContent,
UploadedFileInfo,
} from '../../../../../../common/endpoint/types';
@@ -38,6 +40,7 @@ import type {
RunScriptActionRequestBody,
ScanActionRequestBody,
SuspendProcessRequestBody,
+ CancelActionRequestBody,
UnisolationRouteRequestBody,
UploadActionApiRequestBody,
} from '../../../../../../common/api/endpoint';
@@ -180,6 +183,15 @@ export interface ResponseActionsClient {
) => Promise<
ActionDetails
>;
+ /**
+ * Cancel a response action
+ * @param actionRequest
+ * @param options
+ */
+ cancel: (
+ actionRequest: OmitUnsupportedAttributes,
+ options?: CommonResponseActionMethodOptions
+ ) => Promise>;
}
/**
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts
index 0e1139af65206..cb333798b0c67 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/mocks.ts
@@ -137,6 +137,16 @@ const createMsConnectorActionsClientMock = (): ActionsClientMock => {
data: createMicrosoftGetLibraryFilesApiResponseMock(),
});
+ case MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.CANCEL_ACTION:
+ return responseActionsClientMock.createConnectorActionExecuteResponse({
+ data: createMicrosoftMachineActionMock({ type: 'LiveResponse' }),
+ });
+
+ case MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_ACTION_RESULTS:
+ return responseActionsClientMock.createConnectorActionExecuteResponse({
+ data: createMicrosoftGetActionResultsApiResponseMock(),
+ });
+
default:
return responseActionsClientMock.createConnectorActionExecuteResponse();
}
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 dce1330a4370d..0379a285ff470 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
@@ -92,6 +92,7 @@ describe('MS Defender response actions client', () => {
release: true,
processPendingActions: true,
getCustomScripts: true,
+ cancel: true,
};
it.each(
@@ -775,6 +776,632 @@ describe('MS Defender response actions client', () => {
});
});
+ describe('#cancel()', () => {
+ beforeEach(() => {
+ const generator = new EndpointActionGenerator('seed');
+ // @ts-expect-error assign to readonly property
+ clientConstructorOptionsMock.endpointService.experimentalFeatures.microsoftDefenderEndpointCancelEnabled =
+ true;
+
+ // Reset mock and ensure it returns a valid pending action
+ getActionDetailsByIdMock.mockReset();
+ getActionDetailsByIdMock.mockImplementation(async (_, __, id: string) => {
+ return new EndpointActionGenerator('seed').generateActionDetails({
+ id,
+ isCompleted: false,
+ wasSuccessful: false,
+ command: 'isolate',
+ agents: ['1-2-3'],
+ });
+ });
+
+ // Mock the search for original action request to get external action ID
+ const originalActionSearchResponse = generator.toEsSearchResponse([
+ generator.generateActionEsHit<
+ undefined,
+ {},
+ MicrosoftDefenderEndpointActionRequestCommonMeta
+ >({
+ agent: { id: 'agent-uuid-1' },
+ EndpointActions: {
+ action_id: 'original-action-id',
+ data: { command: 'isolate' },
+ input_type: 'microsoft_defender_endpoint',
+ },
+ meta: { machineActionId: 'external-machine-action-id-123' },
+ }),
+ ]);
+
+ applyEsClientSearchMock({
+ esClientMock: clientConstructorOptionsMock.esClient,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: originalActionSearchResponse,
+ });
+ });
+
+ it('should send cancel request to Microsoft Defender with expected parameters', async () => {
+ await msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'original-action-id' },
+ });
+
+ expect(connectorActionsMock.execute).toHaveBeenCalledWith({
+ params: {
+ subAction: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.CANCEL_ACTION,
+ subActionParams: {
+ actionId: 'external-machine-action-id-123',
+ comment: expect.stringMatching(
+ /Action triggered from Elastic Security by user \[foo\] for action \[.* \(action id: .*\)\]: cancel test comment/
+ ),
+ },
+ },
+ });
+ });
+
+ it('should write cancel action request doc to endpoint index', async () => {
+ await msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'original-action-id' },
+ });
+
+ expect(clientConstructorOptionsMock.esClient.index).toHaveBeenCalledWith(
+ {
+ document: {
+ '@timestamp': expect.any(String),
+ EndpointActions: {
+ action_id: expect.any(String),
+ data: {
+ command: 'cancel',
+ comment: 'cancel test comment',
+ hosts: {
+ '1-2-3': {
+ name: 'mymachine1.contoso.com',
+ },
+ },
+ parameters: {
+ id: 'original-action-id',
+ },
+ },
+ expiration: expect.any(String),
+ input_type: 'microsoft_defender_endpoint',
+ type: 'INPUT_ACTION',
+ },
+ agent: {
+ id: ['1-2-3'],
+ policy: [
+ {
+ agentId: '1-2-3',
+ agentPolicyId: expect.any(String),
+ elasticAgentId: '1-2-3',
+ integrationPolicyId: expect.any(String),
+ },
+ ],
+ },
+ originSpaceId: 'default',
+ tags: [],
+ meta: {
+ machineActionId: '5382f7ea-7557-4ab7-9782-d50480024a4e',
+ },
+ user: {
+ id: 'foo',
+ },
+ },
+ index: '.logs-endpoint.actions-default',
+ refresh: 'wait_for',
+ },
+ { meta: true }
+ );
+ });
+
+ it('should return action details for cancel request', async () => {
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'original-action-id' },
+ })
+ ).resolves.toEqual(
+ expect.objectContaining({
+ id: expect.any(String),
+ command: expect.any(String),
+ isCompleted: expect.any(Boolean),
+ })
+ );
+ expect(getActionDetailsByIdMock).toHaveBeenCalled();
+ });
+
+ it('should throw error when id parameter is missing', async () => {
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: '' },
+ })
+ ).rejects.toThrow('id is required in parameters');
+ });
+
+ it('should throw error when parameters are undefined', async () => {
+ await expect(
+ // @ts-expect-error - intentionally testing missing parameters
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ })
+ ).rejects.toThrow('id is required in parameters');
+ });
+
+ it('should throw error when external action ID cannot be resolved', async () => {
+ // Mock empty search results for original action
+ const generator = new EndpointActionGenerator('seed');
+ const emptySearchResponse = generator.toEsSearchResponse([]);
+
+ applyEsClientSearchMock({
+ esClientMock: clientConstructorOptionsMock.esClient,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: emptySearchResponse,
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'non-existent-action' },
+ })
+ ).rejects.toThrow("Action with id 'non-existent-action' not found.");
+ });
+
+ it('should throw error when original action lacks machineActionId in meta', async () => {
+ const generator = new EndpointActionGenerator('seed');
+
+ // Mock original action without machineActionId
+ const originalActionSearchResponse = generator.toEsSearchResponse([
+ generator.generateActionEsHit<
+ undefined,
+ {},
+ MicrosoftDefenderEndpointActionRequestCommonMeta
+ >({
+ agent: { id: 'agent-uuid-1' },
+ EndpointActions: {
+ action_id: 'action-without-external-id',
+ data: { command: 'isolate' },
+ input_type: 'microsoft_defender_endpoint',
+ },
+ meta: {}, // Missing machineActionId
+ }),
+ ]);
+
+ applyEsClientSearchMock({
+ esClientMock: clientConstructorOptionsMock.esClient,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: originalActionSearchResponse,
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'action-without-external-id' },
+ })
+ ).rejects.toThrow(
+ 'Unable to resolve Microsoft Defender machine action ID for action [action-without-external-id]'
+ );
+ });
+
+ it('should handle Microsoft Defender API errors gracefully', async () => {
+ const apiError = new Error('Microsoft Defender cancel API error');
+ connectorActionsMock.execute.mockImplementation(async (options) => {
+ if (options.params.subAction === MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.CANCEL_ACTION) {
+ throw apiError;
+ }
+ return responseActionsClientMock.createConnectorActionExecuteResponse();
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'original-action-id' },
+ })
+ ).rejects.toThrow('Microsoft Defender cancel API error');
+ });
+
+ it('should handle MDE error when action status is not Pending or InProgress', async () => {
+ const mdeError = new Error(
+ 'Attempt to send [cancelAction] to Microsoft Defender for Endpoint failed: Status code: 400. Message: API Error: [Bad Request] Request failed with status code 400\n' +
+ 'URL called:[post] https://api.securitycenter.windows.com/api/machineactions/357dd251-e714-4a6e-b00a-93c06d87aaff/cancel\n' +
+ 'Response body: {"error":{"code":"BadRequest","message":"Canceled machine action status must be Pending or InProgress, Current Status: Failed, machineActionId: 357dd251-e714-4a6e-b00a-93c06d87aaff","target":"|00-fdaabbeb2ebe3146b47f958d7b671dcd-e13c20d6f3402a0d-01.8c111081_1.1."}}'
+ );
+
+ connectorActionsMock.execute.mockImplementation(async (options) => {
+ if (options.params.subAction === MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.CANCEL_ACTION) {
+ throw mdeError;
+ }
+ return responseActionsClientMock.createConnectorActionExecuteResponse();
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'original-action-id' },
+ })
+ ).rejects.toThrow(
+ 'Attempt to send [cancelAction] to Microsoft Defender for Endpoint failed: Status code: 400. Message: API Error: [Bad Request] Request failed with status code 400'
+ );
+ });
+
+ it('should handle missing machine action ID in cancel response', async () => {
+ responseActionsClientMock.setConnectorActionsClientExecuteResponse(
+ connectorActionsMock,
+ MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.CANCEL_ACTION,
+ responseActionsClientMock.createConnectorActionExecuteResponse({
+ data: {
+ /* missing id */
+ },
+ })
+ );
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'original-action-id' },
+ })
+ ).rejects.toThrow(
+ 'Cancel request was sent to Microsoft Defender, but Machine Action Id was not provided!'
+ );
+ });
+
+ it('should handle ES client errors when resolving external action ID', async () => {
+ const generator = new EndpointActionGenerator('seed');
+
+ // Mock ES client to throw error specifically when searching for original action
+ const originalEsClientSearch = clientConstructorOptionsMock.esClient.search;
+
+ (clientConstructorOptionsMock.esClient.search as unknown as jest.Mock).mockImplementation(
+ (searchRequest: Parameters[0]) => {
+ if (!searchRequest) {
+ return Promise.resolve(generator.toEsSearchResponse([]));
+ }
+
+ // First few calls are for agent policy info - handle normally
+ if (
+ searchRequest.index === MICROSOFT_DEFENDER_ENDPOINT_LOG_INDEX_PATTERN ||
+ (Array.isArray(searchRequest.index) &&
+ searchRequest.index.includes('logs-microsoft_defender_endpoint.log-default'))
+ ) {
+ const msLogIndexEsHit = new MicrosoftDefenderDataGenerator(
+ 'seed'
+ ).generateEndpointLogEsHit({
+ cloud: { instance: { id: '1-2-3' } },
+ });
+ msLogIndexEsHit.inner_hits = {
+ most_recent: {
+ hits: {
+ hits: [
+ {
+ _index: '',
+ _source: {
+ agent: { id: '1-2-3' },
+ cloud: { instance: { id: '1-2-3' } },
+ },
+ },
+ ],
+ },
+ },
+ };
+ return Promise.resolve(
+ new MicrosoftDefenderDataGenerator('seed').generateEndpointLogEsSearchResponse([
+ msLogIndexEsHit,
+ ])
+ );
+ }
+
+ // Throw error specifically for original action lookup
+ if (
+ searchRequest.index === ENDPOINT_ACTIONS_INDEX &&
+ searchRequest.query?.term?.action_id
+ ) {
+ throw new Error('ES search error for original action');
+ }
+
+ return Promise.resolve(generator.toEsSearchResponse([]));
+ }
+ );
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'original-action-id' },
+ })
+ ).rejects.toThrow("Action with id 'original-action-id' not found.");
+
+ // Restore original mock
+ (clientConstructorOptionsMock.esClient.search as unknown as jest.Mock).mockImplementation(
+ originalEsClientSearch
+ );
+ });
+
+ describe('validation scenarios', () => {
+ it('should reject cancel request for already completed action', async () => {
+ // Mock getActionDetailsById to return a completed action
+ getActionDetailsByIdMock.mockResolvedValue({
+ id: 'completed-action-id',
+ command: 'isolate',
+ isCompleted: true,
+ wasSuccessful: true,
+ agents: ['1-2-3'],
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'completed-action-id' },
+ })
+ ).rejects.toThrow(
+ 'Cannot cancel action [completed-action-id] because it has already completed successfully'
+ );
+
+ // Should not call connector if validation fails
+ expect(connectorActionsMock.execute).not.toHaveBeenCalled();
+ });
+
+ it('should reject cancel request for already failed action', async () => {
+ // Mock getActionDetailsById to return a failed action
+ getActionDetailsByIdMock.mockResolvedValue({
+ id: 'failed-action-id',
+ command: 'isolate',
+ isCompleted: true,
+ wasSuccessful: false,
+ agents: ['1-2-3'],
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'failed-action-id' },
+ })
+ ).rejects.toThrow('Cannot cancel action [failed-action-id] because it has already failed.');
+
+ expect(connectorActionsMock.execute).not.toHaveBeenCalled();
+ });
+
+ it('should reject cancel request when endpoint ID is not associated with action', async () => {
+ // Mock getActionDetailsById to return an action for a different agent
+ getActionDetailsByIdMock.mockResolvedValue({
+ id: 'other-agent-action-id',
+ command: 'isolate',
+ isCompleted: false,
+ wasSuccessful: false,
+ agents: ['different-agent-id'],
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'other-agent-action-id' },
+ })
+ ).rejects.toThrow("Endpoint '1-2-3' is not associated with action 'other-agent-action-id'");
+
+ expect(connectorActionsMock.execute).not.toHaveBeenCalled();
+ });
+
+ it('should reject cancel request when action command information is missing', async () => {
+ // Mock getActionDetailsById to return an action without command info
+ getActionDetailsByIdMock.mockResolvedValue({
+ id: 'no-command-action-id',
+ command: undefined, // Missing command
+ isCompleted: false,
+ wasSuccessful: false,
+ agents: ['1-2-3'],
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'no-command-action-id' },
+ })
+ ).rejects.toThrow("Unable to determine command type for action 'no-command-action-id'");
+
+ expect(connectorActionsMock.execute).not.toHaveBeenCalled();
+ });
+
+ it('should reject cancel request when action is not found', async () => {
+ // Mock getActionDetailsById to throw "not found" error
+ getActionDetailsByIdMock.mockRejectedValue(
+ new Error("Action with id 'non-existent-action' not found")
+ );
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'non-existent-action' },
+ })
+ ).rejects.toThrow("Action with id 'non-existent-action' not found.");
+
+ expect(connectorActionsMock.execute).not.toHaveBeenCalled();
+ });
+
+ it('should allow cancel request for valid pending action', async () => {
+ // Mock getActionDetailsById to return a valid pending action
+ getActionDetailsByIdMock.mockResolvedValue({
+ id: 'valid-pending-action',
+ command: 'isolate',
+ isCompleted: false,
+ wasSuccessful: false,
+ agents: ['1-2-3'],
+ });
+
+ // Mock successful external action lookup
+ const generator = new EndpointActionGenerator('seed');
+ const originalActionSearchResponse = generator.toEsSearchResponse([
+ generator.generateActionEsHit<
+ undefined,
+ {},
+ MicrosoftDefenderEndpointActionRequestCommonMeta
+ >({
+ agent: { id: 'agent-uuid-1' },
+ EndpointActions: {
+ action_id: 'valid-pending-action',
+ data: { command: 'isolate' },
+ input_type: 'microsoft_defender_endpoint',
+ },
+ meta: { machineActionId: 'external-machine-action-id-456' },
+ }),
+ ]);
+
+ applyEsClientSearchMock({
+ esClientMock: clientConstructorOptionsMock.esClient,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: originalActionSearchResponse,
+ });
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'valid-pending-action' },
+ })
+ ).resolves.toMatchObject({
+ id: expect.any(String),
+ command: expect.any(String),
+ isCompleted: expect.any(Boolean),
+ });
+
+ // Should call connector for valid request
+ expect(connectorActionsMock.execute).toHaveBeenCalledWith({
+ params: {
+ subAction: MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.CANCEL_ACTION,
+ subActionParams: {
+ actionId: 'external-machine-action-id-456',
+ comment: expect.any(String),
+ },
+ },
+ });
+ });
+
+ it('should handle multiple agents array in validation', async () => {
+ // Mock getActionDetailsById to return an action with multiple agents
+ getActionDetailsByIdMock.mockResolvedValue({
+ id: 'multi-agent-action-id',
+ command: 'isolate',
+ isCompleted: false,
+ wasSuccessful: false,
+ agents: ['agent-1', '1-2-3', 'agent-3'], // Array with our target agent
+ });
+
+ // Mock successful external action lookup
+ const generator = new EndpointActionGenerator('seed');
+ const originalActionSearchResponse = generator.toEsSearchResponse([
+ generator.generateActionEsHit<
+ undefined,
+ {},
+ MicrosoftDefenderEndpointActionRequestCommonMeta
+ >({
+ agent: { id: 'agent-uuid-1' },
+ EndpointActions: {
+ action_id: 'multi-agent-action-id',
+ data: { command: 'isolate' },
+ input_type: 'microsoft_defender_endpoint',
+ },
+ meta: { machineActionId: 'external-machine-action-id-789' },
+ }),
+ ]);
+
+ applyEsClientSearchMock({
+ esClientMock: clientConstructorOptionsMock.esClient,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: originalActionSearchResponse,
+ });
+
+ // Should succeed because 1-2-3 is in the agents array
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'multi-agent-action-id' },
+ })
+ ).resolves.toMatchObject({
+ id: expect.any(String),
+ command: expect.any(String),
+ isCompleted: expect.any(Boolean),
+ });
+ });
+
+ it('should handle unexpected errors during validation gracefully', async () => {
+ // Mock getActionDetailsById to throw an unexpected error (not "not found")
+ const unexpectedError = new Error('Database connection failed');
+ getActionDetailsByIdMock.mockRejectedValue(unexpectedError);
+
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'some-action-id' },
+ })
+ ).rejects.toThrow('Database connection failed');
+
+ expect(connectorActionsMock.execute).not.toHaveBeenCalled();
+ });
+
+ it('should validate id parameter is not empty string', async () => {
+ await expect(
+ msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: '' }, // Empty string should be rejected
+ })
+ ).rejects.toThrow('id is required in parameters');
+
+ // getActionDetailsById may be called during validation but should fail before connector call
+ expect(connectorActionsMock.execute).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('telemetry events', () => {
+ beforeEach(() => {
+ // Reset mock and ensure it returns a valid pending action
+ getActionDetailsByIdMock.mockReset();
+ getActionDetailsByIdMock.mockImplementation(async (_, __, id: string) => {
+ return new EndpointActionGenerator('seed').generateActionDetails({
+ id,
+ isCompleted: false, // Ensure not completed so cancel can proceed
+ wasSuccessful: false,
+ command: 'isolate',
+ agents: ['1-2-3'],
+ });
+ });
+ });
+
+ it('should send cancel action creation telemetry event', async () => {
+ await msClientMock.cancel({
+ endpoint_ids: ['1-2-3'],
+ comment: 'cancel test comment',
+ parameters: { id: 'original-action-id' },
+ });
+
+ expect(
+ clientConstructorOptionsMock.endpointService.getTelemetryService().reportEvent
+ ).toHaveBeenCalledWith(ENDPOINT_RESPONSE_ACTION_SENT_EVENT.eventType, {
+ responseActions: {
+ actionId: expect.any(String),
+ agentType: 'microsoft_defender_endpoint',
+ command: 'cancel',
+ isAutomated: false,
+ },
+ });
+ });
+ });
+ });
+
describe('#getCustomScripts()', () => {
it('should retrieve custom scripts from Microsoft Defender', async () => {
const result = await msClientMock.getCustomScripts();
@@ -1376,6 +2003,121 @@ describe('MS Defender response actions client', () => {
);
});
});
+
+ describe('for Cancel actions with Cancelled status', () => {
+ let msMachineActionsApiResponse: MicrosoftDefenderEndpointGetActionsResponse;
+
+ beforeEach(() => {
+ const generator = new EndpointActionGenerator('seed');
+
+ // Set up a cancel action request
+ const actionRequestsSearchResponse = generator.toEsSearchResponse([
+ generator.generateActionEsHit<
+ { id: string },
+ {},
+ MicrosoftDefenderEndpointActionRequestCommonMeta
+ >({
+ EndpointActions: {
+ action_id: '90d62689-f72d-4a05-b5e3-500cad0dc366',
+ data: { command: 'cancel', parameters: { id: 'target-action-id' } },
+ },
+ agent: { id: 'agent-uuid-1' },
+ meta: { machineActionId: '5382f7ea-7557-4ab7-9782-d50480024a4e' },
+ }),
+ ]);
+
+ applyEsClientSearchMock({
+ esClientMock: clientConstructorOptionsMock.esClient,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: jest
+ .fn(() => generator.toEsSearchResponse([]))
+ .mockReturnValueOnce(actionRequestsSearchResponse),
+ pitUsage: true,
+ });
+
+ // Set up the MS API response
+ msMachineActionsApiResponse = microsoftDefenderMock.createGetActionsApiResponse(
+ microsoftDefenderMock.createMachineAction({
+ id: '5382f7ea-7557-4ab7-9782-d50480024a4e',
+ status: 'Cancelled',
+ })
+ );
+
+ responseActionsClientMock.setConnectorActionsClientExecuteResponse(
+ connectorActionsMock,
+ MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.GET_ACTIONS,
+ msMachineActionsApiResponse
+ );
+ });
+
+ it('should generate success response for cancel action with Cancelled status', async () => {
+ const expectedResult: LogsEndpointActionResponse = {
+ '@timestamp': expect.any(String),
+ EndpointActions: {
+ action_id: '90d62689-f72d-4a05-b5e3-500cad0dc366',
+ completed_at: expect.any(String),
+ data: { command: 'cancel' },
+ input_type: 'microsoft_defender_endpoint',
+ started_at: expect.any(String),
+ },
+ agent: { id: 'agent-uuid-1' },
+ error: undefined, // Cancel action should succeed, no error
+ meta: undefined,
+ };
+
+ await msClientMock.processPendingActions(processPendingActionsOptions);
+
+ expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith(expectedResult);
+ });
+
+ it('should generate failure response for non-cancel action with Cancelled status', async () => {
+ // Update the action to be an isolate action (not a cancel action)
+ const generator = new EndpointActionGenerator('seed');
+ const actionRequestsSearchResponse = generator.toEsSearchResponse([
+ generator.generateActionEsHit<
+ undefined,
+ {},
+ MicrosoftDefenderEndpointActionRequestCommonMeta
+ >({
+ EndpointActions: {
+ action_id: '90d62689-f72d-4b5e3-500cad0dc366',
+ data: { command: 'isolate' },
+ },
+ agent: { id: 'agent-uuid-1' },
+ meta: { machineActionId: '5382f7ea-7557-4ab7-9782-d50480024a4e' },
+ }),
+ ]);
+
+ applyEsClientSearchMock({
+ esClientMock: clientConstructorOptionsMock.esClient,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: jest
+ .fn(() => generator.toEsSearchResponse([]))
+ .mockReturnValueOnce(actionRequestsSearchResponse),
+ pitUsage: true,
+ });
+
+ const expectedResult: LogsEndpointActionResponse = {
+ '@timestamp': expect.any(String),
+ EndpointActions: {
+ action_id: '90d62689-f72d-4b5e3-500cad0dc366',
+ completed_at: expect.any(String),
+ data: { command: 'isolate' },
+ input_type: 'microsoft_defender_endpoint',
+ started_at: expect.any(String),
+ },
+ agent: { id: 'agent-uuid-1' },
+ error: {
+ message: expect.any(String), // Isolate action that was cancelled should fail
+ },
+ meta: undefined,
+ };
+
+ await msClientMock.processPendingActions(processPendingActionsOptions);
+
+ expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith(expectedResult);
+ });
+ });
});
describe('and space awareness is enabled', () => {
@@ -1383,8 +2125,18 @@ describe('MS Defender response actions client', () => {
// @ts-expect-error assign to readonly property
clientConstructorOptionsMock.endpointService.experimentalFeatures.endpointManagementSpaceAwarenessEnabled =
true;
-
- getActionDetailsByIdMock.mockResolvedValue({});
+ // @ts-expect-error assign to readonly property
+ clientConstructorOptionsMock.endpointService.experimentalFeatures.microsoftDefenderEndpointCancelEnabled =
+ true;
+ getActionDetailsByIdMock.mockImplementation(async (_, __, id: string) => {
+ return new EndpointActionGenerator('seed').generateActionDetails({
+ id,
+ isCompleted: false, // Ensure not completed so cancel can proceed
+ wasSuccessful: false,
+ command: 'isolate',
+ agents: ['1-2-3'],
+ });
+ });
});
afterEach(() => {
getActionDetailsByIdMock.mockReset();
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts
index b7192377f739f..3e7fc3b9ddc11 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/clients/microsoft/defender/endpoint/ms_defender_endpoint_actions_client.ts
@@ -10,15 +10,16 @@ import {
MICROSOFT_DEFENDER_ENDPOINT_CONNECTOR_ID,
MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION,
} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/constants';
-import {
- type MicrosoftDefenderEndpointAgentDetailsParams,
- type MicrosoftDefenderEndpointIsolateHostParams,
- type MicrosoftDefenderEndpointMachine,
- type MicrosoftDefenderEndpointMachineAction,
- type MicrosoftDefenderEndpointGetActionsParams,
- type MicrosoftDefenderEndpointGetActionsResponse,
- type MicrosoftDefenderEndpointRunScriptParams,
- type MicrosoftDefenderGetLibraryFilesResponse,
+import type {
+ MicrosoftDefenderEndpointAgentDetailsParams,
+ MicrosoftDefenderEndpointIsolateHostParams,
+ MicrosoftDefenderEndpointCancelParams,
+ MicrosoftDefenderEndpointMachine,
+ MicrosoftDefenderEndpointMachineAction,
+ MicrosoftDefenderEndpointGetActionsParams,
+ MicrosoftDefenderEndpointGetActionsResponse,
+ MicrosoftDefenderEndpointRunScriptParams,
+ MicrosoftDefenderGetLibraryFilesResponse,
} from '@kbn/stack-connectors-plugin/common/microsoft_defender_endpoint/types';
import { groupBy } from 'lodash';
import type { Readable } from 'stream';
@@ -30,6 +31,7 @@ import type {
RunScriptActionRequestBody,
UnisolationRouteRequestBody,
MSDefenderRunScriptActionRequestParams,
+ CancelActionRequestBody,
} from '../../../../../../../../common/api/endpoint';
import type {
ActionDetails,
@@ -41,6 +43,8 @@ import type {
MicrosoftDefenderEndpointActionRequestCommonMeta,
MicrosoftDefenderEndpointActionRequestFileMeta,
MicrosoftDefenderEndpointLogEsDoc,
+ ResponseActionCancelParameters,
+ ResponseActionCancelOutputContent,
ResponseActionRunScriptOutputContent,
ResponseActionRunScriptParameters,
UploadedFileInfo,
@@ -389,6 +393,87 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien
};
}
+ // For cancel actions, perform comprehensive validation
+ if (payload.command === 'cancel') {
+ const { microsoftDefenderEndpointCancelEnabled } =
+ this.options.endpointService.experimentalFeatures;
+
+ if (!microsoftDefenderEndpointCancelEnabled) {
+ throw new ResponseActionsClientError(
+ 'Cancel operation is not enabled for Microsoft Defender for Endpoint',
+ 400
+ );
+ }
+
+ const actionId = payload.parameters?.id;
+ if (!actionId) {
+ return {
+ isValid: false,
+ error: new ResponseActionsClientError(
+ 'id is required in parameters for cancel action',
+ 400
+ ),
+ };
+ }
+
+ try {
+ // Fetch the original action to validate cancel request
+ const originalAction = await this.fetchActionDetails(actionId);
+
+ // Check if action is already completed
+ if (originalAction.isCompleted) {
+ const statusMessage = originalAction.wasSuccessful ? 'completed successfully' : 'failed';
+ return {
+ isValid: false,
+ error: new ResponseActionsClientError(
+ `Cannot cancel action [${actionId}] because it has already ${statusMessage}.`,
+ 400
+ ),
+ };
+ }
+
+ // Validate endpoint ID association if provided
+ const requestEndpointId = payload.endpoint_ids?.[0];
+ if (requestEndpointId && originalAction.agents) {
+ const originalActionAgentIds = Array.isArray(originalAction.agents)
+ ? originalAction.agents
+ : [originalAction.agents];
+
+ if (!originalActionAgentIds.includes(requestEndpointId)) {
+ return {
+ isValid: false,
+ error: new ResponseActionsClientError(
+ `Endpoint '${requestEndpointId}' is not associated with action '${actionId}'`,
+ 400
+ ),
+ };
+ }
+ }
+
+ // Validate command information exists
+ if (!originalAction.command) {
+ return {
+ isValid: false,
+ error: new ResponseActionsClientError(
+ `Unable to determine command type for action '${actionId}'`,
+ 500
+ ),
+ };
+ }
+ } catch (error) {
+ // If we can't fetch the action details (e.g., action not found),
+ // return a validation error
+ if (error instanceof Error && error.message.includes('not found')) {
+ return {
+ isValid: false,
+ error: new ResponseActionsClientError(`Action with id '${actionId}' not found.`, 404),
+ };
+ }
+ // For other errors, let them bubble up
+ throw error;
+ }
+ }
+
return super.validateRequest(payload);
}
@@ -557,6 +642,79 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien
>;
}
+ async cancel(
+ actionRequest: CancelActionRequestBody,
+ options: CommonResponseActionMethodOptions = {}
+ ): Promise> {
+ const actionId = actionRequest.parameters?.id;
+
+ const reqIndexOptions: ResponseActionsClientWriteActionRequestToEndpointIndexOptions<
+ ResponseActionCancelParameters,
+ {},
+ MicrosoftDefenderEndpointActionRequestCommonMeta
+ > = {
+ ...actionRequest,
+ ...this.getMethodOptions(options),
+ command: 'cancel',
+ parameters: {
+ id: actionId,
+ },
+ };
+
+ if (!reqIndexOptions.error) {
+ let error = (await this.validateRequest(reqIndexOptions)).error;
+
+ if (!error) {
+ try {
+ // Get the external action ID from the internal response action ID
+ const actionRequestWithExternalId = await this.fetchActionRequestEsDoc<
+ EndpointActionDataParameterTypes,
+ EndpointActionResponseDataOutput,
+ MicrosoftDefenderEndpointActionRequestCommonMeta
+ >(actionId);
+ const externalActionId = actionRequestWithExternalId?.meta?.machineActionId;
+ if (!externalActionId) {
+ throw new ResponseActionsClientError(
+ `Unable to resolve Microsoft Defender machine action ID for action [${actionId}]`,
+ 500
+ );
+ }
+
+ const msActionResponse = await this.sendAction<
+ MicrosoftDefenderEndpointMachineAction,
+ MicrosoftDefenderEndpointCancelParams
+ >(MICROSOFT_DEFENDER_ENDPOINT_SUB_ACTION.CANCEL_ACTION, {
+ actionId: externalActionId,
+ comment: this.buildExternalComment(reqIndexOptions),
+ });
+
+ if (msActionResponse?.data?.id) {
+ reqIndexOptions.meta = { machineActionId: msActionResponse.data.id };
+ } else {
+ throw new ResponseActionsClientError(
+ `Cancel request was sent to Microsoft Defender, but Machine Action Id was not provided!`
+ );
+ }
+ } catch (err) {
+ error = err;
+ }
+ }
+
+ reqIndexOptions.error = error?.message;
+
+ if (!this.options.isAutomated && error) {
+ throw error;
+ }
+ }
+
+ const { actionDetails } = await this.handleResponseActionCreation(reqIndexOptions);
+
+ return actionDetails as ActionDetails<
+ ResponseActionCancelOutputContent,
+ ResponseActionCancelParameters
+ >;
+ }
+
async processPendingActions({
abortSignal,
addToQueue,
@@ -592,6 +750,7 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien
switch (actionType as ResponseActionsApiCommandNames) {
case 'isolate':
case 'unisolate':
+ case 'cancel':
addResponsesToQueueIfAny(
await this.checkPendingActions(
typePendingActions as Array<
@@ -603,6 +762,7 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien
>
)
);
+ break;
case 'runscript':
addResponsesToQueueIfAny(
await this.checkPendingActions(
@@ -616,6 +776,7 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien
{ downloadResult: true }
)
);
+ break;
}
}
}
@@ -686,7 +847,6 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien
if (!isPending) {
const pendingActionRequests = actionsByMachineId[machineAction.id] ?? [];
-
for (const actionRequest of pendingActionRequests) {
let additionalData = {};
// In order to not copy paste most of the logic, I decided to add this additional check here to support `runscript` action and it's result that comes back as a link to download the file
@@ -699,6 +859,18 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien
},
};
}
+
+ // Special handling for cancelled actions:
+ // Cancel actions that successfully cancel something should show as success
+ // Actions that were cancelled by another action should show as failed
+ let finalIsError = isError;
+ if (
+ machineAction.status === 'Cancelled' &&
+ actionRequest.EndpointActions.data.command === 'cancel'
+ ) {
+ finalIsError = false; // Cancel action succeeded
+ }
+
completedResponses.push(
this.buildActionResponseEsDoc({
actionId: actionRequest.EndpointActions.action_id,
@@ -706,7 +878,7 @@ export class MicrosoftDefenderEndpointActionsClient extends ResponseActionsClien
? actionRequest.agent.id[0]
: actionRequest.agent.id,
data: { command: actionRequest.EndpointActions.data.command },
- error: isError
+ error: finalIsError
? {
message: commandErrors || message,
}
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 0e665b9ee6384..8705600fab40e 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
@@ -55,6 +55,7 @@ import type {
UploadActionApiRequestBody,
ScanActionRequestBody,
RunScriptActionRequestBody,
+ CancelActionRequestBody,
} from '../../../../../common/api/endpoint';
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
import { RESPONSE_ACTION_API_COMMANDS_NAMES } from '../../../../../common/endpoint/service/response_actions/constants';
@@ -84,6 +85,7 @@ const createResponseActionClientMock = (): jest.Mocked =>
scan: jest.fn().mockReturnValue(Promise.resolve()),
runscript: jest.fn().mockReturnValue(Promise.resolve()),
getCustomScripts: jest.fn().mockReturnValue(Promise.resolve()),
+ cancel: jest.fn().mockReturnValue(Promise.resolve()),
};
};
@@ -365,6 +367,18 @@ const createRunScriptOptionsMock = <
return merge(options, overrides);
};
+const createCancelActionOptionsMock = (
+ overrides: Partial = {}
+): CancelActionRequestBody => {
+ const options: CancelActionRequestBody = {
+ ...createNoParamsResponseActionOptionsMock(),
+ parameters: {
+ id: 'test-action-id-123',
+ },
+ };
+ return merge(options, overrides);
+};
+
const createConnectorMock = (
overrides: DeepPartial = {}
): ConnectorWithExtraFindData => {
@@ -527,6 +541,9 @@ const getOptionsForResponseActionMethod = (method: ResponseActionsClientMethods)
case 'getFile':
return createGetFileOptionsMock();
+ case 'cancel':
+ return createCancelActionOptionsMock();
+
default:
throw new Error(`Mock options are not defined for response action method [${method}]`);
}
@@ -549,6 +566,7 @@ export const responseActionsClientMock = Object.freeze({
createUploadOptions: createUploadOptionsMock,
createScanOptions: createScanOptionsMock,
createRunScriptOptions: createRunScriptOptionsMock,
+ createCancelActionOptions: createCancelActionOptionsMock,
createIndexedResponse: createEsIndexTransportResponseMock,
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts
index 8e759ac848910..c41e671c72019 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts
@@ -32,7 +32,7 @@ describe('fetchActionResponses()', () => {
error: '',
'@timestamp': '2022-04-30T16:08:47.449Z',
action_data: {
- command: 'execute',
+ command: 'upload',
comment: '',
parameter: undefined,
},
@@ -44,26 +44,18 @@ describe('fetchActionResponses()', () => {
action_id: '123',
completed_at: '2022-04-30T10:53:59.449Z',
data: {
- command: 'execute',
+ command: 'upload',
comment: '',
output: {
content: {
- code: 'ra_execute_success_done',
- cwd: '/some/path',
- output_file_id: 'some-output-file-id',
- output_file_stderr_truncated: false,
- output_file_stdout_truncated: true,
- shell: 'bash',
- shell_code: 0,
- stderr: expect.any(String),
- stderr_truncated: true,
- stdout_truncated: true,
- stdout: expect.any(String),
+ code: 'ra_upload_file-success',
+ disk_free_space: 4825566125475,
+ path: '/disk1/file/saved/here',
},
type: 'json',
},
},
- started_at: '2022-04-30T13:56:00.449Z',
+ started_at: '2022-04-30T12:56:00.449Z',
},
agent: {
id: 'agent-a',
@@ -75,7 +67,7 @@ describe('fetchActionResponses()', () => {
{
'@timestamp': '2022-04-30T16:08:47.449Z',
action_data: {
- command: 'execute',
+ command: 'upload',
comment: '',
parameter: undefined,
},
@@ -91,26 +83,18 @@ describe('fetchActionResponses()', () => {
action_id: '123',
completed_at: '2022-04-30T10:53:59.449Z',
data: {
- command: 'execute',
+ command: 'upload',
comment: '',
output: {
content: {
- code: 'ra_execute_success_done',
- cwd: '/some/path',
- output_file_id: 'some-output-file-id',
- output_file_stderr_truncated: false,
- output_file_stdout_truncated: true,
- shell: 'bash',
- shell_code: 0,
- stderr_truncated: true,
- stdout_truncated: true,
- stderr: expect.any(String),
- stdout: expect.any(String),
+ code: 'ra_upload_file-success',
+ disk_free_space: 4825566125475,
+ path: '/disk1/file/saved/here',
},
type: 'json',
},
},
- started_at: '2022-04-30T13:56:00.449Z',
+ started_at: '2022-04-30T12:56:00.449Z',
},
agent: {
id: 'agent-a',
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts
index 9fe23d6302a77..4b3570a056a5e 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts
@@ -77,7 +77,7 @@ describe('When using Actions service utilities', () => {
agents: ['6e6796b0-af39-4f12-b025-fcb06db499e5'],
agentType: 'endpoint',
hosts: {},
- command: 'kill-process',
+ command: 'suspend-process',
comment: expect.any(String),
createdAt: '2022-04-27T16:08:47.449Z',
createdBy: 'elastic',
@@ -99,7 +99,7 @@ describe('When using Actions service utilities', () => {
agents: ['90d62689-f72d-4a05-b5e3-500cad0dc366'],
agentType: 'endpoint',
hosts: {},
- command: 'kill-process',
+ command: 'suspend-process',
comment: expect.any(String),
createdAt: '2022-04-27T16:08:47.449Z',
createdBy: 'Shanel',
@@ -1066,17 +1066,9 @@ describe('When using Actions service utilities', () => {
outputs: {
'6e6796b0-af39-4f12-b025-fcb06db499e5': {
content: {
- code: 'ra_execute_success_done',
- cwd: '/some/path',
- output_file_id: 'some-output-file-id',
- output_file_stderr_truncated: false,
- output_file_stdout_truncated: true,
- shell: 'bash',
- shell_code: 0,
- stderr: expect.any(String),
- stderr_truncated: true,
- stdout: expect.any(String),
- stdout_truncated: true,
+ code: 'ra_upload_file-success',
+ disk_free_space: 4825566125475,
+ path: '/disk1/file/saved/here',
},
type: 'json',
},
diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/feature_usage/feature_keys.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/feature_usage/feature_keys.ts
index cf8c7d13ff328..baf2bc32a6f1d 100644
--- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/feature_usage/feature_keys.ts
+++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/feature_usage/feature_keys.ts
@@ -26,6 +26,7 @@ export const FEATURE_KEYS = {
EXECUTE: 'Execute command',
SCAN: 'Scan files',
RUN_SCRIPT: 'Run script',
+ CANCEL: 'Cancel action',
ALERTS_BY_PROCESS_ANCESTRY: 'Get related alerts by process ancestry',
ENDPOINT_EXCEPTIONS: 'Endpoint exceptions',
} as const;
@@ -44,6 +45,7 @@ const RESPONSE_ACTIONS_FEATURE_KEY: Readonly info
@@ -2365,6 +2377,9 @@ export interface AlertsMigrationCleanupProps {
export interface BulkUpsertAssetCriticalityRecordsProps {
body: BulkUpsertAssetCriticalityRecordsRequestBodyInput;
}
+export interface CancelActionProps {
+ body: CancelActionRequestBodyInput;
+}
export interface CleanDraftTimelinesProps {
body: CleanDraftTimelinesRequestBodyInput;
}