[Security Solution][Endpoint] Response actions console isolate command (#131876)

* Delete the Security Solution Top Header consoles component
* Adds a menu item to the Alert details flyout, for Endpoint alerts only, that will launch the Responder console
* Adds a menu item to the Endpoint List and Endpoint details flyout for launching the Responder console
* Adds console's isolate command (send a request to isolate endpoint)
* Adds console's status command (endpoint status)
* Adds additional render functions to the Endpoint App Test renderer (renderHook(), renderReactQueryHook())
This commit is contained in:
Paul Tavares 2022-05-24 14:04:15 -04:00 committed by GitHub
parent 0103f1a22e
commit 577752ae7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 2591 additions and 828 deletions

View file

@ -11,8 +11,10 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ENDPOINT_ACTION_RESPONSES_DS, ENDPOINT_ACTIONS_INDEX } from '../constants';
import { BaseDataGenerator } from './base_data_generator';
import {
ActionDetails,
ActivityLogItemTypes,
EndpointActivityLogActionResponse,
EndpointPendingActions,
ISOLATION_ACTIONS,
LogsEndpointAction,
LogsEndpointActionResponse,
@ -103,6 +105,82 @@ export class EndpointActionGenerator extends BaseDataGenerator {
});
}
generateActionDetails(overrides: DeepPartial<ActionDetails> = {}): ActionDetails {
const details: ActionDetails = {
agents: ['agent-a'],
command: 'isolate',
completedAt: '2022-04-30T16:08:47.449Z',
id: '123',
isCompleted: true,
isExpired: false,
wasSuccessful: true,
errors: undefined,
logEntries: [
{
item: {
data: {
'@timestamp': '2022-04-27T16:08:47.449Z',
action_id: '123',
agents: ['agent-a'],
data: {
command: 'isolate',
comment: '5wb6pu6kh2xix5i',
},
expiration: '2022-04-29T16:08:47.449Z',
input_type: 'endpoint',
type: 'INPUT_ACTION',
user_id: 'elastic',
},
id: '44d8b915-c69c-4c48-8c86-b57d0bd631d0',
},
type: 'fleetAction',
},
{
item: {
data: {
'@timestamp': '2022-04-30T16:08:47.449Z',
action_data: {
command: 'unisolate',
comment: '',
},
action_id: '123',
agent_id: 'agent-a',
completed_at: '2022-04-30T16:08:47.449Z',
error: '',
started_at: '2022-04-30T16:08:47.449Z',
},
id: '54-65-65-98',
},
type: 'fleetResponse',
},
{
item: {
data: {
'@timestamp': '2022-04-30T16:08:47.449Z',
EndpointActions: {
action_id: '123',
completed_at: '2022-04-30T16:08:47.449Z',
data: {
command: 'unisolate',
comment: '',
},
started_at: '2022-04-30T16:08:47.449Z',
},
agent: {
id: 'agent-a',
},
},
id: '32-65-98',
},
type: 'response',
},
],
startedAt: '2022-04-27T16:08:47.449Z',
};
return merge(details, overrides);
}
generateActivityLogActionResponse(
overrides: DeepPartial<EndpointActivityLogActionResponse>
): EndpointActivityLogActionResponse {
@ -118,6 +196,21 @@ export class EndpointActionGenerator extends BaseDataGenerator {
);
}
generateAgentPendingActionsSummary(
overrides: Partial<EndpointPendingActions> = {}
): EndpointPendingActions {
return merge(
{
agent_id: this.seededUUIDv4(),
pending_actions: {
isolate: 2,
unisolate: 0,
},
},
overrides
);
}
randomFloat(): number {
return this.random();
}

View file

