Skip to content

Commit 3ca5551

Browse files
[8.19] [EDR Workflows] Show error when fetching scripts fails (#226532) (#229252)
1 parent f5ef1a5 commit 3ca5551

File tree

14 files changed

+397
-30
lines changed

14 files changed

+397
-30
lines changed

x-pack/platform/plugins/shared/stack_connectors/common/microsoft_defender_endpoint/types.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ export interface MicrosoftDefenderEndpointMachineAction {
151151
externalID?: string;
152152
/** The name of the user/application that submitted the action. */
153153
requestSource: string;
154-
/** Commands to run. Allowed values are PutFile, RunScript, GetFile. */
155-
commands: Array<'PutFile' | 'RunScript' | 'GetFile'>;
154+
/** Array of command execution details for this action. */
155+
commands: MicrosoftDefenderCommandEntry[];
156156
/** Identity of the person that canceled the action. */
157157
cancellationRequestor: string;
158158
/** Comment that was written when issuing the action. */
@@ -198,3 +198,24 @@ export interface MicrosoftDefenderEndpointApiTokenResponse {
198198
}
199199

200200
export type MicrosoftDefenderGetLibraryFilesResponse = TypeOf<typeof GetLibraryFilesResponse>;
201+
202+
/**
203+
* Represents a single command execution entry as returned by Microsoft Defender API.
204+
*/
205+
export interface MicrosoftDefenderCommandEntry {
206+
/** The index of the command in the sequence. */
207+
index: number;
208+
/** The UTC start time (ISO string) of the command execution. */
209+
startTime: string;
210+
/** The UTC end time (ISO string) of the command execution. */
211+
endTime: string;
212+
/** Status of the command execution. */
213+
commandStatus: string;
214+
/** Array of error messages (if any) for the command execution. */
215+
errors: string[];
216+
/** The command details (type and params). */
217+
command: {
218+
type: 'PutFile' | 'RunScript' | 'GetFile';
219+
params: Array<{ key: string; value: string }>;
220+
};
221+
}

x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/microsoft_defender_endpoint.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,22 @@ describe('Microsoft Defender for Endpoint Connector', () => {
131131
cancellationComment: '',
132132
cancellationDateTimeUtc: '',
133133
cancellationRequestor: '',
134-
commands: ['RunScript'],
134+
commands: [
135+
{
136+
index: 0,
137+
startTime: '2025-07-07T18:50:10.186354Z',
138+
endTime: '2025-07-07T18:50:21.811356Z',
139+
commandStatus: 'Completed',
140+
errors: [],
141+
command: {
142+
type: 'RunScript',
143+
params: [
144+
{ key: 'ScriptName', value: 'hello.sh' },
145+
{ key: 'Args', value: '--noargs' },
146+
],
147+
},
148+
},
149+
],
135150
computerDnsName: 'desktop-test',
136151
creationDateTimeUtc: '2019-01-02T14:39:38.2262283Z',
137152
externalID: 'abc',

x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,22 @@ const createMicrosoftMachineAction = (
237237
creationDateTimeUtc: '2019-01-02T14:39:38.2262283Z',
238238
lastUpdateDateTimeUtc: '2019-01-02T14:40:44.6596267Z',
239239
externalID: 'abc',
240-
commands: ['RunScript'],
240+
commands: [
241+
{
242+
index: 0,
243+
startTime: '2025-07-07T18:50:10.186354Z',
244+
endTime: '2025-07-07T18:50:21.811356Z',
245+
commandStatus: 'Completed',
246+
errors: [],
247+
command: {
248+
type: 'RunScript',
249+
params: [
250+
{ key: 'ScriptName', value: 'hello.sh' },
251+
{ key: 'Args', value: '--noargs' },
252+
],
253+
},
254+
},
255+
],
241256
cancellationRequestor: '',
242257
cancellationComment: '',
243258
cancellationDateTimeUtc: '',
Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@
77

88
import React from 'react';
99
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
10-
import { CustomScriptSelector } from './custom_script_selector';
11-
import { useGetCustomScripts } from '../../hooks/custom_scripts/use_get_custom_scripts';
12-
import { useConsoleStateDispatch } from '../console/hooks/state_selectors/use_console_state_dispatch';
13-
import type { CommandArgumentValueSelectorProps } from '../console/types';
14-
import type { CustomScript } from '../../../../server/endpoint/services';
10+
import type { KibanaReactContextValue } from '@kbn/kibana-react-plugin/public';
1511

16-
jest.mock('../../hooks/custom_scripts/use_get_custom_scripts');
17-
jest.mock('../console/hooks/state_selectors/use_console_state_dispatch');
12+
import { CustomScriptSelector } from './custom_script_selector';
13+
import { useGetCustomScripts } from '../../../hooks/custom_scripts/use_get_custom_scripts';
14+
import { useConsoleStateDispatch } from '../../console/hooks/state_selectors/use_console_state_dispatch';
15+
import { useCustomScriptsErrorToast } from './use_custom_scripts_error_toast';
16+
import { useKibana } from '../../../../common/lib/kibana';
17+
import type { CommandArgumentValueSelectorProps } from '../../console/types';
18+
import type { CustomScript } from '../../../../../server/endpoint/services';
19+
20+
jest.mock('../../../hooks/custom_scripts/use_get_custom_scripts');
21+
jest.mock('../../console/hooks/state_selectors/use_console_state_dispatch');
22+
jest.mock('./use_custom_scripts_error_toast');
23+
jest.mock('../../../../common/lib/kibana');
1824

1925
// Mock setTimeout to execute immediately in tests
2026
jest.useFakeTimers();
@@ -26,6 +32,10 @@ describe('CustomScriptSelector', () => {
2632
const mockUseConsoleStateDispatch = useConsoleStateDispatch as jest.MockedFunction<
2733
typeof useConsoleStateDispatch
2834
>;
35+
const mockUseCustomScriptsErrorToast = useCustomScriptsErrorToast as jest.MockedFunction<
36+
typeof useCustomScriptsErrorToast
37+
>;
38+
const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
2939
const mockOnChange = jest.fn();
3040
const mockDispatch = jest.fn();
3141
const mockScripts: CustomScript[] = [
@@ -53,6 +63,23 @@ describe('CustomScriptSelector', () => {
5363

5464
// Mock the dispatch function
5565
mockUseConsoleStateDispatch.mockReturnValue(mockDispatch);
66+
67+
// Mock the error toast hook
68+
mockUseCustomScriptsErrorToast.mockImplementation(() => {});
69+
70+
// Mock useKibana
71+
mockUseKibana.mockReturnValue({
72+
services: {
73+
notifications: {
74+
toasts: {
75+
add: jest.fn(),
76+
addSuccess: jest.fn(),
77+
addWarning: jest.fn(),
78+
addDanger: jest.fn(),
79+
},
80+
},
81+
},
82+
} as unknown as KibanaReactContextValue<never>);
5683
});
5784

5885
afterEach(() => {
Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ import {
1818
import { css } from '@emotion/react';
1919

2020
import { i18n } from '@kbn/i18n';
21+
import { FormattedMessage } from '@kbn/i18n-react';
2122
import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
22-
import type { CustomScript } from '../../../../server/endpoint/services';
23-
import { useConsoleStateDispatch } from '../console/hooks/state_selectors/use_console_state_dispatch';
24-
import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants';
25-
import { useGetCustomScripts } from '../../hooks/custom_scripts/use_get_custom_scripts';
26-
import type { CommandArgumentValueSelectorProps } from '../console/types';
23+
import type { CustomScript } from '../../../../../server/endpoint/services';
24+
import { useConsoleStateDispatch } from '../../console/hooks/state_selectors/use_console_state_dispatch';
25+
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
26+
import { useGetCustomScripts } from '../../../hooks/custom_scripts/use_get_custom_scripts';
27+
import { useKibana } from '../../../../common/lib/kibana';
28+
import type { CommandArgumentValueSelectorProps } from '../../console/types';
29+
import { useCustomScriptsErrorToast } from './use_custom_scripts_error_toast';
2730

2831
// Css to have a tooltip in place with a one line truncated description
2932
const truncationStyle = css({
@@ -60,6 +63,10 @@ export const CustomScriptSelector = (agentType: ResponseActionAgentType) => {
6063
CommandArgumentValueSelectorProps<string, CustomScriptSelectorState>
6164
>(({ value, valueText, onChange, store: _store }) => {
6265
const dispatch = useConsoleStateDispatch();
66+
const {
67+
services: { notifications },
68+
} = useKibana();
69+
6370
const state = useMemo<CustomScriptSelectorState>(() => {
6471
return _store ?? { isPopoverOpen: true };
6572
}, [_store]);
@@ -77,7 +84,12 @@ export const CustomScriptSelector = (agentType: ResponseActionAgentType) => {
7784
[onChange, state, value, valueText]
7885
);
7986

80-
const { data = [], isLoading: isLoadingScripts } = useGetCustomScripts(agentType);
87+
const {
88+
data = [],
89+
isLoading: isLoadingScripts,
90+
error: scriptsError,
91+
} = useGetCustomScripts(agentType);
92+
8193
const scriptsOptions: SelectableOption[] = useMemo(() => {
8294
return data.map((script: CustomScript) => {
8395
const isChecked = script.name === value;
@@ -160,7 +172,11 @@ export const CustomScriptSelector = (agentType: ResponseActionAgentType) => {
160172
[onChange, state]
161173
);
162174

163-
if (isAwaitingRenderDelay || isLoadingScripts) {
175+
// notifications comes from useKibana() and is of type NotificationsStart
176+
// which is compatible with our updated useCustomScriptsErrorToast function
177+
useCustomScriptsErrorToast(scriptsError, notifications);
178+
179+
if (isAwaitingRenderDelay || (isLoadingScripts && !scriptsError)) {
164180
return <EuiLoadingSpinner />;
165181
}
166182

@@ -206,6 +222,14 @@ export const CustomScriptSelector = (agentType: ResponseActionAgentType) => {
206222
showIcons: true,
207223
textWrap: 'truncate',
208224
}}
225+
errorMessage={
226+
scriptsError ? (
227+
<FormattedMessage
228+
id="xpack.securitySolution.endpoint.customScriptSelector.errorLoading"
229+
defaultMessage="Error loading scripts"
230+
/>
231+
) : undefined
232+
}
209233
>
210234
{(list, search) => (
211235
<>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { renderHook } from '@testing-library/react';
9+
import type { NotificationsStart } from '@kbn/core-notifications-browser';
10+
import type { IHttpFetchError } from '@kbn/core/public';
11+
import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
12+
import { useCustomScriptsErrorToast } from './use_custom_scripts_error_toast';
13+
import type { CustomScriptsErrorType } from '../../../hooks/custom_scripts/use_get_custom_scripts';
14+
15+
describe('useCustomScriptsErrorToast', () => {
16+
const mockToastDanger = jest.fn();
17+
const mockNotifications = {
18+
toasts: {
19+
addDanger: mockToastDanger,
20+
addSuccess: jest.fn(),
21+
addWarning: jest.fn(),
22+
add: jest.fn(),
23+
},
24+
} as unknown as NotificationsStart;
25+
26+
beforeEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
test('does not show toast when no error is provided', () => {
31+
renderHook(() => useCustomScriptsErrorToast(null, mockNotifications));
32+
33+
expect(mockToastDanger).not.toHaveBeenCalled();
34+
});
35+
36+
test('shows toast with full error message from err.body.message', () => {
37+
const mockError: IHttpFetchError<CustomScriptsErrorType> = {
38+
name: 'HttpFetchError',
39+
message: 'HTTP Error',
40+
body: {
41+
statusCode: 403,
42+
message: 'Response body: {"error":{"code":"Forbidden","message":"Access denied"}}',
43+
meta: {} as ActionTypeExecutorResult<unknown>,
44+
},
45+
request: {} as unknown as Request,
46+
response: {} as unknown as Response,
47+
};
48+
49+
renderHook(() => useCustomScriptsErrorToast(mockError, mockNotifications));
50+
51+
expect(mockToastDanger).toHaveBeenCalledWith({
52+
title: 'Error: 403',
53+
text: expect.any(String),
54+
});
55+
});
56+
57+
test('shows toast with status code when no parsed error is available', () => {
58+
const mockError: IHttpFetchError<CustomScriptsErrorType> = {
59+
name: 'HttpFetchError',
60+
message: 'HTTP Error',
61+
body: {
62+
statusCode: 500,
63+
message: 'Internal server error',
64+
meta: {} as ActionTypeExecutorResult<unknown>,
65+
},
66+
request: {} as unknown as Request,
67+
response: {} as unknown as Response,
68+
};
69+
70+
renderHook(() => useCustomScriptsErrorToast(mockError, mockNotifications));
71+
72+
expect(mockToastDanger).toHaveBeenCalledWith({
73+
title: 'Error: 500',
74+
text: expect.any(String),
75+
});
76+
});
77+
78+
test('shows toast with error message when no body is available', () => {
79+
const mockError: IHttpFetchError<CustomScriptsErrorType> = {
80+
name: 'HttpFetchError',
81+
message: 'Network error',
82+
body: undefined,
83+
request: {} as unknown as Request,
84+
response: {} as unknown as Response,
85+
};
86+
87+
renderHook(() => useCustomScriptsErrorToast(mockError, mockNotifications));
88+
89+
expect(mockToastDanger).toHaveBeenCalledWith({
90+
title: 'Error: Error',
91+
text: expect.any(String),
92+
});
93+
});
94+
95+
test('only shows toast once per error instance', () => {
96+
const mockError: IHttpFetchError<CustomScriptsErrorType> = {
97+
name: 'HttpFetchError',
98+
message: 'HTTP Error',
99+
body: {
100+
statusCode: 403,
101+
message: 'Access denied',
102+
meta: {} as ActionTypeExecutorResult<unknown>,
103+
},
104+
request: {} as unknown as Request,
105+
response: {} as unknown as Response,
106+
};
107+
108+
const { rerender } = renderHook(() => useCustomScriptsErrorToast(mockError, mockNotifications));
109+
110+
// Rerender with the same error - should not show toast again
111+
rerender();
112+
113+
expect(mockToastDanger).toHaveBeenCalledTimes(1);
114+
});
115+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { useEffect } from 'react';
9+
import type { IHttpFetchError } from '@kbn/core/public';
10+
import type { NotificationsStart } from '@kbn/core-notifications-browser';
11+
import type { CustomScriptsErrorType } from '../../../hooks/custom_scripts/use_get_custom_scripts';
12+
13+
/**
14+
* Shows a danger toast with details if scriptsError is present.
15+
* @param scriptsError Error object from custom scripts fetch
16+
* @param notifications Kibana notifications service
17+
*/
18+
export const useCustomScriptsErrorToast = (
19+
scriptsError: IHttpFetchError<CustomScriptsErrorType> | null,
20+
notifications: NotificationsStart
21+
) => {
22+
useEffect(() => {
23+
if (scriptsError) {
24+
let code = 'Error';
25+
let message: string | undefined;
26+
27+
const err = scriptsError;
28+
if (err?.body?.message) {
29+
message = err.body.message;
30+
code = String(err.body.statusCode ?? code);
31+
} else {
32+
message = err?.message || String(err);
33+
}
34+
35+
if (message) {
36+
notifications.toasts.addDanger({
37+
title: `Error: ${code}`,
38+
text: message,
39+
});
40+
}
41+
}
42+
}, [scriptsError, notifications]);
43+
};

x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { i18n } from '@kbn/i18n';
9-
import { CustomScriptSelector } from '../../console_argument_selectors/custom_script_selector';
9+
import { CustomScriptSelector } from '../../console_argument_selectors/custom_scripts_selector/custom_script_selector';
1010
import { RunScriptActionResult } from '../command_render_components/run_script_action';
1111
import type { CommandArgDefinition } from '../../console/types';
1212
import { isAgentTypeAndActionSupported } from '../../../../common/lib/endpoint';

x-pack/solutions/security/plugins/security_solution/public/management/components/endpoint_response_actions_list/components/action_log_expanded_tray.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ const OutputContent = memo<{
226226
data-test-subj={getTestId('actionsLogTray')}
227227
hideFile={action.agentType === 'crowdstrike'}
228228
hideContext={true}
229+
showPasscode={action.agentType !== 'microsoft_defender_endpoint'}
229230
/>
230231
</div>
231232
))}

0 commit comments

Comments
 (0)