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; }