@ -200,8 +200,17 @@ export interface ActionDetails {
* performed on the endpoint
*/
command: string;
/**
* Will be set to true only if action is not yet completed and elapsed time has exceeded
* the request's expiration date
*/
isExpired: boolean;
/** Action has been completed */
isCompleted: boolean;
/** If the action was successful */
wasSuccessful: boolean;
/** Any errors encountered if `wasSuccessful` is `false` */
errors: undefined | string[];
/** The date when the initial action request was submitted */
startedAt: string;
/** The date when the action was completed (a response by the endpoint (not fleet) was received) */

View file

@ -27,7 +27,6 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import { timelineSelectors } from '../../../timelines/store/timeline';
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { getScopeFromPath, showSourcererByPath } from '../../../common/containers/sourcerer';
import { ConsolesPopoverHeaderSectionItem } from '../../../common/components/consoles_popover_header_section_item';
const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', {
defaultMessage: 'Add integrations',
@ -73,9 +72,6 @@ export const GlobalHeader = React.memo(
return (
<InPortal node={portalNode}>
<EuiHeaderSection side="right">
{/* The consoles Popover may or may not be shown, depending on the user's authz */}
<ConsolesPopoverHeaderSectionItem />
{isDetectionsPath(pathname) && (
<EuiHeaderSectionItem>
<MlPopover />

View file

@ -7,7 +7,7 @@
import { renderHook } from '@testing-library/react-hooks';
import { mockTimelineModel } from '../../../common/mock';
import { mockTimelineModel } from '../../../common/mock/timeline_results';
import { useFormatUrl } from '../../../common/components/link_to';
import { SecurityPageName } from '../../../app/types';
import { useInsertTimeline } from '.';

View file

@ -1,63 +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 React from 'react';
import { AppContextTestRender, createAppRootMockRenderer } from '../../mock/endpoint';
import { ConsolesPopoverHeaderSectionItem } from './consoles_popover_header_section_item';
import { useUserPrivileges as _useUserPrivileges } from '../user_privileges';
import { getEndpointPrivilegesInitialStateMock } from '../user_privileges/endpoint/mocks';
jest.mock('../user_privileges');
const userUserPrivilegesMock = _useUserPrivileges as jest.Mock;
describe('When rendering the `ConsolesPopoverHeaderSectionItem`', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let setExperimentalFlag: AppContextTestRender['setExperimentalFlag'];
beforeEach(() => {
const mockedContext = createAppRootMockRenderer();
setExperimentalFlag = mockedContext.setExperimentalFlag;
setExperimentalFlag({ responseActionsConsoleEnabled: true });
render = () => {
return (renderResult = mockedContext.render(<ConsolesPopoverHeaderSectionItem />));
};
});
afterEach(() => {
userUserPrivilegesMock.mockReturnValue({
...userUserPrivilegesMock(),
endpointPrivileges: getEndpointPrivilegesInitialStateMock(),
});
});
it('should show menu item if feature flag is true and user has authz to endpoint management', () => {
render();
expect(renderResult.getByTestId('endpointConsoles')).toBeTruthy();
});
it('should hide the menu item if feature flag is false', () => {
setExperimentalFlag({ responseActionsConsoleEnabled: false });
render();
expect(renderResult.queryByTestId('endpointConsoles')).toBeNull();
});
it('should hide menu item if user does not have authz to endpoint management', () => {
userUserPrivilegesMock.mockReturnValue({
...userUserPrivilegesMock(),
endpointPrivileges: getEndpointPrivilegesInitialStateMock({
canAccessEndpointManagement: false,
}),
});
render();
expect(renderResult.queryByTestId('endpointConsoles')).toBeNull();
});
});

View file

@ -1,77 +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 React, { memo, useState, useCallback, useMemo } from 'react';
import { EuiHeaderSectionItem, EuiHeaderSectionItemButton, EuiPopover } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useUserPrivileges } from '../user_privileges';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
const LABELS = Object.freeze({
buttonLabel: i18n.translate('xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel', {
defaultMessage: 'Endpoint consoles',
}),
});
const ConsolesPopover = memo(() => {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const handlePopoverToggle = useCallback(() => {
setIsPopoverOpen((prevState) => !prevState);
}, []);
const handlePopoverClose = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const buttonTextProps = useMemo(() => {
return { style: { fontSize: '1rem' } };
}, []);
return (
<EuiHeaderSectionItem>
<EuiPopover
anchorPosition="downRight"
id="consoles-popover"
button={
<EuiHeaderSectionItemButton
aria-expanded={isPopoverOpen}
aria-haspopup="true"
aria-label={LABELS.buttonLabel}
color="primary"
data-test-subj="endpointConsoles"
iconType="console"
iconSide="left"
onClick={handlePopoverToggle}
textProps={buttonTextProps}
>
{LABELS.buttonLabel}
</EuiHeaderSectionItemButton>
}
isOpen={isPopoverOpen}
closePopover={handlePopoverClose}
repositionOnScroll
>
{
'TODO: Currently open consoles and the ability to start a new console will be shown here soon'
}
</EuiPopover>
</EuiHeaderSectionItem>
);
});
ConsolesPopover.displayName = 'ConsolesPopover';
export const ConsolesPopoverHeaderSectionItem = memo((props) => {
const canAccessEndpointManagement =
useUserPrivileges().endpointPrivileges.canAccessEndpointManagement;
const isExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled(
'responseActionsConsoleEnabled'
);
return canAccessEndpointManagement && isExperimentalFeatureEnabled ? <ConsolesPopover /> : null;
});
ConsolesPopoverHeaderSectionItem.displayName = 'ConsolesPopoverHeaderSectionItem';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export const mockAlertDetailsData = [
export const generateAlertDetailsDataMock = () => [
{ category: 'process', field: 'process.name', values: ['-'], originalValue: '-' },
{ category: 'process', field: 'process.pid', values: [0], originalValue: 0 },
{ category: 'process', field: 'process.executable', values: ['-'], originalValue: '-' },
@ -654,3 +654,5 @@ export const mockAlertDetailsData = [
originalValue: ['dummy.exe'],
},
];
export const mockAlertDetailsData = generateAlertDetailsDataMock();

View file

@ -334,5 +334,55 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
</div>
</div>
</div>
.c0 {
position: fixed;
bottom: 60px;
left: 20px;
height: 50vh;
width: 48vw;
max-width: 90vw;
}
.c0.is-hidden {
display: none;
}
.c0.is-confirming .modal-content {
opacity: 0.3;
}
.c0 .console-holder {
height: 100%;
}
.c0 .terminate-confirm-panel {
max-width: 85%;
-webkit-box-flex: 0;
-webkit-flex-grow: 0;
-ms-flex-positive: 0;
flex-grow: 0;
}
<div
class="c0 euiModal euiModal--maxWidth-default is-hidden"
data-test-subj="consolePopupWrapper"
>
<div
class="euiModal__flex modal-content"
>
<div
class="euiModalBody"
data-test-subj="consolePopupBody"
>
<div
class="euiModalBody__overflow"
>
<div
class="console-holder"
/>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -41,7 +41,7 @@ describe('Last Event Time Stat', () => {
<LastEventTime docValueFields={[]} indexKey={LastEventIndexKey.hosts} indexNames={[]} />
</TestProviders>
);
expect(wrapper.html()).toBe(
expect(wrapper.find(LastEventTime).html()).toBe(
'<span class="euiLoadingSpinner euiLoadingSpinner--medium"></span>'
);
});
@ -58,7 +58,9 @@ describe('Last Event Time Stat', () => {
<LastEventTime docValueFields={[]} indexKey={LastEventIndexKey.hosts} indexNames={[]} />
</TestProviders>
);
expect(wrapper.html()).toBe('Last event: <span class="euiToolTipAnchor">20 hours ago</span>');
expect(wrapper.find(LastEventTime).html()).toBe(
'Last event: <span class="euiToolTipAnchor">20 hours ago</span>'
);
});
test('Bad date time string', async () => {
(useTimelineLastEventTime as jest.Mock).mockReturnValue([
@ -74,7 +76,7 @@ describe('Last Event Time Stat', () => {
</TestProviders>
);
expect(wrapper.html()).toBe('something-invalid');
expect(wrapper.find(LastEventTime).html()).toBe('something-invalid');
});
test('Null time string', async () => {
(useTimelineLastEventTime as jest.Mock).mockReturnValue([
@ -89,6 +91,6 @@ describe('Last Event Time Stat', () => {
<LastEventTime docValueFields={[]} indexKey={LastEventIndexKey.hosts} indexNames={[]} />
</TestProviders>
);
expect(wrapper.html()).toContain(getEmptyValue());
expect(wrapper.find(LastEventTime).html()).toContain(getEmptyValue());
});
});

View file

@ -97,5 +97,55 @@ exports[`SessionsView renders correctly against snapshot 1`] = `
</div>
</div>
</div>
.c0 {
position: fixed;
bottom: 60px;
left: 20px;
height: 50vh;
width: 48vw;
max-width: 90vw;
}
.c0.is-hidden {
display: none;
}
.c0.is-confirming .modal-content {
opacity: 0.3;
}
.c0 .console-holder {
height: 100%;
}
.c0 .terminate-confirm-panel {
max-width: 85%;
-webkit-box-flex: 0;
-webkit-flex-grow: 0;
-ms-flex-positive: 0;
flex-grow: 0;
}
<div
class="c0 euiModal euiModal--maxWidth-default is-hidden"
data-test-subj="consolePopupWrapper"
>
<div
class="euiModal__flex modal-content"
>
<div
class="euiModalBody"
data-test-subj="consolePopupBody"
>
<div
class="euiModalBody__overflow"
>
<div
class="console-holder"
/>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -13,7 +13,15 @@ import { AppDeepLink } from '@kbn/core/public';
import { QueryClient, QueryClientProvider, setLogger } from 'react-query';
import { coreMock } from '@kbn/core/public/mocks';
import { PLUGIN_ID } from '@kbn/fleet-plugin/common';
import { StartPlugins, StartServices } from '../../../types';
import {
renderHook as reactRenderHoook,
RenderHookOptions,
RenderHookResult,
} from '@testing-library/react-hooks';
import { ReactHooksRenderer, WrapperComponent } from '@testing-library/react-hooks/src/types/react';
import type { UseBaseQueryResult } from 'react-query/types/react/types';
import { ConsoleManager } from '../../../management/components/console';
import type { StartPlugins, StartServices } from '../../../types';
import { depsStartMock } from './dependencies_start_mock';
import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils';
import { kibanaObservable } from '../test_providers';
@ -30,6 +38,44 @@ import { fleetGetPackageListHttpMock } from '../../../management/pages/mocks';
export type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
/**
* Have the renderer wait for one of the ReactQuery state flag properties. Default is `isSuccess`.
* To disable this `await`, the value `false` can be used.
*/
export type WaitForReactHookState =
| keyof Pick<
UseBaseQueryResult,
| 'isSuccess'
| 'isLoading'
| 'isError'
| 'isIdle'
| 'isLoadingError'
| 'isStale'
| 'isFetched'
| 'isFetching'
| 'isRefetching'
>
| false;
type HookRendererFunction<TProps, TResult> = (props: TProps) => TResult;
/**
* A utility renderer for hooks that return React Query results
*/
export type ReactQueryHookRenderer<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TProps = any,
TResult extends UseBaseQueryResult = UseBaseQueryResult
> = (
hookFn: HookRendererFunction<TProps, TResult>,
/**
* If defined (default is `isSuccess`), the renderer will wait for the given react
* query response state value to be true
*/
waitForHook?: WaitForReactHookState,
options?: RenderHookOptions<TProps>
) => Promise<TResult>;
// hide react-query output in console
setLogger({
error: () => {},
@ -61,6 +107,16 @@ export interface AppContextTestRender {
*/
render: UiRender;
/**
* Renders a hook within a mocked security solution app context
*/
renderHook: ReactHooksRenderer['renderHook'];
/**
* A helper utility for rendering specifically hooks that wrap ReactQuery
*/
renderReactQueryHook: ReactQueryHookRenderer;
/**
* Set technical preview features on/off. Calling this method updates the Store with the new values
* for the given feature flags
@ -136,7 +192,9 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => (
<KibanaContextProvider services={startServices}>
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
<ConsoleManager>{children}</ConsoleManager>
</QueryClientProvider>
</AppRootProvider>
</KibanaContextProvider>
);
@ -148,6 +206,35 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
});
};
const renderHook: ReactHooksRenderer['renderHook'] = <TProps, TResult>(
hookFn: HookRendererFunction<TProps, TResult>,
options: RenderHookOptions<TProps> = {}
): RenderHookResult<TProps, TResult> => {
return reactRenderHoook<TProps, TResult>(hookFn, {
wrapper: AppWrapper as WrapperComponent<TProps>,
...options,
});
};
const renderReactQueryHook: ReactQueryHookRenderer = async <
TProps,
TResult extends UseBaseQueryResult = UseBaseQueryResult
>(
hookFn: HookRendererFunction<TProps, TResult>,
waitForHook: WaitForReactHookState = 'isSuccess',
options: RenderHookOptions<TProps> = {}
) => {
const { result: hookResult, waitFor } = renderHook<TProps, TResult>(hookFn, options);
if (waitForHook) {
await waitFor(() => {
return hookResult.current[waitForHook];
});
}
return hookResult.current;
};
const setExperimentalFlag: AppContextTestRender['setExperimentalFlag'] = (flags) => {
store.dispatch({
type: UpdateExperimentalFeaturesTestActionType,
@ -181,6 +268,8 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
middlewareSpy,
AppWrapper,
render,
renderHook,
renderReactQueryHook,
setExperimentalFlag,
};
};

View file

@ -92,6 +92,7 @@ interface RouteMock<R extends ResponseProvidersInterface = ResponseProvidersInte
*/
id: keyof R;
method: HttpMethods;
/** The API path to match on. This value could can have tokens in the format of `{token_name}` */
path: string;
/**
* The handler for providing a response to for this API call.

View file

@ -8,77 +8,77 @@
import { Ecs } from '../../../common/ecs';
import { TimelineNonEcsData } from '../../../common/search_strategy';
export const mockEcsDataWithAlert: Ecs = {
_id: '1',
timestamp: '2018-11-05T19:03:25.937Z',
host: {
name: ['apache'],
ip: ['192.168.0.1'],
},
event: {
id: ['1'],
action: ['Action'],
category: ['Access'],
module: ['nginx'],
severity: [3],
},
source: {
ip: ['192.168.0.1'],
port: [80],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
user: {
id: ['1'],
name: ['john.dee'],
},
geo: {
region_name: ['xx'],
country_iso_code: ['xx'],
},
signal: {
rule: {
created_at: ['2020-01-10T21:11:45.839Z'],
updated_at: ['2020-01-10T21:11:45.839Z'],
created_by: ['elastic'],
description: ['24/7'],
enabled: [true],
false_positives: ['test-1'],
filters: [],
from: ['now-300s'],
id: ['b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
immutable: [false],
index: ['auditbeat-*'],
interval: ['5m'],
rule_id: ['rule-id-1'],
language: ['kuery'],
output_index: ['.siem-signals-default'],
max_signals: [100],
risk_score: ['21'],
query: ['user.name: root or user.name: admin'],
references: ['www.test.co'],
saved_id: ["Garrett's IP"],
timeline_id: ['1234-2136-11ea-9864-ebc8cc1cb8c2'],
timeline_title: ['Untitled timeline'],
severity: ['low'],
updated_by: ['elastic'],
tags: [],
to: ['now'],
type: ['saved_query'],
threat: [],
note: ['# this is some markdown documentation'],
version: ['1'],
export const getDetectionAlertMock = (overrides: Partial<Ecs> = {}): Ecs => ({
...{
_id: '1',
timestamp: '2018-11-05T19:03:25.937Z',
host: {
name: ['apache'],
ip: ['192.168.0.1'],
},
event: {
id: ['1'],
action: ['Action'],
category: ['Access'],
module: ['nginx'],
severity: [3],
},
source: {
ip: ['192.168.0.1'],
port: [80],
},
destination: {
ip: ['192.168.0.3'],
port: [6343],
},
user: {
id: ['1'],
name: ['john.dee'],
},
geo: {
region_name: ['xx'],
country_iso_code: ['xx'],
},
signal: {
rule: {
created_at: ['2020-01-10T21:11:45.839Z'],
updated_at: ['2020-01-10T21:11:45.839Z'],
created_by: ['elastic'],
description: ['24/7'],
enabled: [true],
false_positives: ['test-1'],
filters: [],
from: ['now-300s'],
id: ['b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
immutable: [false],
index: ['auditbeat-*'],
interval: ['5m'],
rule_id: ['rule-id-1'],
language: ['kuery'],
output_index: ['.siem-signals-default'],
max_signals: [100],
risk_score: ['21'],
query: ['user.name: root or user.name: admin'],
references: ['www.test.co'],
saved_id: ["Garrett's IP"],
timeline_id: ['1234-2136-11ea-9864-ebc8cc1cb8c2'],
timeline_title: ['Untitled timeline'],
severity: ['low'],
updated_by: ['elastic'],
tags: [],
to: ['now'],
type: ['saved_query'],
threat: [],
note: ['# this is some markdown documentation'],
version: ['1'],
},
},
},
};
export const getDetectionAlertMock = (overrides: Partial<Ecs> = {}): Ecs => ({
...mockEcsDataWithAlert,
...overrides,
});
export const mockEcsDataWithAlert: Ecs = getDetectionAlertMock();
export const getThreatMatchDetectionAlert = (overrides: Partial<Ecs> = {}): Ecs => ({
...mockEcsDataWithAlert,
signal: {

View file

@ -17,6 +17,7 @@ import { ThemeProvider } from 'styled-components';
import { Capabilities } from '@kbn/core/public';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ConsoleManager } from '../../management/components/console';
import { createStore, State } from '../store';
import { mockGlobalState } from './global_state';
import {
@ -59,7 +60,9 @@ export const TestProvidersComponent: React.FC<Props> = ({
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<QueryClientProvider client={queryClient}>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
<ConsoleManager>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</ConsoleManager>
</QueryClientProvider>
</ThemeProvider>
</ReduxStoreProvider>

View file

@ -9,44 +9,10 @@ import { mount } from 'enzyme';
import React from 'react';
import { HostsTableType } from '../../../hosts/store/model';
import { RouteSpyState } from './types';
import { ManageRoutesSpy } from './manage_spy_routes';
import { SpyRouteComponent } from './spy_routes';
import { useRouteSpy } from './use_route_spy';
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
const defaultLocation = {
hash: '',
pathname: '/hosts',
search: '',
state: '',
};
export const mockHistory = {
action: pop,
block: jest.fn(),
createHref: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
length: 2,
listen: jest.fn(),
location: defaultLocation,
push: jest.fn(),
replace: jest.fn(),
};
const dispatchMock = jest.fn();
const mockRoutes: RouteSpyState = {
pageName: '',
detailName: undefined,
tabName: undefined,
search: '',
pathName: '/',
history: mockHistory,
};
import { generateHistoryMock, generateRoutesMock } from './mocks';
const mockUseRouteSpy: jest.Mock = useRouteSpy as jest.Mock;
jest.mock('./use_route_spy', () => ({
@ -54,19 +20,25 @@ jest.mock('./use_route_spy', () => ({
}));
describe('Spy Routes', () => {
let mockRoutes: ReturnType<typeof generateRoutesMock>;
let mockHistoryValue: ReturnType<typeof generateHistoryMock>;
let dispatchMock: jest.Mock;
beforeEach(() => {
mockRoutes = generateRoutesMock();
mockHistoryValue = generateHistoryMock();
dispatchMock = jest.fn();
mockUseRouteSpy.mockImplementation(() => [mockRoutes, dispatchMock]);
});
describe('At Initialization of the app', () => {
beforeEach(() => {
dispatchMock.mockReset();
dispatchMock.mockClear();
});
test('Make sure we update search state first', () => {
test('Make sure we update search state first', async () => {
const pathname = '/';
mockUseRouteSpy.mockImplementation(() => [mockRoutes, dispatchMock]);
mount(
<ManageRoutesSpy>
<SpyRouteComponent
location={{ hash: '', pathname, search: '?importantQueryString="really"', state: '' }}
history={mockHistory}
history={mockHistoryValue}
match={{
isExact: false,
path: pathname,
@ -93,12 +65,11 @@ describe('Spy Routes', () => {
test('Make sure we update search state first and then update the route but keeping the initial search', () => {
const pathname = '/hosts/allHosts';
mockUseRouteSpy.mockImplementation(() => [mockRoutes, dispatchMock]);
mount(
<ManageRoutesSpy>
<SpyRouteComponent
location={{ hash: '', pathname, search: '?importantQueryString="really"', state: '' }}
history={mockHistory}
history={mockHistoryValue}
match={{
isExact: false,
path: pathname,
@ -127,7 +98,7 @@ describe('Spy Routes', () => {
route: {
pageName: 'hosts',
detailName: undefined,
history: mockHistory,
history: mockHistoryValue,
pathName: pathname,
tabName: HostsTableType.hosts,
},
@ -138,18 +109,13 @@ describe('Spy Routes', () => {
});
describe('When app is running', () => {
beforeEach(() => {
dispatchMock.mockReset();
dispatchMock.mockClear();
});
test('Update route should be updated when there is changed detected', () => {
const pathname = '/hosts/allHosts';
const newPathname = `hosts/${HostsTableType.authentications}`;
mockUseRouteSpy.mockImplementation(() => [mockRoutes, dispatchMock]);
const wrapper = mount(
<SpyRouteComponent
location={{ hash: '', pathname, search: '?importantQueryString="really"', state: '' }}
history={mockHistory}
history={mockHistoryValue}
match={{
isExact: false,
path: pathname,
@ -195,7 +161,7 @@ describe('Spy Routes', () => {
{
route: {
detailName: undefined,
history: mockHistory,
history: mockHistoryValue,
pageName: 'hosts',
pathName: newPathname,
tabName: HostsTableType.authentications,

View file

@ -0,0 +1,44 @@
/*
* 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 { RouteSpyState } from './types';
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
const generateDefaultLocationMock = () => ({
hash: '',
pathname: '/hosts',
search: '',
state: '',
});
export const generateHistoryMock = () => ({
action: pop,
block: jest.fn(),
createHref: jest.fn(),
go: jest.fn(),
goBack: jest.fn(),
goForward: jest.fn(),
length: 2,
listen: jest.fn(),
location: generateDefaultLocationMock(),
push: jest.fn(),
replace: jest.fn(),
});
export const mockHistory = generateHistoryMock();
export const generateRoutesMock = (): RouteSpyState => ({
pageName: '',
detailName: undefined,
tabName: undefined,
search: '',
pathName: '/',
history: mockHistory,
});

View file

@ -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 { useResponseActionsConsoleActionItem } from './use_response_actions_console_action_item';

View file

@ -0,0 +1,89 @@
/*
* 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 { EuiContextMenuItem } from '@elastic/eui';
import React, { memo, ReactNode, useCallback, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
useGetEndpointDetails,
useShowEndpointResponseActionsConsole,
} from '../../../management/hooks';
import { HostStatus } from '../../../../common/endpoint/types';
export const NOT_FROM_ENDPOINT_HOST_TOOLTIP = i18n.translate(
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.notSupportedTooltip',
{ defaultMessage: 'The current item does not support endpoint response actions' }
);
export const HOST_ENDPOINT_UNENROLLED_TOOLTIP = i18n.translate(
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.unenrolledTooltip',
{ defaultMessage: 'Host is no longer enrolled with endpoint security' }
);
export const LOADING_ENDPOINT_DATA_TOOLTIP = i18n.translate(
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.loadingTooltip',
{ defaultMessage: 'Loading' }
);
export interface ResponseActionsConsoleContextMenuItemProps {
endpointId: string;
onClick?: () => void;
}
export const ResponseActionsConsoleContextMenuItem =
memo<ResponseActionsConsoleContextMenuItemProps>(({ endpointId, onClick }) => {
const showEndpointResponseActionsConsole = useShowEndpointResponseActionsConsole();
const {
data: endpointHostInfo,
isFetching,
error,
} = useGetEndpointDetails(endpointId, { enabled: Boolean(endpointId) });
const [isDisabled, tooltip]: [disabled: boolean, tooltip: ReactNode] = useMemo(() => {
if (!endpointId) {
return [true, NOT_FROM_ENDPOINT_HOST_TOOLTIP];
}
// Still loading Endpoint host info
if (isFetching) {
return [true, LOADING_ENDPOINT_DATA_TOOLTIP];
}
// if we got an error and it's a 404 (alerts can exist for endpoint that are no longer around)
// or,
// the Host status is `unenrolled`
if (
(error && error.body.statusCode === 404) ||
endpointHostInfo?.host_status === HostStatus.UNENROLLED
) {
return [true, HOST_ENDPOINT_UNENROLLED_TOOLTIP];
}
return [false, undefined];
}, [endpointHostInfo?.host_status, endpointId, error, isFetching]);
const handleResponseActionsClick = useCallback(() => {
if (endpointHostInfo) showEndpointResponseActionsConsole(endpointHostInfo.metadata);
if (onClick) onClick();
}, [endpointHostInfo, onClick, showEndpointResponseActionsConsole]);
return (
<EuiContextMenuItem
key="endpointResponseActions-action-item"
data-test-subj="endpointResponseActions-action-item"
disabled={isDisabled}
toolTipContent={tooltip}
size="s"
onClick={handleResponseActionsClick}
>
<FormattedMessage
id="xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.buttonLabel"
defaultMessage="Launch responder"
/>
</EuiContextMenuItem>
);
});
ResponseActionsConsoleContextMenuItem.displayName = 'ResponseActionsConsoleContextMenuItem';

View file

@ -0,0 +1,56 @@
/*
* 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, { useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { isAlertFromEndpointEvent } from '../../../common/utils/endpoint_alert_check';
import { ResponseActionsConsoleContextMenuItem } from './response_actions_console_context_menu_item';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { getFieldValue } from '../host_isolation/helpers';
export const useResponseActionsConsoleActionItem = (
eventDetailsData: TimelineEventsDetailsItem[] | null,
onClick: () => void
): JSX.Element[] => {
const isResponseActionsConsoleEnabled = useIsExperimentalFeatureEnabled(
'responseActionsConsoleEnabled'
);
const { loading: isAuthzLoading, canAccessEndpointManagement } =
useUserPrivileges().endpointPrivileges;
const isEndpointAlert = useMemo(() => {
return isAlertFromEndpointEvent({ data: eventDetailsData || [] });
}, [eventDetailsData]);
const endpointId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, eventDetailsData),
[eventDetailsData]
);
return useMemo(() => {
const actions: JSX.Element[] = [];
if (isResponseActionsConsoleEnabled && !isAuthzLoading && canAccessEndpointManagement) {
actions.push(
<ResponseActionsConsoleContextMenuItem
endpointId={isEndpointAlert ? endpointId : ''}
onClick={onClick}
/>
);
}
return actions;
}, [
canAccessEndpointManagement,
endpointId,
isAuthzLoading,
isEndpointAlert,
isResponseActionsConsoleEnabled,
onClick,
]);
};

View file

@ -9,27 +9,40 @@ import { mount, ReactWrapper } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { TakeActionDropdown, TakeActionDropdownProps } from '.';
import { mockAlertDetailsData } from '../../../common/components/event_details/__mocks__';
import { mockEcsDataWithAlert } from '../../../common/mock/mock_detection_alerts';
import { generateAlertDetailsDataMock } from '../../../common/components/event_details/__mocks__';
import { getDetectionAlertMock } from '../../../common/mock/mock_detection_alerts';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { TimelineId } from '../../../../common/types';
import { TestProviders } from '../../../common/mock';
import { mockTimelines } from '../../../common/mock/mock_timelines_plugin';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { useKibana } from '../../../common/lib/kibana';
import { useKibana, useGetUserCasesPermissions, useHttp } from '../../../common/lib/kibana';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../../../common/components/user_privileges/user_privileges_context';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import {
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
} from '../response_actions_console/response_actions_console_context_menu_item';
import { endpointMetadataHttpMocks } from '../../../management/pages/endpoint_hosts/mocks';
import { HttpSetup } from '@kbn/core/public';
import {
isAlertFromEndpointEvent,
isAlertFromEndpointAlert,
} from '../../../common/utils/endpoint_alert_check';
import { HostStatus } from '../../../../common/endpoint/types';
import { getUserPrivilegesMockDefaultValue } from '../../../common/components/user_privileges/__mocks__';
jest.mock('../../../common/components/user_privileges');
jest.mock('../user_info', () => ({
useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]),
}));
jest.mock('../../../common/lib/kibana', () => ({
useKibana: jest.fn(),
useGetUserCasesPermissions: jest.fn().mockReturnValue({ crud: true }),
}));
jest.mock('../../../common/lib/kibana');
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({ crud: true });
jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () => ({
useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }),
}));
@ -71,26 +84,29 @@ jest.mock('../../containers/detection_engine/alerts/use_host_isolation_status',
jest.mock('../../../common/components/user_privileges');
describe('take action dropdown', () => {
const defaultProps: TakeActionDropdownProps = {
detailsData: mockAlertDetailsData as TimelineEventsDetailsItem[],
ecsData: mockEcsDataWithAlert,
handleOnEventClosed: jest.fn(),
indexName: 'index',
isHostIsolationPanelOpen: false,
loadingEventDetails: false,
onAddEventFilterClick: jest.fn(),
onAddExceptionTypeClick: jest.fn(),
onAddIsolationStatusClick: jest.fn(),
refetch: jest.fn(),
refetchFlyoutData: jest.fn(),
timelineId: TimelineId.active,
onOsqueryClick: jest.fn(),
};
let defaultProps: TakeActionDropdownProps;
let mockStartServicesMock: ReturnType<typeof createStartServicesMock>;
beforeEach(() => {
defaultProps = {
detailsData: generateAlertDetailsDataMock() as TimelineEventsDetailsItem[],
ecsData: getDetectionAlertMock(),
handleOnEventClosed: jest.fn(),
indexName: 'index',
isHostIsolationPanelOpen: false,
loadingEventDetails: false,
onAddEventFilterClick: jest.fn(),
onAddExceptionTypeClick: jest.fn(),
onAddIsolationStatusClick: jest.fn(),
refetch: jest.fn(),
refetchFlyoutData: jest.fn(),
timelineId: TimelineId.active,
onOsqueryClick: jest.fn(),
};
mockStartServicesMock = createStartServicesMock();
beforeAll(() => {
(useKibana as jest.Mock).mockImplementation(() => {
const mockStartServicesMock = createStartServicesMock();
return {
services: {
...mockStartServicesMock,
@ -105,6 +121,12 @@ describe('take action dropdown', () => {
},
};
});
(useHttp as jest.Mock).mockReturnValue(mockStartServicesMock.http);
});
afterEach(() => {
(useUserPrivileges as jest.Mock).mockReturnValue(getUserPrivilegesMockDefaultValue());
});
test('should render takeActionButton', () => {
@ -128,14 +150,12 @@ describe('take action dropdown', () => {
});
describe('should render take action items', () => {
const testProps = {
...defaultProps,
};
let wrapper: ReactWrapper;
beforeAll(() => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...testProps} />
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
@ -207,88 +227,247 @@ describe('take action dropdown', () => {
);
});
});
test('should render "Launch responder"', async () => {
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="endpointResponseActions-action-item"]').first().text()
).toEqual('Launch responder');
});
});
});
describe('should correctly enable/disable the "Add Endpoint event filter" button', () => {
let wrapper: ReactWrapper;
describe('for Endpoint related actions', () => {
/** Removes the detail data that is used to determine if data is for an Alert */
const setAlertDetailsDataMockToEvent = () => {
if (defaultProps.detailsData) {
defaultProps.detailsData = defaultProps.detailsData
.map((obj) => {
if (obj.field === 'kibana.alert.rule.uuid') {
return null;
}
if (obj.field === 'event.kind') {
return {
category: 'event',
field: 'event.kind',
values: ['event'],
originalValue: 'event',
};
}
return obj;
})
.filter((obj) => obj) as TimelineEventsDetailsItem[];
} else {
expect(defaultProps.detailsData).toBeInstanceOf(Object);
}
};
const getEcsDataWithAgentType = (agentType: string) => ({
...mockEcsDataWithAlert,
agent: {
type: [agentType],
},
const setAlertDetailsDataMockToEndpointAgent = () => {
if (defaultProps.detailsData) {
defaultProps.detailsData = defaultProps.detailsData.map((obj) => {
if (obj.field === 'agent.type') {
return {
category: 'agent',
field: 'agent.type',
values: ['endpoint'],
originalValue: ['endpoint'],
};
}
if (obj.field === 'agent.id') {
return {
category: 'agent',
field: 'agent.id',
values: ['123'],
originalValue: ['123'],
};
}
return obj;
}) as TimelineEventsDetailsItem[];
} else {
expect(defaultProps.detailsData).toBeInstanceOf(Object);
}
};
/** Set the `agent.type` and `agent.id` on the EcsData */
const setTypeOnEcsDataWithAgentType = (
agentType: string = 'endpoint',
agentId: string = '123'
) => {
if (defaultProps.ecsData) {
defaultProps.ecsData.agent = {
// @ts-expect-error Ecs definition for agent seems to be missing properties
id: agentId,
type: [agentType],
};
} else {
expect(defaultProps.ecsData).toBeInstanceOf(Object);
}
};
describe('should correctly enable/disable the "Add Endpoint event filter" button', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
setTypeOnEcsDataWithAgentType();
setAlertDetailsDataMockToEvent();
});
test('should enable the "Add Endpoint event filter" button if provided endpoint event', async () => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode()
).toBeEnabled();
});
});
test('should disable the "Add Endpoint event filter" button if no endpoint management privileges', async () => {
(useUserPrivileges as jest.Mock).mockReturnValue({
...mockInitialUserPrivilegesState(),
endpointPrivileges: { loading: false, canAccessEndpointManagement: false },
});
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode()
).toBeDisabled();
});
});
test('should hide the "Add Endpoint event filter" button if provided no event from endpoint', async () => {
setTypeOnEcsDataWithAgentType('filebeat');
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
await waitFor(() => {
expect(wrapper.exists('[data-test-subj="add-event-filter-menu-item"]')).toBeFalsy();
});
});
});
const modifiedMockDetailsData = mockAlertDetailsData
.map((obj) => {
if (obj.field === 'kibana.alert.rule.uuid') {
return null;
describe('should correctly enable/disable the "Launch responder" button', () => {
let wrapper: ReactWrapper;
let apiMocks: ReturnType<typeof endpointMetadataHttpMocks>;
const render = (): ReactWrapper => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
return wrapper;
};
const findLaunchResponderButton = (): ReturnType<typeof wrapper.find> => {
return wrapper.find('[data-test-subj="endpointResponseActions-action-item"]');
};
beforeAll(() => {
// Un-Mock endpoint alert check hooks
const actualChecks = jest.requireActual('../../../common/utils/endpoint_alert_check');
(isAlertFromEndpointEvent as jest.Mock).mockImplementation(
actualChecks.isAlertFromEndpointEvent
);
(isAlertFromEndpointAlert as jest.Mock).mockImplementation(
actualChecks.isAlertFromEndpointAlert
);
});
afterAll(() => {
// Set the mock modules back to what they were
(isAlertFromEndpointEvent as jest.Mock).mockImplementation(() => true);
(isAlertFromEndpointAlert as jest.Mock).mockImplementation(() => true);
});
beforeEach(() => {
setTypeOnEcsDataWithAgentType();
apiMocks = endpointMetadataHttpMocks(mockStartServicesMock.http as jest.Mocked<HttpSetup>);
});
describe('when the `responseActionsConsoleEnabled` feature flag is false', () => {
beforeAll(() => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((featureKey) => {
if (featureKey === 'responseActionsConsoleEnabled') {
return false;
}
return true;
});
});
afterAll(() => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => true);
});
it('should hide the button if feature flag if off', async () => {
render();
expect(findLaunchResponderButton()).toHaveLength(0);
});
});
it('should not display the button if user is not allowed to manage endpoints', async () => {
(useUserPrivileges as jest.Mock).mockReturnValue({
...mockInitialUserPrivilegesState(),
endpointPrivileges: { loading: false, canAccessEndpointManagement: false },
});
render();
expect(findLaunchResponderButton()).toHaveLength(0);
});
it('should disable the button if alert NOT from a host running endpoint', async () => {
setTypeOnEcsDataWithAgentType('filebeat');
if (defaultProps.detailsData) {
defaultProps.detailsData = generateAlertDetailsDataMock() as TimelineEventsDetailsItem[];
}
if (obj.field === 'event.kind') {
return {
category: 'event',
field: 'event.kind',
values: ['event'],
originalValue: 'event',
};
}
return obj;
})
.filter((obj) => obj) as TimelineEventsDetailsItem[];
render();
test('should enable the "Add Endpoint event filter" button if provided endpoint event', async () => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown
{...defaultProps}
detailsData={modifiedMockDetailsData}
ecsData={getEcsDataWithAgentType('endpoint')}
/>
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode()
).toBeEnabled();
});
});
const consoleButton = findLaunchResponderButton().first();
test('should disable the "Add Endpoint event filter" button if no endpoint management privileges', async () => {
(useUserPrivileges as jest.Mock).mockReturnValue({
...mockInitialUserPrivilegesState(),
endpointPrivileges: { loading: false, canAccessEndpointManagement: false },
expect(consoleButton.prop('disabled')).toBe(true);
expect(consoleButton.prop('toolTipContent')).toEqual(NOT_FROM_ENDPOINT_HOST_TOOLTIP);
});
wrapper = mount(
<TestProviders>
<TakeActionDropdown
{...defaultProps}
detailsData={modifiedMockDetailsData}
ecsData={getEcsDataWithAgentType('endpoint')}
/>
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode()
).toBeDisabled();
});
});
test('should hide the "Add Endpoint event filter" button if provided no event from endpoint', async () => {
wrapper = mount(
<TestProviders>
<TakeActionDropdown
{...defaultProps}
detailsData={modifiedMockDetailsData}
ecsData={getEcsDataWithAgentType('filesbeat')}
/>
</TestProviders>
);
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
await waitFor(() => {
expect(wrapper.exists('[data-test-subj="add-event-filter-menu-item"]')).toBeFalsy();
it('should disable the button if host status is unenrolled', async () => {
setAlertDetailsDataMockToEndpointAgent();
const getApiResponse = apiMocks.responseProvider.metadataDetails.getMockImplementation();
apiMocks.responseProvider.metadataDetails.mockImplementation(() => {
if (getApiResponse) {
return {
...getApiResponse(),
host_status: HostStatus.UNENROLLED,
};
}
throw new Error('mock implementation missing');
});
render();
await waitFor(() => {
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalled();
});
wrapper.update();
expect(findLaunchResponderButton().first().prop('disabled')).toBe(true);
expect(findLaunchResponderButton().first().prop('toolTipContent')).toEqual(
HOST_ENDPOINT_UNENROLLED_TOOLTIP
);
});
});
});

View file

@ -8,6 +8,7 @@
import React, { useCallback, useMemo, useState } from 'react';
import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
import { useResponseActionsConsoleActionItem } from '../response_actions_console';
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations';
import { useExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions';
@ -135,6 +136,11 @@ export const TakeActionDropdown = React.memo(
isHostIsolationPanelOpen,
});
const endpointResponseActionsConsoleItems = useResponseActionsConsoleActionItem(
detailsData,
closePopoverHandler
);
const handleOnAddExceptionTypeClick = useCallback(
(type: ExceptionListType) => {
onAddExceptionTypeClick(type);
@ -223,6 +229,7 @@ export const TakeActionDropdown = React.memo(
...(tGridEnabled ? addToCaseActionItems : []),
...alertsActionItems,
...hostIsolationActionItems,
...endpointResponseActionsConsoleItems,
...(osqueryAvailable ? [osqueryActionItem] : []),
...investigateInTimelineActionItems,
],
@ -231,6 +238,7 @@ export const TakeActionDropdown = React.memo(
addToCaseActionItems,
alertsActionItems,
hostIsolationActionItems,
endpointResponseActionsConsoleItems,
osqueryAvailable,
osqueryActionItem,
investigateInTimelineActionItems,

View file

@ -9,7 +9,6 @@ import React from 'react';
import { mount } from 'enzyme';
import { TestProviders } from '../../../../../../common/mock';
import { mockHistory } from '../../../../../../common/utils/route/index.test';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
import { useUserData } from '../../../../../components/user_info';
@ -17,6 +16,7 @@ import { ExceptionListsTable } from './exceptions_table';
import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks';
import { useAllExceptionLists } from './use_all_exception_lists';
import { useHistory } from 'react-router-dom';
import { generateHistoryMock } from '../../../../../../common/utils/route/mocks';
jest.mock('../../../../../components/user_info');
jest.mock('../../../../../../common/lib/kibana');
@ -44,6 +44,7 @@ jest.mock('../../../../../containers/detection_engine/lists/use_lists_config', (
}));
describe('ExceptionListsTable', () => {
const mockHistory = generateHistoryMock();
const exceptionList1 = getExceptionListSchemaMock();
const exceptionList2 = { ...getExceptionListSchemaMock(), list_id: 'not_endpoint_list', id: '2' };

View file

@ -7,7 +7,7 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { render as _render, waitFor } from '@testing-library/react';
import { useFirstLastSeenHost } from '../../containers/hosts/first_last_seen';
import { TestProviders } from '../../../common/mock';
@ -26,38 +26,46 @@ describe('FirstLastSeen Component', () => {
const firstSeen = 'Apr 8, 2019 @ 16:09:40.692';
const lastSeen = 'Apr 8, 2019 @ 18:35:45.064';
let render: (ui: React.ReactElement) => ReturnType<typeof _render>;
beforeEach(() => {
render = (ui: React.ReactElement): ReturnType<typeof _render> => {
return _render(
<TestProviders>
<div data-test-subj="test-render-output">{ui}</div>
</TestProviders>
);
};
});
test('Loading', async () => {
useFirstLastSeenHostMock.mockReturnValue([true, MOCKED_RESPONSE]);
const { container } = render(
<TestProviders>
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.FIRST_SEEN}
/>
</TestProviders>
const { getByTestId } = render(
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.FIRST_SEEN}
/>
);
expect(container.innerHTML).toBe(
expect(getByTestId('test-render-output').innerHTML).toBe(
'<span class="euiLoadingSpinner euiLoadingSpinner--medium"></span>'
);
});
test('First Seen', async () => {
useFirstLastSeenHostMock.mockReturnValue([false, MOCKED_RESPONSE]);
const { container } = render(
<TestProviders>
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.FIRST_SEEN}
/>
</TestProviders>
const { getByTestId } = render(
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.FIRST_SEEN}
/>
);
await waitFor(() => {
expect(container.innerHTML).toBe(
expect(getByTestId('test-render-output').innerHTML).toBe(
`<div class="euiText euiText--small"><span class="euiToolTipAnchor">${firstSeen}</span></div>`
);
});
@ -65,18 +73,16 @@ describe('FirstLastSeen Component', () => {
test('Last Seen', async () => {
useFirstLastSeenHostMock.mockReturnValue([false, MOCKED_RESPONSE]);
const { container } = render(
<TestProviders>
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.LAST_SEEN}
/>
</TestProviders>
const { getByTestId } = render(
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.LAST_SEEN}
/>
);
await waitFor(() => {
expect(container.innerHTML).toBe(
expect(getByTestId('test-render-output').innerHTML).toBe(
`<div class="euiText euiText--small"><span class="euiToolTipAnchor">${lastSeen}</span></div>`
);
});
@ -90,19 +96,17 @@ describe('FirstLastSeen Component', () => {
firstSeen: null,
},
]);
const { container } = render(
<TestProviders>
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.LAST_SEEN}
/>
</TestProviders>
const { getByTestId } = render(
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.LAST_SEEN}
/>
);
await waitFor(() => {
expect(container.innerHTML).toBe(
expect(getByTestId('test-render-output').innerHTML).toBe(
`<div class="euiText euiText--small"><span class="euiToolTipAnchor">${lastSeen}</span></div>`
);
});
@ -116,19 +120,17 @@ describe('FirstLastSeen Component', () => {
lastSeen: null,
},
]);
const { container } = render(
<TestProviders>
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.FIRST_SEEN}
/>
</TestProviders>
const { getByTestId } = render(
<FirstLastSeenHost
docValueFields={[]}
indexNames={[]}
hostName="kibana-siem"
type={FirstLastSeenHostType.FIRST_SEEN}
/>
);
await waitFor(() => {
expect(container.innerHTML).toBe(
expect(getByTestId('test-render-output').innerHTML).toBe(
`<div class="euiText euiText--small"><span class="euiToolTipAnchor">${firstSeen}</span></div>`
);
});

View file

@ -1,17 +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 React, { memo } from 'react';
import { EuiCallOut } from '@elastic/eui';
export interface CommandExecutionFailureProps {
error: Error;
}
export const CommandExecutionFailure = memo<CommandExecutionFailureProps>(({ error }) => {
return <EuiCallOut>{error}</EuiCallOut>;
});
CommandExecutionFailure.displayName = 'CommandExecutionOutput';

View file

@ -8,6 +8,7 @@
import React, { memo, useCallback, useMemo } from 'react';
import { EuiLoadingChart } from '@elastic/eui';
import styled from 'styled-components';
import type { CommandExecutionComponentProps } from '../types';
import type { CommandExecutionState, CommandHistoryItem } from './console_state/types';
import { UserCommandInput } from './user_command_input';
import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch';
@ -43,13 +44,13 @@ export const CommandExecutionOutput = memo<CommandExecutionOutputProps>(
);
/** Updates the Command's execution store */
const setCommandStore = useCallback(
(store) => {
const setCommandStore: CommandExecutionComponentProps['setStore'] = useCallback(
(updateStoreFn) => {
dispatch({
type: 'updateCommandStoreState',
payload: {
id,
value: store,
value: updateStoreFn,
},
});
},

View file

@ -22,11 +22,11 @@ import { ConfirmTerminate } from './confirm_terminate';
const ConsolePopupWrapper = styled.div`
position: fixed;
top: 100px;
right: 0;
min-height: 60vh;
min-width: 40vw;
max-width: 70vw;
bottom: 60px;
left: 20px;
height: 50vh;
width: 48vw;
max-width: 90vw;
&.is-hidden {
display: none;

View file

@ -248,11 +248,7 @@ describe('When using ConsoleManager', () => {
const mockedContext = createAppRootMockRenderer();
render = async () => {
renderResult = mockedContext.render(
<ConsoleManager>
<ConsoleManagerTestComponent />
</ConsoleManager>
);
renderResult = mockedContext.render(<ConsoleManagerTestComponent />);
clickOnRegisterNewConsole();

View file

@ -5,11 +5,17 @@
* 2.0.
*/
/* eslint-disable import/no-extraneous-dependencies */
import React, { memo, useCallback } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { ConsoleRegistrationInterface, RegisteredConsoleClient } from './types';
import { useConsoleManager } from './console_manager';
import { act } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react';
import { AppContextTestRender } from '../../../../../common/mock/endpoint';
import { getCommandListMock } from '../../mocks';
import { useConsoleManager } from './console_manager';
import { ConsoleRegistrationInterface, RegisteredConsoleClient } from './types';
export const getNewConsoleRegistrationMock = (
overrides: Partial<ConsoleRegistrationInterface> = {}
@ -27,6 +33,65 @@ export const getNewConsoleRegistrationMock = (
};
};
/**
* A set of queries and user action helper methods for interacting with the ConsoleManager test component
* and ConsoleManager
* @param renderResult
*/
export const getConsoleManagerMockRenderResultQueriesAndActions = (
renderResult: ReturnType<AppContextTestRender['render']>
) => {
return {
/**
* Clicks the button to register a new console. The new console registration is shown in the list of running consoles
*/
clickOnRegisterNewConsole: async () => {
const currentRunningCount = renderResult.queryAllByTestId('showRunningConsole').length;
act(() => {
userEvent.click(renderResult.getByTestId('registerNewConsole'));
});
await waitFor(() => {
expect(renderResult.queryAllByTestId('showRunningConsole')).toHaveLength(
currentRunningCount + 1
);
});
},
/**
* Clicks one of the hidden consoles so that it can be shown.
* @param atIndex
*/
openRunningConsole: async (atIndex: number = 0) => {
act(() => {
userEvent.click(renderResult.queryAllByTestId('showRunningConsole')[atIndex]);
});
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(false);
});
},
hideOpenedConsole: async () => {
const hideConsoleButton = renderResult.queryByTestId('consolePopupHideButton');
if (!hideConsoleButton) {
return;
}
userEvent.click(hideConsoleButton);
await waitFor(() => {
expect(
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
).toBe(true);
});
},
};
};
const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
({ registeredConsole }) => {
const handleShowOnClick = useCallback(() => {
@ -40,7 +105,11 @@ const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
{registeredConsole.title}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={handleShowOnClick} data-test-subj="showRunningConsole">
<EuiButton
onClick={handleShowOnClick}
data-test-subj="showRunningConsole"
data-console-id={registeredConsole.id}
>
{'show'}
</EuiButton>
</EuiFlexItem>
@ -52,17 +121,26 @@ const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
);
RunningConsole.displayName = 'RunningConsole';
export const ConsoleManagerTestComponent = memo(() => {
/**
* A test component that enables one to open consoles managed via `ConsoleManager`.
*/
export const ConsoleManagerTestComponent = memo<{
/**
* A callback that can return the registration (or partial registration) for a new console. These
* will be appended to the output generated by `getNewConsoleRegistrationMock()`
*/
registerConsoleProps?: () => Partial<ConsoleRegistrationInterface>;
}>(({ registerConsoleProps = () => ({}) }) => {
const consoleManager = useConsoleManager();
const handleRegisterNewConsoleOnClick = useCallback(() => {
consoleManager.register(getNewConsoleRegistrationMock());
}, [consoleManager]);
consoleManager.register(getNewConsoleRegistrationMock(registerConsoleProps()));
}, [consoleManager, registerConsoleProps]);
return (
<div>
<div>
<EuiButton data-test-subj="registerNewConsole" onClick={handleRegisterNewConsoleOnClick}>
{'Register and show new managed console'}
{'Register new console'}
</EuiButton>
</div>
<div>

View file

@ -37,7 +37,9 @@ export const handleUpdateCommandState: ConsoleStoreReducer<UpdateCommandStateAct
switch (type) {
case 'updateCommandStoreState':
updatedCommandState.state.store = value as CommandExecutionState['store'];
updatedCommandState.state.store = (
value as (prevState: CommandExecutionState['store']) => CommandExecutionState['store']
)(updatedCommandState.state.store);
break;
case 'updateCommandStatusState':
// If the status was not changed, then there is nothing to be done here, so

View file

@ -44,7 +44,10 @@ export type ConsoleDataAction =
| { type: 'clear' }
| {
type: 'updateCommandStoreState';
payload: { id: string; value: CommandExecutionState['store'] };
payload: {
id: string;
value: (prevState: CommandExecutionState['store']) => CommandExecutionState['store'];
};
}
| {
type: 'updateCommandStatusState';

View file

@ -109,7 +109,9 @@ export const getCommandListMock = (): CommandDefinition[] => {
if (status !== 'success') {
new Promise((r) => setTimeout(r, 500)).then(() => {
setStatus('success');
setStore({ foo: 'bar' });
setStore((prevState) => {
return { foo: 'bar' };
});
});
}
}, [setStatus, setStore, status]);

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import type { ComponentType, ComponentProps } from 'react';
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ComponentType } from 'react';
import type { CommonProps } from '@elastic/eui';
import type { CommandExecutionState } from './components/console_state/types';
import type { Immutable } from '../../../../common/endpoint/types';
import type { Immutable, MaybeImmutable } from '../../../../common/endpoint/types';
import type { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input';
export interface CommandDefinition {
export interface CommandDefinition<TMeta = any> {
name: string;
about: string;
/**
@ -28,7 +30,7 @@ export interface CommandDefinition {
* The entire `CommandDefinition` is passed along to the component
* that will handle it, so this data will be available there
*/
meta?: Record<string, unknown>;
meta?: TMeta;
/** If all args are optional, but at least one must be defined, set to true */
mustHaveArgs?: boolean;
@ -54,21 +56,22 @@ export interface CommandDefinition {
/**
* A command to be executed (as entered by the user)
*/
export interface Command {
export interface Command<TDefinition extends CommandDefinition = CommandDefinition> {
/** The raw input entered by the user */
input: string;
// FIXME:PT this should be a generic that allows for the arguments type to be used
/** An object with the arguments entered by the user and their value */
args: ParsedCommandInput;
/** The command defined associated with this user command */
commandDefinition: CommandDefinition;
/** The command definition associated with this user command */
commandDefinition: TDefinition;
}
/**
* The component that will handle the Command execution and display the result.
*/
export type CommandExecutionComponent = ComponentType<{
command: Command;
export interface CommandExecutionComponentProps<
TStore extends object = Record<string, unknown>,
TMeta = any
> {
command: Command<CommandDefinition<TMeta>>;
/**
* A data store for the command execution to store data in, if needed.
* Because the Console could be closed/opened several times, which will cause this component
@ -76,9 +79,15 @@ export type CommandExecutionComponent = ComponentType<{
* persisting data (ex. API response with IDs) that the command can use to determine
* if the command has already been executed or if it's a new instance.
*/
store: Immutable<CommandExecutionState['store']>;
/** Sets the `store` data above */
setStore: (state: CommandExecutionState['store']) => void;
store: Immutable<Partial<TStore>>;
/**
* Sets the `store` data above. Function will be called the latest (prevState) store data
*/
setStore: (
updateStoreFn: (prevState: Immutable<Partial<TStore>>) => MaybeImmutable<TStore>
) => void;
/**
* The status of the command execution.
* Note that the console's UI will show the command as "busy" while the status here is
@ -86,11 +95,18 @@ export type CommandExecutionComponent = ComponentType<{
* either `success` or `error`.
*/
status: CommandExecutionState['status'];
/** Set the status of the command execution */
setStatus: (status: CommandExecutionState['status']) => void;
}>;
}
export type CommandExecutionComponentProps = ComponentProps<CommandExecutionComponent>;
/**
* The component that will handle the Command execution and display the result.
*/
export type CommandExecutionComponent<
TStore extends object = Record<string, unknown>,
TMeta = any
> = ComponentType<CommandExecutionComponentProps<TStore, TMeta>>;
export interface ConsoleProps extends CommonProps {
/**

View file

@ -0,0 +1,47 @@
/*
* 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 { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint';
import {
EndpointAgentAndIsolationStatus,
EndpointAgentAndIsolationStatusProps,
} from './endpoint_agent_and_isolation_status';
import { HostStatus } from '../../../../common/endpoint/types';
import React from 'react';
describe('When using the EndpointAgentAndIsolationStatus component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<AppContextTestRender['render']>;
let renderProps: EndpointAgentAndIsolationStatusProps;
beforeEach(() => {
const appTestContext = createAppRootMockRenderer();
renderProps = {
status: HostStatus.HEALTHY,
'data-test-subj': 'test',
};
render = () => {
renderResult = appTestContext.render(<EndpointAgentAndIsolationStatus {...renderProps} />);
return renderResult;
};
});
it('should display host status only when `isIsolated` is undefined', () => {
render();
expect(renderResult.queryByTestId('test-isolationStatus')).toBeNull();
});
it('should display pending status and pending counts', () => {
renderProps.isIsolated = true;
render();
expect(renderResult.getByTestId('test-isolationStatus')).toBeTruthy();
});
});

View file

@ -0,0 +1,64 @@
/*
* 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 } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { useTestIdGenerator } from '../hooks/use_test_id_generator';
import { HostStatus } from '../../../../common/endpoint/types';
import { AgentStatus } from '../../../common/components/endpoint/agent_status';
import {
EndpointHostIsolationStatus,
EndpointHostIsolationStatusProps,
} from '../../../common/components/endpoint/host_isolation';
const EuiFlexGroupStyled = styled(EuiFlexGroup)`
.isolation-status {
margin-left: ${({ theme }) => theme.eui.paddingSizes.s};
}
`;
export interface EndpointAgentAndIsolationStatusProps
extends Pick<EndpointHostIsolationStatusProps, 'pendingIsolate' | 'pendingUnIsolate'> {
status: HostStatus;
/**
* If defined with a boolean, then the isolation status will be shown along with the agent status.
* The `pendingIsolate` and `pendingUnIsolate` props will only be used when this prop is set to a
* `boolean`
*/
isIsolated?: boolean;
'data-test-subj'?: string;
}
export const EndpointAgentAndIsolationStatus = memo<EndpointAgentAndIsolationStatusProps>(
({ status, isIsolated, pendingIsolate, pendingUnIsolate, 'data-test-subj': dataTestSubj }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
return (
<EuiFlexGroupStyled
gutterSize="none"
responsive={false}
className="eui-textTruncate"
data-test-subj={dataTestSubj}
>
<EuiFlexItem grow={false}>
<AgentStatus hostStatus={status} />
</EuiFlexItem>
{isIsolated !== undefined && (
<EuiFlexItem grow={false} className="eui-textTruncate isolation-status">
<EndpointHostIsolationStatus
data-test-subj={getTestId('isolationStatus')}
isIsolated={isIsolated}
pendingIsolate={pendingIsolate}
pendingUnIsolate={pendingUnIsolate}
/>
</EuiFlexItem>
)}
</EuiFlexGroupStyled>
);
}
);
EndpointAgentAndIsolationStatus.displayName = 'EndpointAgentAndIsolationStatus';

View file

@ -0,0 +1,9 @@
/*
* 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 { EndpointAgentAndIsolationStatus } from './endpoint_agent_and_isolation_status';
export type { EndpointAgentAndIsolationStatusProps } from './endpoint_agent_and_isolation_status';

View file

@ -0,0 +1,50 @@
/*
* 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 { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint';
import {
EndpointAppliedPolicyStatus,
EndpointAppliedPolicyStatusProps,
} from './endpoint_applied_policy_status';
import React from 'react';
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
import { POLICY_STATUS_TO_TEXT } from '../../pages/endpoint_hosts/view/host_constants';
describe('when using EndpointPolicyStatus component', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<AppContextTestRender['render']>;
let renderProps: EndpointAppliedPolicyStatusProps;
beforeEach(() => {
const appTestContext = createAppRootMockRenderer();
renderProps = {
policyApplied: new EndpointDocGenerator('seed').generateHostMetadata().Endpoint.policy
.applied,
};
render = () => {
renderResult = appTestContext.render(<EndpointAppliedPolicyStatus {...renderProps} />);
return renderResult;
};
});
it('should display status from metadata `policy.applied` value', () => {
render();
expect(renderResult.getByTestId('policyStatus').textContent).toEqual(
POLICY_STATUS_TO_TEXT[renderProps.policyApplied.status]
);
});
it('should display status passed as `children`', () => {
renderProps.children = 'status goes here';
render();
expect(renderResult.getByTestId('policyStatus').textContent).toEqual('status goes here');
});
});

View file

@ -0,0 +1,85 @@
/*
* 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, PropsWithChildren } from 'react';
import { EuiHealth, EuiToolTip, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
POLICY_STATUS_TO_HEALTH_COLOR,
POLICY_STATUS_TO_TEXT,
} from '../../pages/endpoint_hosts/view/host_constants';
import { HostMetadata } from '../../../../common/endpoint/types';
/**
* Displays the status of an applied policy on the Endpoint (using the information provided
* by the endpoint in the Metadata document `Endpoint.policy.applied`.
* By default, the policy status is displayed as plain text, however, that can be overridden
* by defining the `children` prop or passing a child component to this one.
*/
export type EndpointAppliedPolicyStatusProps = PropsWithChildren<{
policyApplied: HostMetadata['Endpoint']['policy']['applied'];
}>;
/**
* Display the status of the Policy applied on an endpoint
*/
export const EndpointAppliedPolicyStatus = memo<EndpointAppliedPolicyStatusProps>(
({ policyApplied, children }) => {
return (
<EuiToolTip
title={
<FormattedMessage
id="xpack.securitySolution.endpointPolicyStatus.tooltipTitleLabel"
defaultMessage="Policy applied"
/>
}
anchorClassName="eui-textTruncate"
content={
<EuiFlexGroup
responsive={false}
gutterSize="s"
alignItems="center"
data-test-subj="endpointAppliedPolicyTooltipInfo"
>
<EuiFlexItem className="eui-textTruncate" grow>
<EuiText size="s" className="eui-textTruncate">
{policyApplied.name}
</EuiText>
</EuiFlexItem>
{policyApplied.endpoint_policy_version && (
<EuiFlexItem grow={false}>
<EuiText
color="subdued"
size="xs"
style={{ whiteSpace: 'nowrap', paddingLeft: '6px' }}
className="eui-textTruncate"
data-test-subj="policyRevision"
>
<FormattedMessage
id="xpack.securitySolution.endpointPolicyStatus.revisionNumber"
defaultMessage="rev. {revNumber}"
values={{ revNumber: policyApplied.endpoint_policy_version }}
/>
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
}
>
<EuiHealth
color={POLICY_STATUS_TO_HEALTH_COLOR[policyApplied.status]}
className="eui-textTruncate eui-fullWidth"
data-test-subj="policyStatus"
>
{children !== undefined ? children : POLICY_STATUS_TO_TEXT[policyApplied.status]}
</EuiHealth>
</EuiToolTip>
);
}
);
EndpointAppliedPolicyStatus.displayName = 'EndpointAppliedPolicyStatus';

View file

@ -0,0 +1,9 @@
/*
* 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 { EndpointAppliedPolicyStatus } from './endpoint_applied_policy_status';
export type { EndpointAppliedPolicyStatusProps } from './endpoint_applied_policy_status';

View file

@ -0,0 +1,48 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { CommandDefinition } from '../console';
import { IsolateActionResult } from './isolate_action';
import { EndpointStatusActionResult } from './status_action';
export const getEndpointResponseActionsConsoleCommands = (
endpointAgentId: string
): CommandDefinition[] => {
return [
{
name: 'isolate',
about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.isolate.about', {
defaultMessage: 'Isolate the host',
}),
RenderComponent: IsolateActionResult,
meta: {
endpointId: endpointAgentId,
},
args: {
comment: {
required: false,
allowMultiples: false,
about: i18n.translate(
'xpack.securitySolution.endpointConsoleCommands.isolate.arg.command',
{ defaultMessage: 'A comment to go along with the action' }
),
},
},
},
{
name: 'status',
about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.status.about', {
defaultMessage: 'Display the latest status information for the Endpoint',
}),
RenderComponent: EndpointStatusActionResult,
meta: {
endpointId: endpointAgentId,
},
},
];
};

View file

@ -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 { getEndpointResponseActionsConsoleCommands } from './endpoint_response_actions_console_commands';

View file

@ -0,0 +1,174 @@
/*
* 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 { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint';
import {
ConsoleManagerTestComponent,
getConsoleManagerMockRenderResultQueriesAndActions,
} from '../console/components/console_manager/mocks';
import React from 'react';
import { getEndpointResponseActionsConsoleCommands } from './endpoint_response_actions_console_commands';
import { responseActionsHttpMocks } from '../../pages/mocks/response_actions_http_mocks';
import { enterConsoleCommand } from '../console/mocks';
import { waitFor } from '@testing-library/react';
describe('When using isolate action from response actions console', () => {
let render: () => Promise<ReturnType<AppContextTestRender['render']>>;
let renderResult: ReturnType<AppContextTestRender['render']>;
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
let consoleManagerMockAccess: ReturnType<
typeof getConsoleManagerMockRenderResultQueriesAndActions
>;
beforeEach(() => {
const mockedContext = createAppRootMockRenderer();
apiMocks = responseActionsHttpMocks(mockedContext.coreStart.http);
render = async () => {
renderResult = mockedContext.render(
<ConsoleManagerTestComponent
registerConsoleProps={() => {
return {
consoleProps: {
'data-test-subj': 'test',
commands: getEndpointResponseActionsConsoleCommands('a.b.c'),
},
};
}}
/>
);
consoleManagerMockAccess = getConsoleManagerMockRenderResultQueriesAndActions(renderResult);
await consoleManagerMockAccess.clickOnRegisterNewConsole();
await consoleManagerMockAccess.openRunningConsole();
return renderResult;
};
});
it('should call `isolate` api when command is entered', async () => {
await render();
enterConsoleCommand(renderResult, 'isolate');
await waitFor(() => {
expect(apiMocks.responseProvider.isolateHost).toHaveBeenCalledTimes(1);
});
});
it('should accept an optional `--comment`', async () => {
await render();
enterConsoleCommand(renderResult, 'isolate --comment "This is a comment"');
await waitFor(() => {
expect(apiMocks.responseProvider.isolateHost).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.stringContaining('This is a comment'),
})
);
});
});
it('should only accept one `--comment`', async () => {
await render();
enterConsoleCommand(renderResult, 'isolate --comment "one" --comment "two"');
expect(renderResult.getByTestId('test-badArgument').textContent).toMatch(
/argument can only be used once: --comment/
);
});
it('should call the action status api after creating the `isolate` request', async () => {
await render();
enterConsoleCommand(renderResult, 'isolate');
await waitFor(() => {
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalled();
});
});
it('should show success when `isolate` action completes with no errors', async () => {
await render();
enterConsoleCommand(renderResult, 'isolate');
await waitFor(() => {
expect(renderResult.getByTestId('isolateSuccessCallout')).toBeTruthy();
});
});
it('should show error if isolate failed to complete successfully', async () => {
const pendingDetailResponse = apiMocks.responseProvider.actionDetails({
path: '/api/endpoint/action/1.2.3',
});
pendingDetailResponse.data.wasSuccessful = false;
pendingDetailResponse.data.errors = ['error one', 'error two'];
apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse);
await render();
enterConsoleCommand(renderResult, 'isolate');
await waitFor(() => {
expect(renderResult.getByTestId('isolateErrorCallout').textContent).toMatch(
/error one \| error two/
);
});
});
describe('and when console is closed (not terminated) and then reopened', () => {
beforeEach(() => {
const _render = render;
render = async () => {
const response = await _render();
enterConsoleCommand(response, 'isolate');
await waitFor(() => {
expect(apiMocks.responseProvider.isolateHost).toHaveBeenCalledTimes(1);
});
// Hide the console
await consoleManagerMockAccess.hideOpenedConsole();
return response;
};
});
it('should NOT send the `isolate` request again', async () => {
await render();
await consoleManagerMockAccess.openRunningConsole();
expect(apiMocks.responseProvider.isolateHost).toHaveBeenCalledTimes(1);
});
it('should continue to check action status when still pending', async () => {
const pendingDetailResponse = apiMocks.responseProvider.actionDetails({
path: '/api/endpoint/action/1.2.3',
});
pendingDetailResponse.data.isCompleted = false;
apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse);
await render();
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(2);
await consoleManagerMockAccess.hideOpenedConsole();
await consoleManagerMockAccess.openRunningConsole();
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(3);
});
it('should display completion output if done (no additional API calls)', async () => {
await render();
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);
await consoleManagerMockAccess.hideOpenedConsole();
await consoleManagerMockAccess.openRunningConsole();
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,119 @@
/*
* 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, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut } from '@elastic/eui';
import { ActionDetails } from '../../../../common/endpoint/types';
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
import { EndpointCommandDefinitionMeta } from './types';
import { useSendIsolateEndpointRequest } from '../../hooks/endpoint/use_send_isolate_endpoint_request';
import { CommandExecutionComponentProps } from '../console/types';
export const IsolateActionResult = memo<
CommandExecutionComponentProps<
{
actionId?: string;
actionRequestSent?: boolean;
completedActionDetails?: ActionDetails;
},
EndpointCommandDefinitionMeta
>
>(({ command, setStore, store, status, setStatus }) => {
const endpointId = command.commandDefinition?.meta?.endpointId;
const { actionId, completedActionDetails } = store;
const isPending = status === 'pending';
const actionRequestSent = Boolean(store.actionRequestSent);
const isolateHostApi = useSendIsolateEndpointRequest();
const { data: actionDetails } = useGetActionDetails(actionId ?? '-', {
enabled: Boolean(actionId) && isPending,
refetchInterval: isPending ? 3000 : false,
});
// Send Isolate request if not yet done
useEffect(() => {
if (!actionRequestSent && endpointId) {
isolateHostApi.mutate({
endpoint_ids: [endpointId],
comment: command.args.args?.comment?.value,
});
setStore((prevState) => {
return { ...prevState, actionRequestSent: true };
});
}
}, [actionRequestSent, command.args.args?.comment?.value, endpointId, isolateHostApi, setStore]);
// If isolate request was created, store the action id if necessary
useEffect(() => {
if (isolateHostApi.isSuccess && actionId !== isolateHostApi.data.action) {
setStore((prevState) => {
return { ...prevState, actionId: isolateHostApi.data.action };
});
}
}, [actionId, isolateHostApi?.data?.action, isolateHostApi.isSuccess, setStore]);
useEffect(() => {
if (actionDetails?.data.isCompleted) {
setStatus('success');
setStore((prevState) => {
return {
...prevState,
completedActionDetails: actionDetails.data,
};
});
}
}, [actionDetails?.data, setStatus, setStore]);
// Show nothing if still pending
if (isPending) {
return null;
}
// Show errors
if (completedActionDetails?.errors) {
return (
<EuiCallOut
color="danger"
iconType="alert"
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.isolate.errorMessageTitle',
{ defaultMessage: 'Failure' }
)}
data-test-subj="isolateErrorCallout"
>
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.isolate.errorMessage"
defaultMessage="Isolate action failed with: {errors}"
values={{ errors: completedActionDetails.errors.join(' | ') }}
/>
</EuiCallOut>
);
}
// Show Success
return (
<EuiCallOut
color="success"
iconType="check"
title={i18n.translate(
'xpack.securitySolution.endpointResponseActions.isolate.successMessageTitle',
{ defaultMessage: 'Success' }
)}
data-test-subj="isolateSuccessCallout"
>
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.isolate.successMessage"
defaultMessage="A host isolation request was sent and an acknowledgement was received from Host."
/>
</EuiCallOut>
);
});
IsolateActionResult.displayName = 'IsolateActionResult';

View file

@ -0,0 +1,179 @@
/*
* 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, useEffect, useMemo } from 'react';
import { EuiCallOut, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import type { HttpFetchError } from '@kbn/core/public';
import { v4 as uuidV4 } from 'uuid';
import type { HostInfo, PendingActionsResponse } from '../../../../common/endpoint/types';
import type { EndpointCommandDefinitionMeta } from './types';
import { EndpointHostIsolationStatusProps } from '../../../common/components/endpoint/host_isolation';
import { useGetEndpointPendingActionsSummary } from '../../hooks/endpoint/use_get_endpoint_pending_actions_summary';
import { FormattedDate } from '../../../common/components/formatted_date';
import { EndpointAppliedPolicyStatus } from '../endpoint_applied_policy_status';
import { EndpointAgentAndIsolationStatus } from '../endpoint_agent_and_isolation_status';
import { useGetEndpointDetails } from '../../hooks';
import type { CommandExecutionComponentProps } from '../console/types';
import { FormattedError } from '../formatted_error';
export const EndpointStatusActionResult = memo<
CommandExecutionComponentProps<
{
apiCalled?: boolean;
endpointDetails?: HostInfo;
detailsFetchError?: HttpFetchError;
endpointPendingActions?: PendingActionsResponse;
},
EndpointCommandDefinitionMeta
>
>(({ command, status, setStatus, store, setStore }) => {
const endpointId = command.commandDefinition?.meta?.endpointId as string;
const { endpointPendingActions, endpointDetails, detailsFetchError, apiCalled } = store;
const isPending = status === 'pending';
const {
isFetching,
isFetched,
refetch: fetchEndpointDetails,
} = useGetEndpointDetails(endpointId, { enabled: false });
const { refetch: fetchEndpointPendingActionsSummary } = useGetEndpointPendingActionsSummary(
[endpointId],
{ enabled: false }
);
const pendingIsolationActions = useMemo<
Pick<Required<EndpointHostIsolationStatusProps>, 'pendingIsolate' | 'pendingUnIsolate'>
>(() => {
if (endpointPendingActions?.data.length) {
const pendingActions = endpointPendingActions.data[0].pending_actions;
return {
pendingIsolate: pendingActions.isolate ?? 0,
pendingUnIsolate: pendingActions.unisolate ?? 0,
};
}
return {
pendingIsolate: 0,
pendingUnIsolate: 0,
};
}, [endpointPendingActions?.data]);
useEffect(() => {
if (!apiCalled) {
setStore((prevState) => {
return {
...prevState,
apiCalled: true,
};
});
// Using a unique `queryKey` here and below so that data is NOT updated
// from cache when future requests for this endpoint ID is done again.
fetchEndpointDetails({ queryKey: uuidV4() })
.then(({ data }) => {
setStore((prevState) => {
return {
...prevState,
endpointDetails: data,
};
});
})
.catch((err) => {
setStore((prevState) => {
return {
...prevState,
detailsFetchError: err,
};
});
});
fetchEndpointPendingActionsSummary({ queryKey: uuidV4() }).then(({ data }) => {
setStore((prevState) => {
return {
...prevState,
endpointPendingActions: data,
};
});
});
}
}, [apiCalled, fetchEndpointDetails, fetchEndpointPendingActionsSummary, setStore]);
useEffect(() => {
if (isFetched && isPending) {
setStatus(detailsFetchError ? 'error' : 'success');
}
}, [detailsFetchError, isFetched, setStatus, isPending]);
if (isFetching) {
return null;
}
if (detailsFetchError) {
return (
<EuiCallOut>
<EuiFieldText>
<FormattedError error={detailsFetchError} />
</EuiFieldText>
</EuiCallOut>
);
}
if (!endpointDetails) {
return null;
}
return (
<EuiFlexGroup wrap={false} responsive={false}>
<EuiFlexItem grow={false}>
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.status.agentStatus"
defaultMessage="Agent status"
/>
</EuiText>
<EndpointAgentAndIsolationStatus
status={endpointDetails.host_status}
isIsolated={Boolean(endpointDetails.metadata.Endpoint.state?.isolation)}
{...pendingIsolationActions}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.status.policyStatus"
defaultMessage="Policy status"
/>
</EuiText>
<EndpointAppliedPolicyStatus
policyApplied={endpointDetails.metadata.Endpoint.policy.applied}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.endpointResponseActions.status.lastActive"
defaultMessage="Last active"
/>
</EuiText>
<EuiText>
<FormattedDate
fieldName={i18n.translate(
'xpack.securitySolution.endpointResponseActions.status.lastActive',
{ defaultMessage: 'Last active' }
)}
value={endpointDetails.metadata['@timestamp']}
className="eui-textTruncate"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
});
EndpointStatusActionResult.displayName = 'EndpointStatusActionResult';

View file

@ -5,4 +5,6 @@
* 2.0.
*/
export { ConsolesPopoverHeaderSectionItem } from './consoles_popover_header_section_item';
export interface EndpointCommandDefinitionMeta {
endpointId: string;
}

View file

@ -0,0 +1,76 @@
/*
* 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 {
AppContextTestRender,
createAppRootMockRenderer,
ReactQueryHookRenderer,
} from '../../../common/mock/endpoint';
import { useGetActionDetails } from './use_get_action_details';
import { responseActionsHttpMocks } from '../../pages/mocks/response_actions_http_mocks';
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants';
import { useQuery as _useQuery } from 'react-query';
const useQueryMock = _useQuery as jest.Mock;
jest.mock('react-query', () => {
const actualReactQueryModule = jest.requireActual('react-query');
return {
...actualReactQueryModule,
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
};
});
describe('useGetActionDetails hook', () => {
let renderReactQueryHook: ReactQueryHookRenderer<
Parameters<typeof useGetActionDetails>,
ReturnType<typeof useGetActionDetails>
>;
let http: AppContextTestRender['coreStart']['http'];
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
beforeEach(() => {
const testContext = createAppRootMockRenderer();
renderReactQueryHook = testContext.renderReactQueryHook as typeof renderReactQueryHook;
http = testContext.coreStart.http;
apiMocks = responseActionsHttpMocks(http);
});
it('should call the proper API', async () => {
await renderReactQueryHook(() => useGetActionDetails('123'));
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledWith({
path: resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: '123' }),
});
});
it('should call api with `undefined` for action id if it was not defined on input', async () => {
await renderReactQueryHook(() => useGetActionDetails(''));
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledWith({
path: resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: 'undefined' }),
});
});
it('should allow custom options to be used', async () => {
await renderReactQueryHook(
() => useGetActionDetails('123', { queryKey: ['1', '2'], enabled: false }),
false
);
expect(useQueryMock).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['1', '2'],
enabled: false,
})
);
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { UseQueryOptions, UseQueryResult } from 'react-query';
import type { HttpFetchError } from '@kbn/core/public';
import { useQuery } from 'react-query';
import { useHttp } from '../../../common/lib/kibana';
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants';
import type { ActionDetailsApiResponse } from '../../../../common/endpoint/types';
export const useGetActionDetails = (
actionId: string,
options: UseQueryOptions<ActionDetailsApiResponse, HttpFetchError> = {}
): UseQueryResult<ActionDetailsApiResponse, HttpFetchError> => {
const http = useHttp();
return useQuery<ActionDetailsApiResponse, HttpFetchError>({
queryKey: ['get-action-details', actionId],
...options,
queryFn: () => {
return http.get<ActionDetailsApiResponse>(
resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: actionId.trim() || 'undefined' })
);
},
});
};

View file

@ -0,0 +1,35 @@
/*
* 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 { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import type { HttpFetchError } from '@kbn/core/public';
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
import { useHttp } from '../../../common/lib/kibana';
import type { HostInfo } from '../../../../common/endpoint/types';
import { HOST_METADATA_GET_ROUTE } from '../../../../common/endpoint/constants';
/**
* Get info for a security solution endpoint host using the endpoint id (`agent.id`)
* @param endpointId
* @param options
*/
export const useGetEndpointDetails = (
endpointId: string,
options: UseQueryOptions<HostInfo, HttpFetchError> = {}
): UseQueryResult<HostInfo, HttpFetchError> => {
const http = useHttp();
return useQuery<HostInfo, HttpFetchError>({
queryKey: ['get-endpoint-host-info', endpointId],
...options,
queryFn: () => {
return http.get<HostInfo>(
resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: endpointId.trim() || 'undefined' })
);
},
});
};

View file

@ -0,0 +1,76 @@
/*
* 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 {
AppContextTestRender,
createAppRootMockRenderer,
ReactQueryHookRenderer,
} from '../../../common/mock/endpoint';
import { useGetEndpointDetails } from './use_get_endpoint_details';
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
import { HOST_METADATA_GET_ROUTE } from '../../../../common/endpoint/constants';
import { useQuery as _useQuery } from 'react-query';
import { endpointMetadataHttpMocks } from '../../pages/endpoint_hosts/mocks';
const useQueryMock = _useQuery as jest.Mock;
jest.mock('react-query', () => {
const actualReactQueryModule = jest.requireActual('react-query');
return {
...actualReactQueryModule,
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
};
});
describe('useGetEndpointDetails hook', () => {
let renderReactQueryHook: ReactQueryHookRenderer<
Parameters<typeof useGetEndpointDetails>,
ReturnType<typeof useGetEndpointDetails>
>;
let http: AppContextTestRender['coreStart']['http'];
let apiMocks: ReturnType<typeof endpointMetadataHttpMocks>;
beforeEach(() => {
const testContext = createAppRootMockRenderer();
renderReactQueryHook = testContext.renderReactQueryHook as typeof renderReactQueryHook;
http = testContext.coreStart.http;
apiMocks = endpointMetadataHttpMocks(http);
});
it('should call the proper API', async () => {
await renderReactQueryHook(() => useGetEndpointDetails('123'));
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalledWith({
path: resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: '123' }),
});
});
it('should call api with `undefined` for endpoint id if it was not defined on input', async () => {
await renderReactQueryHook(() => useGetEndpointDetails(''));
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalledWith({
path: resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: 'undefined' }),
});
});
it('should allow custom options to be used', async () => {
await renderReactQueryHook(
() => useGetEndpointDetails('123', { queryKey: ['1', '2'], enabled: false }),
false
);
expect(useQueryMock).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['1', '2'],
enabled: false,
})
);
});
});

View file

@ -0,0 +1,72 @@
/*
* 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 {
AppContextTestRender,
createAppRootMockRenderer,
ReactQueryHookRenderer,
} from '../../../common/mock/endpoint';
import { useGetEndpointPendingActionsSummary } from './use_get_endpoint_pending_actions_summary';
import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import { useQuery as _useQuery } from 'react-query';
import { responseActionsHttpMocks } from '../../pages/mocks/response_actions_http_mocks';
const useQueryMock = _useQuery as jest.Mock;
jest.mock('react-query', () => {
const actualReactQueryModule = jest.requireActual('react-query');
return {
...actualReactQueryModule,
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
};
});
describe('useGetEndpointPendingActionsSummary hook', () => {
let renderReactQueryHook: ReactQueryHookRenderer<
Parameters<typeof useGetEndpointPendingActionsSummary>,
ReturnType<typeof useGetEndpointPendingActionsSummary>
>;
let http: AppContextTestRender['coreStart']['http'];
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
beforeEach(() => {
const testContext = createAppRootMockRenderer();
renderReactQueryHook = testContext.renderReactQueryHook as typeof renderReactQueryHook;
http = testContext.coreStart.http;
apiMocks = responseActionsHttpMocks(http);
});
it('should call the proper API', async () => {
await renderReactQueryHook(() => useGetEndpointPendingActionsSummary(['123', '456']));
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalledWith({
path: `${ACTION_STATUS_ROUTE}`,
query: { agent_ids: ['123', '456'] },
});
});
it('should allow custom options to be used', async () => {
await renderReactQueryHook(
() =>
useGetEndpointPendingActionsSummary(['123', '456'], {
queryKey: ['1', '2'],
enabled: false,
}),
false
);
expect(useQueryMock).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ['1', '2'],
enabled: false,
})
);
});
});

View file

@ -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.
*/
import { QueryObserverResult, UseQueryOptions, useQuery } from 'react-query';
import { HttpFetchError } from '@kbn/core/public';
import { PendingActionsResponse } from '../../../../common/endpoint/types';
import { fetchPendingActionsByAgentId } from '../../../common/lib/endpoint_pending_actions';
/**
* Retrieves the pending actions against the given Endpoint `agent.id`'s
* @param endpointAgentIds
* @param options
*/
export const useGetEndpointPendingActionsSummary = (
endpointAgentIds: string[],
options: UseQueryOptions<PendingActionsResponse, HttpFetchError> = {}
): QueryObserverResult<PendingActionsResponse, HttpFetchError> => {
return useQuery<PendingActionsResponse, HttpFetchError>({
queryKey: ['fetch-endpoint-pending-actions-summary', ...endpointAgentIds],
...options,
queryFn: () => fetchPendingActionsByAgentId(endpointAgentIds),
});
};

View file

@ -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 { useMutation, UseMutationOptions } from 'react-query';
import { HttpFetchError } from '@kbn/core/public';
import { isolateHost } from '../../../common/lib/endpoint_isolation';
import { HostIsolationRequestBody, HostIsolationResponse } from '../../../../common/endpoint/types';
/**
* Create host isolation requests
* @param customOptions
*/
export const useSendIsolateEndpointRequest = (
customOptions?: UseMutationOptions<
HostIsolationResponse,
HttpFetchError,
HostIsolationRequestBody
>
) => {
return useMutation<HostIsolationResponse, HttpFetchError, HostIsolationRequestBody>(
(isolateData: HostIsolationRequestBody) => {
return isolateHost(isolateData);
},
customOptions
);
};

View file

@ -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.
*/
import { useCallback } from 'react';
import { getEndpointResponseActionsConsoleCommands } from '../../components/endpoint_console/endpoint_response_actions_console_commands';
import { useConsoleManager } from '../../components/console';
import type { HostMetadata } from '../../../../common/endpoint/types';
type ShowEndpointResponseActionsConsole = (endpointMetadata: HostMetadata) => void;
export const useShowEndpointResponseActionsConsole = (): ShowEndpointResponseActionsConsole => {
const consoleManager = useConsoleManager();
return useCallback(
(endpointMetadata: HostMetadata) => {
const endpointAgentId = endpointMetadata.agent.id;
const endpointRunningConsole = consoleManager.getOne(endpointAgentId);
if (endpointRunningConsole) {
endpointRunningConsole.show();
} else {
consoleManager
.register({
id: endpointAgentId,
title: `${endpointMetadata.host.name} - Endpoint v${endpointMetadata.agent.version}`,
consoleProps: {
commands: getEndpointResponseActionsConsoleCommands(endpointAgentId),
'data-test-subj': 'endpointResponseActionsConsole',
prompt: `endpoint-${endpointMetadata.agent.version}`,
},
})
.show();
}
},
[consoleManager]
);
};

View file

@ -0,0 +1,9 @@
/*
* 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 { useGetEndpointDetails } from './endpoint/use_get_endpoint_details';
export { useShowEndpointResponseActionsConsole } from './endpoint/use_show_endpoint_response_actions_console';

View file

@ -31,7 +31,17 @@ export const TableRowActions = memo<TableRowActionProps>(({ endpointMetadata })
const menuItems: EuiContextMenuPanelProps['items'] = useMemo(() => {
return endpointActions.map((itemProps) => {
return <ContextMenuItemNavByRouter {...itemProps} onClick={handleCloseMenu} />;
return (
<ContextMenuItemNavByRouter
{...itemProps}
onClick={(ev) => {
handleCloseMenu();
if (itemProps.onClick) {
itemProps.onClick(ev);
}
}}
/>
);
});
}, [handleCloseMenu, endpointActions]);

View file

@ -23,7 +23,17 @@ export const ActionsMenu = React.memo<{}>(() => {
const takeActionItems = useMemo(() => {
return menuOptions.map((item) => {
return <ContextMenuItemNavByRouter {...item} onClick={closePopoverHandler} />;
return (
<ContextMenuItemNavByRouter
{...item}
onClick={(ev) => {
closePopoverHandler();
if (item.onClick) {
item.onClick(ev);
}
}}
/>
);
});
}, [closePopoverHandler, menuOptions]);

View file

@ -1,268 +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 React, {
memo,
ReactElement,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
EuiButton,
EuiCode,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { useIsMounted } from '../../../components/hooks/use_is_mounted';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useUrlParams } from '../../../components/hooks/use_url_params';
import {
CommandDefinition,
Console,
RegisteredConsoleClient,
useConsoleManager,
} from '../../../components/console';
const delay = async (ms: number = 4000) => new Promise((r) => setTimeout(r, ms));
const getCommandList = (): CommandDefinition[] => {
return [
{
name: 'cmd1',
about: 'Runs cmd1',
RenderComponent: ({ command, setStatus, store, setStore }) => {
const isMounted = useIsMounted();
const [apiResponse, setApiResponse] = useState<null | string>(null);
const [uiResponse, setUiResponse] = useState<null | ReactElement>(null);
// Emulate a real action where:
// 1. an api request is done to create the action
// 2. wait for a response
// 3. account for component mount/unmount and prevent duplicate api calls
useEffect(() => {
(async () => {
// Emulate an api call
if (!store.apiInflight) {
setStore({
...store,
apiInflight: true,
});
window.console.warn(`${Math.random()} ------> cmd1: doing async work`);
await delay(6000);
setApiResponse(`API was called at: ${new Date().toLocaleString()}`);
}
})();
}, [setStore, store]);
useEffect(() => {
(async () => {
const doUiResponse = () => {
setUiResponse(
<EuiText>
<EuiText>{`${command.commandDefinition.name}`}</EuiText>
<EuiText>{`command input: ${command.input}`}</EuiText>
<EuiText>{'Arguments provided:'}</EuiText>
<EuiCode>{JSON.stringify(command.args, null, 2)}</EuiCode>
</EuiText>
);
};
if (store.apiResponse) {
doUiResponse();
} else {
await delay();
doUiResponse();
}
})();
}, [
command.args,
command.commandDefinition.name,
command.input,
isMounted,
store.apiResponse,
]);
useEffect(() => {
if (apiResponse && uiResponse) {
setStatus('success');
}
}, [apiResponse, setStatus, uiResponse]);
useEffect(() => {
if (apiResponse && store.apiResponse !== apiResponse) {
setStore({
...store,
apiResponse,
});
}
}, [apiResponse, setStore, store]);
if (store.apiResponse) {
return (
<div>
{uiResponse}
<EuiText>{store.apiResponse as ReactNode}</EuiText>
</div>
);
}
return null;
},
args: {
one: {
required: false,
allowMultiples: false,
about: 'just one',
},
},
},
// {
// name: 'get-file',
// about: 'retrieve a file from the endpoint',
// args: {
// file: {
// required: true,
// allowMultiples: false,
// about: 'the file path for the file to be retrieved',
// },
// },
// },
// {
// name: 'cmd2',
// about: 'runs cmd 2',
// args: {
// file: {
// required: true,
// allowMultiples: false,
// about: 'Includes file in the run',
// validate: () => {
// return true;
// },
// },
// bad: {
// required: false,
// allowMultiples: false,
// about: 'will fail validation',
// validate: () => 'This is a bad value',
// },
// },
// },
// {
// name: 'cmd-long-delay',
// about: 'runs cmd 2',
// },
];
};
const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
({ registeredConsole }) => {
const handleShowOnClick = useCallback(() => {
registeredConsole.show();
}, [registeredConsole]);
const handleTerminateOnClick = useCallback(() => {
registeredConsole.terminate();
}, [registeredConsole]);
return (
<>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow>{registeredConsole.title}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiButton onClick={handleTerminateOnClick} color="danger">
{'terminate'}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton onClick={handleShowOnClick}>{'show'}</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
</>
);
}
);
RunningConsole.displayName = 'RunningConsole';
// ------------------------------------------------------------
// FOR DEV PURPOSES ONLY
// FIXME:PT Delete once we have support via row actions menu
// ------------------------------------------------------------
export const ShowDevConsole = memo(() => {
const consoleManager = useConsoleManager();
const commands = useMemo(() => {
return getCommandList();
}, []);
const handleRegisterOnClick = useCallback(() => {
consoleManager
.register({
id: Math.random().toString(36), // getId(),
title: 'Test console here',
meta: {
foo: 'bar',
},
consoleProps: {
prompt: '>>',
commands,
'data-test-subj': 'dev',
},
})
.show();
}, [commands, consoleManager]);
return (
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton onClick={handleRegisterOnClick}>{'Open a managed console'}</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow>
{consoleManager.getList<{ foo: string }>().map((registeredConsole) => {
return (
<RunningConsole key={registeredConsole.id} registeredConsole={registeredConsole} />
);
})}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xxl" />
<EuiText>
<h3>{'Un-managed console'}</h3>
</EuiText>
<EuiPanel style={{ height: '600px' }}>
<Console prompt="$$>" commands={getCommandList()} data-test-subj="dev" />
</EuiPanel>
</EuiPanel>
);
});
ShowDevConsole.displayName = 'ShowDevConsole';
export const DevConsole = memo(() => {
const isConsoleEnabled = useIsExperimentalFeatureEnabled('responseActionsConsoleEnabled');
const {
urlParams: { showConsole = false },
} = useUrlParams();
return isConsoleEnabled && showConsole ? <ShowDevConsole /> : null;
});
DevConsole.displayName = 'DevConsole';

View file

@ -8,6 +8,8 @@
import React, { useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { pagePathGetters } from '@kbn/fleet-plugin/public';
import { useShowEndpointResponseActionsConsole } from '../../../../hooks';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { APP_UI_ID } from '../../../../../../common/constants';
import { getEndpointDetailsPath } from '../../../../common/routing';
import { HostMetadata, MaybeImmutable } from '../../../../../../common/endpoint/types';
@ -30,6 +32,10 @@ export const useEndpointActionItems = (
const { getAppUrl } = useAppUrl();
const fleetAgentPolicies = useEndpointSelector(agentPolicies);
const allCurrentUrlParams = useEndpointSelector(uiQueryParams);
const showEndpointResponseActionsConsole = useShowEndpointResponseActionsConsole();
const isResponseActionsConsoleEnabled = useIsExperimentalFeatureEnabled(
'responseActionsConsoleEnabled'
);
return useMemo<ContextMenuItemNavByRouterProps[]>(() => {
if (endpointMetadata) {
@ -101,6 +107,25 @@ export const useEndpointActionItems = (
return [
...isolationActions,
...(isResponseActionsConsoleEnabled
? [
{
'data-test-subj': 'console',
icon: 'console',
key: 'consoleLink',
onClick: (ev: React.MouseEvent) => {
ev.preventDefault();
showEndpointResponseActionsConsole(endpointMetadata);
},
children: (
<FormattedMessage
id="xpack.securitySolution.endpoint.actions.console"
defaultMessage="Launch responder"
/>
),
},
]
: []),
{
'data-test-subj': 'hostLink',
icon: 'logoSecurity',
@ -192,5 +217,13 @@ export const useEndpointActionItems = (
}
return [];
}, [allCurrentUrlParams, endpointMetadata, fleetAgentPolicies, getAppUrl, isPlatinumPlus]);
}, [
allCurrentUrlParams,
endpointMetadata,
fleetAgentPolicies,
getAppUrl,
isPlatinumPlus,
isResponseActionsConsoleEnabled,
showEndpointResponseActionsConsole,
]);
};

View file

@ -68,7 +68,6 @@ import {
BackToExternalAppButton,
BackToExternalAppButtonProps,
} from '../../../components/back_to_external_app_button/back_to_external_app_button';
import { DevConsole } from './dev_console';
import { ManagementEmptyStateWrapper } from '../../../components/management_empty_state_wrapper';
const MAX_PAGINATED_ITEM = 9999;
@ -689,9 +688,6 @@ export const EndpointList = () => {
}
headerBackComponent={routeState.backLink && backToPolicyList}
>
{/* FIXME: Remove once Console is implemented via ConsoleManagementProvider */}
<DevConsole />
{hasSelectedEndpoint && <EndpointDetailsFlyout />}
<>
{areEndpointsEnrolling && !hasErrorFindingTotals && (

View file

@ -0,0 +1,80 @@
/*
* 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 { HttpFetchOptionsWithPath } from '@kbn/core/public';
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
import {
ACTION_DETAILS_ROUTE,
ACTION_STATUS_ROUTE,
ISOLATE_HOST_ROUTE,
UNISOLATE_HOST_ROUTE,
} from '../../../../common/endpoint/constants';
import {
httpHandlerMockFactory,
ResponseProvidersInterface,
} from '../../../common/mock/endpoint/http_handler_mock_factory';
import {
ActionDetailsApiResponse,
HostIsolationResponse,
PendingActionsResponse,
} from '../../../../common/endpoint/types';
export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
isolateHost: () => HostIsolationResponse;
releaseHost: () => HostIsolationResponse;
actionDetails: (options: HttpFetchOptionsWithPath) => ActionDetailsApiResponse;
agentPendingActionsSummary: (options: HttpFetchOptionsWithPath) => PendingActionsResponse;
}>;
export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHttpMocksInterface>([
{
id: 'isolateHost',
path: ISOLATE_HOST_ROUTE,
method: 'post',
handler: (): HostIsolationResponse => {
return { action: '1-2-3' };
},
},
{
id: 'releaseHost',
path: UNISOLATE_HOST_ROUTE,
method: 'post',
handler: (): HostIsolationResponse => {
return { action: '3-2-1' };
},
},
{
id: 'actionDetails',
path: ACTION_DETAILS_ROUTE,
method: 'get',
handler: ({ path }): ActionDetailsApiResponse => {
const response = new EndpointActionGenerator('seed').generateActionDetails();
// use the ID of the action in the response
response.id = path.substring(path.lastIndexOf('/') + 1) || response.id;
return { data: response };
},
},
{
id: 'agentPendingActionsSummary',
path: ACTION_STATUS_ROUTE,
method: 'get',
handler: ({ query }): PendingActionsResponse => {
const generator = new EndpointActionGenerator('seed');
return {
data: (query as { agent_ids: string[] }).agent_ids.map((id) =>
generator.generateAgentPendingActionsSummary({ agent_id: id })
),
};
},
},
]);

View file

@ -753,33 +753,6 @@ exports[`Details Panel Component DetailsPanel:HostDetails: rendering it should r
color: #535966;
}
.c2 dt {
font-size: 12px !important;
}
.c2 dd {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c2 dd > div {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c1 {
position: relative;
}
.c1 .euiButtonIcon {
position: absolute;
right: 12px;
top: 6px;
z-index: 2;
}
.c0 {
width: 100%;
display: -webkit-box;
@ -808,6 +781,33 @@ exports[`Details Panel Component DetailsPanel:HostDetails: rendering it should r
opacity: 1;
}
.c2 dt {
font-size: 12px !important;
}
.c2 dd {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c2 dd > div {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c1 {
position: relative;
}
.c1 .euiButtonIcon {
position: absolute;
right: 12px;
top: 6px;
z-index: 2;
}
.c4 {
padding: 16px;
background: rgba(250,251,253,0.9);
@ -1894,33 +1894,6 @@ exports[`Details Panel Component DetailsPanel:NetworkDetails: rendering it shoul
color: #535966;
}
.c2 dt {
font-size: 12px !important;
}
.c2 dd {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c2 dd > div {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c1 {
position: relative;
}
.c1 .euiButtonIcon {
position: absolute;
right: 12px;
top: 6px;
z-index: 2;
}
.c0 {
width: 100%;
display: -webkit-box;
@ -1949,6 +1922,33 @@ exports[`Details Panel Component DetailsPanel:NetworkDetails: rendering it shoul
opacity: 1;
}
.c2 dt {
font-size: 12px !important;
}
.c2 dd {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c2 dd > div {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c1 {
position: relative;
}
.c1 .euiButtonIcon {
position: absolute;
right: 12px;
top: 6px;
z-index: 2;
}
.c4 {
padding: 16px;
background: rgba(250,251,253,0.9);

View file

@ -13,6 +13,7 @@ import { Actions, isAlert } from '.';
import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
jest.mock('../../../../../detections/components/user_info', () => ({
useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]),
@ -20,9 +21,7 @@ jest.mock('../../../../../detections/components/user_info', () => ({
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(false),
}));
jest.mock('../../../../../common/hooks/use_selector', () => ({
useShallowEqualSelector: jest.fn().mockReturnValue(mockTimelineModel),
}));
jest.mock('../../../../../common/hooks/use_selector');
jest.mock(
'../../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline',
() => ({
@ -87,6 +86,10 @@ const defaultProps = {
};
describe('Actions', () => {
beforeAll(() => {
(useShallowEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel);
});
test('it renders a checkbox for selecting the event when `showCheckboxes` is `true`', () => {
const wrapper = mount(
<TestProviders>

View file

@ -44,6 +44,8 @@ describe('When using `getActionDetailsById()', () => {
agents: ['agent-a'],
command: 'isolate',
completedAt: '2022-04-30T16:08:47.449Z',
wasSuccessful: true,
error: undefined,
id: '123',
isCompleted: true,
isExpired: false,

View file

@ -110,7 +110,7 @@ export const getActionDetailsById = async (
throw new NotFoundError(`Action with id '${actionId}' not found.`);
}
const { isCompleted, completedAt } = getActionCompletionInfo(
const { isCompleted, completedAt, wasSuccessful, errors } = getActionCompletionInfo(
normalizedActionRequest.agents,
actionResponses
);
@ -123,6 +123,8 @@ export const getActionDetailsById = async (
logEntries: [...actionRequestsLogEntries, ...actionResponses],
isCompleted,
completedAt,
wasSuccessful,
errors,
isExpired: !isCompleted && normalizedActionRequest.expiration < new Date().toISOString(),
};

View file

@ -87,11 +87,13 @@ describe('When using Actions service utilities', () => {
});
});
describe('#getAction CompletionInfo()', () => {
describe('#getActionCompletionInfo()', () => {
const COMPLETED_AT = '2022-05-05T18:53:18.836Z';
const NOT_COMPLETED_OUTPUT = Object.freeze({
isCompleted: false,
completed: undefined,
completedAt: undefined,
wasSuccessful: false,
errors: undefined,
});
it('should show complete `false` if no action ids', () => {
@ -131,7 +133,70 @@ describe('When using Actions service utilities', () => {
}),
]
)
).toEqual({ isCompleted: true, completedAt: COMPLETED_AT });
).toEqual({
isCompleted: true,
completedAt: COMPLETED_AT,
errors: undefined,
wasSuccessful: true,
});
});
describe('and action failed', () => {
let fleetResponseAtError: ActivityLogActionResponse;
let endpointResponseAtError: EndpointActivityLogActionResponse;
beforeEach(() => {
fleetResponseAtError = fleetActionGenerator.generateActivityLogActionResponse({
item: { data: { agent_id: '123', error: 'agent failed to deliver' } },
});
endpointResponseAtError = endpointActionGenerator.generateActivityLogActionResponse({
item: {
data: {
'@timestamp': '2022-05-18T13:03:54.756Z',
agent: { id: '123' },
error: {
message: 'endpoint failed to apply',
},
EndpointActions: {
completed_at: '2022-05-18T13:03:54.756Z',
},
},
},
});
});
it('should show `wasSuccessful` as `false` if endpoint action response has error', () => {
expect(getActionCompletionInfo(['123'], [endpointResponseAtError])).toEqual({
completedAt: endpointResponseAtError.item.data['@timestamp'],
errors: ['Endpoint action response error: endpoint failed to apply'],
isCompleted: true,
wasSuccessful: false,
});
});
it('should show `wasSuccessful` as `false` if fleet action response has error (no endpoint response)', () => {
expect(getActionCompletionInfo(['123'], [fleetResponseAtError])).toEqual({
completedAt: fleetResponseAtError.item.data.completed_at,
errors: ['Fleet action response error: agent failed to deliver'],
isCompleted: true,
wasSuccessful: false,
});
});
it('should include both fleet and endpoint errors if both responses returned failure', () => {
expect(
getActionCompletionInfo(['123'], [fleetResponseAtError, endpointResponseAtError])
).toEqual({
completedAt: endpointResponseAtError.item.data['@timestamp'],
errors: [
'Endpoint action response error: endpoint failed to apply',
'Fleet action response error: agent failed to deliver',
],
isCompleted: true,
wasSuccessful: false,
});
});
});
describe('with multiple agent ids', () => {
@ -212,7 +277,12 @@ describe('When using Actions service utilities', () => {
...action456Responses,
...action789Responses,
])
).toEqual({ isCompleted: true, completedAt: COMPLETED_AT });
).toEqual({
isCompleted: true,
completedAt: COMPLETED_AT,
wasSuccessful: true,
errors: undefined,
});
});
it('should complete as `true` if one agent only received a fleet response with error on it', () => {
@ -228,7 +298,12 @@ describe('When using Actions service utilities', () => {
...action789Responses,
])
).toEqual({ isCompleted: true, completedAt: '2022-05-06T12:50:19.747Z' });
).toEqual({
completedAt: '2022-05-06T12:50:19.747Z',
errors: ['Fleet action response error: something is no good'],
isCompleted: true,
wasSuccessful: false,
});
});
});
});

View file

@ -85,6 +85,8 @@ export const mapToNormalizedActionRequest = (
interface ActionCompletionInfo {
isCompleted: boolean;
completedAt: undefined | string;
wasSuccessful: boolean;
errors: undefined | string[];
}
export const getActionCompletionInfo = (
@ -96,6 +98,8 @@ export const getActionCompletionInfo = (
const completedInfo: ActionCompletionInfo = {
isCompleted: Boolean(agentIds.length),
completedAt: undefined,
wasSuccessful: Boolean(agentIds.length),
errors: undefined,
};
const responsesByAgentId = mapActionResponsesByAgentId(actionResponses);
@ -103,12 +107,15 @@ export const getActionCompletionInfo = (
for (const agentId of agentIds) {
if (!responsesByAgentId[agentId] || !responsesByAgentId[agentId].isCompleted) {
completedInfo.isCompleted = false;
completedInfo.wasSuccessful = false;
break;
}
}
// If completed, then get the completed at date
// If completed, then get the completed at date and determine if action was successful or not
if (completedInfo.isCompleted) {
const responseErrors: ActionCompletionInfo['errors'] = [];
for (const normalizedAgentResponse of Object.values(responsesByAgentId)) {
if (
!completedInfo.completedAt ||
@ -116,6 +123,17 @@ export const getActionCompletionInfo = (
) {
completedInfo.completedAt = normalizedAgentResponse.completedAt;
}
if (!normalizedAgentResponse.wasSuccessful) {
completedInfo.wasSuccessful = false;
responseErrors.push(
...(normalizedAgentResponse.errors ? normalizedAgentResponse.errors : [])
);
}
}
if (responseErrors.length) {
completedInfo.errors = responseErrors;
}
}
@ -125,6 +143,8 @@ export const getActionCompletionInfo = (
interface NormalizedAgentActionResponse {
isCompleted: boolean;
completedAt: undefined | string;
wasSuccessful: boolean;
errors: undefined | string[];
fleetResponse: undefined | ActivityLogActionResponse;
endpointResponse: undefined | EndpointActivityLogActionResponse;
}
@ -150,6 +170,8 @@ const mapActionResponsesByAgentId = (
response[agentId] = {
isCompleted: false,
completedAt: undefined,
wasSuccessful: false,
errors: undefined,
fleetResponse: undefined,
endpointResponse: undefined,
};
@ -172,21 +194,42 @@ const mapActionResponsesByAgentId = (
// endpoint, so we are unlikely to ever receive an Endpoint Response.
Boolean(thisAgentActionResponses.fleetResponse?.item.data.error);
// When completed, calculate additional properties about the action
if (thisAgentActionResponses.isCompleted) {
if (thisAgentActionResponses.endpointResponse) {
thisAgentActionResponses.completedAt =
thisAgentActionResponses.endpointResponse?.item.data['@timestamp'];
thisAgentActionResponses.wasSuccessful = true;
} else if (
thisAgentActionResponses.fleetResponse &&
thisAgentActionResponses.fleetResponse?.item.data.error
) {
// Check if perhaps the Fleet action response returned an error, in which case, the Fleet Agent
// failed to deliver the Action to the Endpoint. If that's the case, we are not going to get
// a Response from endpoint, thus mark the Action as completed and use the Fleet Message's
// timestamp for the complete data/time.
thisAgentActionResponses.fleetResponse &&
thisAgentActionResponses.fleetResponse.item.data.error
) {
thisAgentActionResponses.isCompleted = true;
thisAgentActionResponses.completedAt =
thisAgentActionResponses.fleetResponse?.item.data['@timestamp'];
thisAgentActionResponses.fleetResponse.item.data['@timestamp'];
}
const errors: NormalizedAgentActionResponse['errors'] = [];
if (thisAgentActionResponses.endpointResponse?.item.data.error?.message) {
errors.push(
`Endpoint action response error: ${thisAgentActionResponses.endpointResponse.item.data.error.message}`
);
}
if (thisAgentActionResponses.fleetResponse?.item.data.error) {
errors.push(
`Fleet action response error: ${thisAgentActionResponses.fleetResponse?.item.data.error}`
);
}
if (errors.length) {
thisAgentActionResponses.wasSuccessful = false;
thisAgentActionResponses.errors = errors;
}
}
}

View file

@ -23723,7 +23723,6 @@
"xpack.securitySolution.console.commandValidation.unsupportedArg": "argument non pris en charge : {argName}",
"xpack.securitySolution.console.unknownCommand.helpMessage": "Pour obtenir la liste des commandes disponibles, entrez : {helpCmd}.",
"xpack.securitySolution.console.unknownCommand.title": "Commande inconnue",
"xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel": "Consoles de point de terminaison",
"xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "Impossible d'interroger les données d'anomalies",
"xpack.securitySolution.containers.anomalies.stackByJobId": "tâche",
"xpack.securitySolution.containers.anomalies.title": "Anomalies",

View file

@ -23860,7 +23860,6 @@
"xpack.securitySolution.console.commandValidation.unsupportedArg": "サポートされていない引数:{argName}",
"xpack.securitySolution.console.unknownCommand.helpMessage": "使用可能なコマンドのリストについては、{helpCmd}を入力します",
"xpack.securitySolution.console.unknownCommand.title": "不明なコマンド",
"xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel": "エンドポイントコンソール",
"xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "異常データをクエリできませんでした",
"xpack.securitySolution.containers.anomalies.stackByJobId": "ジョブ",
"xpack.securitySolution.containers.anomalies.title": "異常",

View file

@ -23892,7 +23892,6 @@
"xpack.securitySolution.console.commandValidation.unsupportedArg": "不支持的参数:{argName}",
"xpack.securitySolution.console.unknownCommand.helpMessage": "如需可用命令列表,请输入:{helpCmd}",
"xpack.securitySolution.console.unknownCommand.title": "未知命令",
"xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel": "终端控制台",
"xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "无法查询异常数据",
"xpack.securitySolution.containers.anomalies.stackByJobId": "作业",
"xpack.securitySolution.containers.anomalies.title": "异常",