SentinelOne manual host isolation/release in Alert details (#168441)

## Summary

Add possibility to Isolate/Release SentinelOne host from Alert details
flyout.
Add support for displaying S1 Agent status in UI.
Add an experimental flag to S1 Connector.
Rename S1 connector actions from `Agent` to `Host`
Add a feature flag to security_solution to control enrollment of this
feature.
Update parallel script to support all FTR config options
Add `cypress-data-session` plugin to allow better caching of test data
(mostly for Dev experience)

Testing instruction:
1. Ensure you have 
2. From root Kibana folder run
https://p.elstc.co/paste/URVrCEcR#aG1X9p3BMCRUDY+IzfIg5mGomcTGxwkYO6RGxSIAyWz
3. In Cypress run
```x-pack/plugins/security_solution/public/management/cypress/e2e/sentinelone/isolate.cy.ts```
4. 💚 

<img width="2375" alt="Zrzut ekranu 2023-11-15 o 12 38 27"
src="c7ddc20e-9944-452c-b739-fa2d9fbf072b">
<img width="2374" alt="Zrzut ekranu 2023-11-15 o 12 38 32"
src="ab3ced14-0a5c-4f40-a92e-844feb849bb4">
<img width="2370" alt="Zrzut ekranu 2023-11-15 o 12 38 38"
src="96ccd237-56a6-449e-979d-f4fe8ffbe048">
<img width="2373" alt="Zrzut ekranu 2023-11-15 o 12 38 46"
src="924013aa-79ef-405b-ae73-139cf0644ebf">
<img width="2374" alt="Zrzut ekranu 2023-11-15 o 12 39 17"
src="e1ff5b05-8b80-40a9-84b1-dd21bf9e059c">
<img width="2374" alt="Zrzut ekranu 2023-11-15 o 12 39 58"
src="15fc5d36-970f-47cb-ae2f-f8a19628e6f4">
<img width="2374" alt="Zrzut ekranu 2023-11-15 o 12 40 03"
src="5860a0c9-a6e5-43b9-b37d-aa68e4e71f26">
<img width="2373" alt="Zrzut ekranu 2023-11-15 o 12 40 09"
src="5e2c5d41-c96a-4c32-8d51-a8408efea8e3">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Patryk Kopyciński 2023-12-05 08:16:55 +00:00 committed by GitHub
parent edf4f35152
commit 2d0f99c59c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1636 additions and 355 deletions

View file

@ -902,6 +902,7 @@
"css-box-model": "^1.2.1",
"css.escape": "^1.5.1",
"cuid": "^2.1.8",
"cypress-data-session": "^2.7.0",
"cytoscape": "^3.10.0",
"cytoscape-dagre": "^2.2.2",
"d3": "3.5.17",

View file

@ -223,20 +223,24 @@ export class ActionTypeRegistry {
* Returns a list of registered action types [{ id, name, enabled }], filtered by featureId if provided.
*/
public list(featureId?: string): CommonActionType[] {
return Array.from(this.actionTypes)
.filter(([_, actionType]) =>
featureId ? actionType.supportedFeatureIds.includes(featureId) : true
)
.map(([actionTypeId, actionType]) => ({
id: actionTypeId,
name: actionType.name,
minimumLicenseRequired: actionType.minimumLicenseRequired,
enabled: this.isActionTypeEnabled(actionTypeId),
enabledInConfig: this.actionsConfigUtils.isActionTypeEnabled(actionTypeId),
enabledInLicense: !!this.licenseState.isLicenseValidForActionType(actionType).isValid,
supportedFeatureIds: actionType.supportedFeatureIds,
isSystemActionType: !!actionType.isSystemActionType,
}));
return (
Array.from(this.actionTypes)
.filter(([_, actionType]) =>
featureId ? actionType.supportedFeatureIds.includes(featureId) : true
)
// Temporarily don't return SentinelOne connector for Security Solution Rule Actions
.filter(([actionTypeId]) => (featureId ? actionTypeId !== '.sentinelone' : true))
.map(([actionTypeId, actionType]) => ({
id: actionTypeId,
name: actionType.name,
minimumLicenseRequired: actionType.minimumLicenseRequired,
enabled: this.isActionTypeEnabled(actionTypeId),
enabledInConfig: this.actionsConfigUtils.isActionTypeEnabled(actionTypeId),
enabledInLicense: !!this.licenseState.isLicenseValidForActionType(actionType).isValid,
supportedFeatureIds: actionType.supportedFeatureIds,
isSystemActionType: !!actionType.isSystemActionType,
}))
);
}
/**

View file

@ -141,6 +141,11 @@ export const allowedExperimentalValues = Object.freeze({
* Enables experimental Entity Analytics Asset Criticality feature
*/
entityAnalyticsAssetCriticalityEnabled: false,
/**
* Enables SentinelOne manual host manipulation actions
*/
sentinelOneManualHostActionsEnabled: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -29,15 +29,15 @@ import { useGetEndpointDetails } from '../../../../management/hooks';
import { getAgentStatusText } from '../agent_status_text';
const TOOLTIP_CONTENT_STYLES: React.CSSProperties = Object.freeze({ width: 150 });
const ISOLATING_LABEL = i18n.translate(
export const ISOLATING_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isIsolating',
{ defaultMessage: 'Isolating' }
);
const RELEASING_LABEL = i18n.translate(
export const RELEASING_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isUnIsolating',
{ defaultMessage: 'Releasing' }
);
const ISOLATED_LABEL = i18n.translate(
export const ISOLATED_LABEL = i18n.translate(
'xpack.securitySolution.endpoint.agentAndActionsStatus.isolated',
{ defaultMessage: 'Isolated' }
);

View file

@ -31,10 +31,20 @@ export interface EndpointIsolatedFormProps {
messageAppend?: ReactNode;
/** If true, then `Confirm` and `Cancel` buttons will be disabled, and `Confirm` button will loading loading style */
isLoading?: boolean;
hideCommentField?: boolean;
}
export const EndpointIsolateForm = memo<EndpointIsolatedFormProps>(
({ hostName, onCancel, onConfirm, onChange, comment = '', messageAppend, isLoading = false }) => {
({
hostName,
onCancel,
onConfirm,
onChange,
comment = '',
messageAppend,
isLoading = false,
hideCommentField = false,
}) => {
const handleCommentChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
onChange({ comment: event.target.value });
@ -66,15 +76,17 @@ export const EndpointIsolateForm = memo<EndpointIsolatedFormProps>(
</EuiText>
</EuiFormRow>
<EuiFormRow label={COMMENT} fullWidth>
<EuiTextArea
data-test-subj="host_isolation_comment"
fullWidth
placeholder={COMMENT_PLACEHOLDER}
value={comment}
onChange={handleCommentChange}
/>
</EuiFormRow>
{!hideCommentField && (
<EuiFormRow label={COMMENT} fullWidth>
<EuiTextArea
data-test-subj="host_isolation_comment"
fullWidth
placeholder={COMMENT_PLACEHOLDER}
value={comment}
onChange={handleCommentChange}
/>
</EuiFormRow>
)}
<EuiFormRow fullWidth>
<EuiFlexGroup justifyContent="flexEnd">

View file

@ -23,7 +23,16 @@ import { CANCEL, COMMENT, COMMENT_PLACEHOLDER, CONFIRM, UNISOLATE, ISOLATED } fr
import type { EndpointIsolatedFormProps } from './isolate_form';
export const EndpointUnisolateForm = memo<EndpointIsolatedFormProps>(
({ hostName, onCancel, onConfirm, onChange, comment = '', messageAppend, isLoading = false }) => {
({
hostName,
onCancel,
onConfirm,
onChange,
comment = '',
messageAppend,
isLoading = false,
hideCommentField = false,
}) => {
const handleCommentChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
onChange({ comment: event.target.value });
@ -52,15 +61,17 @@ export const EndpointUnisolateForm = memo<EndpointIsolatedFormProps>(
</EuiText>
</EuiFormRow>
<EuiFormRow label={COMMENT} fullWidth>
<EuiTextArea
data-test-subj="host_isolation_comment"
fullWidth
placeholder={COMMENT_PLACEHOLDER}
value={comment}
onChange={handleCommentChange}
/>
</EuiFormRow>
{!hideCommentField && (
<EuiFormRow label={COMMENT} fullWidth>
<EuiTextArea
data-test-subj="host_isolation_comment"
fullWidth
placeholder={COMMENT_PLACEHOLDER}
value={comment}
onChange={handleCommentChange}
/>
</EuiFormRow>
)}
<EuiFormRow fullWidth>
<EuiFlexGroup justifyContent="flexEnd">

View file

@ -11,6 +11,7 @@ import type { TimelineEventsDetailsItem } from '../../../../common/search_strate
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import { getSummaryRows } from './get_alert_summary_rows';
import { SummaryView } from './summary_view';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
const AlertSummaryViewComponent: React.FC<{
browserFields: BrowserFields;
@ -33,6 +34,10 @@ const AlertSummaryViewComponent: React.FC<{
isReadOnly,
investigationFields,
}) => {
const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
const summaryRows = useMemo(
() =>
getSummaryRows({
@ -43,8 +48,18 @@ const AlertSummaryViewComponent: React.FC<{
scopeId,
isReadOnly,
investigationFields,
sentinelOneManualHostActionsEnabled,
}),
[browserFields, data, eventId, isDraggable, scopeId, isReadOnly, investigationFields]
[
browserFields,
data,
eventId,
isDraggable,
scopeId,
isReadOnly,
investigationFields,
sentinelOneManualHostActionsEnabled,
]
);
return (

View file

@ -34,6 +34,10 @@ import type { EventSummaryField, EnrichedFieldInfo } from './types';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
import { isAlertFromEndpointEvent } from '../../utils/endpoint_alert_check';
import {
SENTINEL_ONE_AGENT_ID_FIELD,
isAlertFromSentinelOneEvent,
} from '../../utils/sentinelone_alert_check';
const THRESHOLD_TERMS_FIELD = `${ALERT_THRESHOLD_RESULT}.terms.field`;
const THRESHOLD_TERMS_VALUE = `${ALERT_THRESHOLD_RESULT}.terms.value`;
@ -44,7 +48,13 @@ const THRESHOLD_COUNT = `${ALERT_THRESHOLD_RESULT}.count`;
/** Always show these fields */
const alwaysDisplayedFields: EventSummaryField[] = [
{ id: 'host.name' },
// ENDPOINT-related field //
{ id: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS },
{
id: SENTINEL_ONE_AGENT_ID_FIELD,
label: i18n.AGENT_STATUS,
},
// ** //
{ id: 'user.name' },
{ id: 'rule.name' },
{ id: 'cloud.provider' },
@ -294,6 +304,7 @@ export const getSummaryRows = ({
isDraggable = false,
isReadOnly = false,
investigationFields,
sentinelOneManualHostActionsEnabled,
}: {
data: TimelineEventsDetailsItem[];
browserFields: BrowserFields;
@ -302,6 +313,7 @@ export const getSummaryRows = ({
investigationFields?: string[];
isDraggable?: boolean;
isReadOnly?: boolean;
sentinelOneManualHostActionsEnabled?: boolean;
}) => {
const eventCategories = getEventCategoriesFromData(data);
@ -357,6 +369,14 @@ export const getSummaryRows = ({
return acc;
}
if (
field.id === SENTINEL_ONE_AGENT_ID_FIELD &&
sentinelOneManualHostActionsEnabled &&
!isAlertFromSentinelOneEvent({ data })
) {
return acc;
}
if (field.id === THRESHOLD_TERMS_FIELD) {
const enrichedInfo = enrichThresholdTerms(item, data, description);
if (enrichedInfo) {

View file

@ -23,6 +23,7 @@ import {
AGENT_STATUS_FIELD_NAME,
QUARANTINED_PATH_FIELD_NAME,
} from '../../../timelines/components/timeline/body/renderers/constants';
import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../utils/sentinelone_alert_check';
/**
* Defines the behavior of the search input that appears above the table of data
@ -181,6 +182,7 @@ export function getEnrichedFieldInfo({
export const FIELDS_WITHOUT_ACTIONS: { [field: string]: boolean } = {
[AGENT_STATUS_FIELD_NAME]: true,
[QUARANTINED_PATH_FIELD_NAME]: true,
[SENTINEL_ONE_AGENT_ID_FIELD]: true,
};
/**

View file

@ -78,8 +78,8 @@ export const ResponseActionsResults = React.memo(
ResponseActionsResults.displayName = 'ResponseActionsResults';
const isOsquery = (item: LogsEndpointAction | LogsOsqueryAction): item is LogsOsqueryAction => {
return item && 'input_type' in item && item?.input_type === 'osquery';
return !!(item && 'input_type' in item && item?.input_type === 'osquery');
};
const isEndpoint = (item: LogsEndpointAction | LogsOsqueryAction): item is LogsEndpointAction => {
return item && 'EndpointActions' in item && item?.EndpointActions.input_type === 'endpoint';
return !!(item && 'EndpointActions' in item && item?.EndpointActions?.input_type === 'endpoint');
};

View file

@ -0,0 +1,15 @@
/*
* 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';
export const LOAD_CONNECTORS_ERROR_MESSAGE = i18n.translate(
'xpack.securitySolution.connectors.useLoadConnectors.errorMessage',
{
defaultMessage: 'Error loading connectors. Please check your configuration and try again.',
}
);

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { ServerError } from '@kbn/cases-plugin/public/types';
import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import type { IToasts } from '@kbn/core-notifications-browser';
import * as i18n from '../translations';
import { useHttp } from '../../../lib/kibana';
export interface Props {
actionTypeId: string;
toasts?: IToasts;
}
/**
* Hook to load all connectors for a given action type.
* @param actionTypeId
* @param toasts
*/
export const useLoadConnectors = ({
actionTypeId,
toasts,
}: Props): UseQueryResult<ActionConnector[], IHttpFetchError> => {
const http = useHttp();
return useQuery(
['load-connectors', actionTypeId],
async () => {
const queryResult = await loadConnectors({ http });
const filteredData = queryResult.filter(
(connector) => !connector.isMissingSecrets && connector.actionTypeId === actionTypeId
);
return filteredData;
},
{
retry: false,
keepPreviousData: true,
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts?.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: i18n.LOAD_CONNECTORS_ERROR_MESSAGE,
}
);
}
},
}
);
};

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 { find, getOr, some } from 'lodash/fp';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { getFieldValue } from '../../detections/components/host_isolation/helpers';
/**
* Check to see if a timeline event item is an Alert (vs an event)
* @param timelineEventItem
*/
export const isTimelineEventItemAnAlert = (
timelineEventItem: TimelineEventsDetailsItem[]
): boolean => {
return some({ category: 'kibana', field: 'kibana.alert.rule.uuid' }, timelineEventItem);
};
export const SENTINEL_ONE_AGENT_ID_FIELD = 'observer.serial_number';
export const getSentinelOneAgentId = (data: TimelineEventsDetailsItem[] | null) =>
getFieldValue({ category: 'observer', field: SENTINEL_ONE_AGENT_ID_FIELD }, data) || undefined;
/**
* Checks to see if the given set of Timeline event detail items includes data that indicates its
* an endpoint Alert. Note that it will NOT match on Events - only alerts
* @param data
*/
export const isAlertFromSentinelOneEvent = ({
data,
}: {
data: TimelineEventsDetailsItem[];
}): boolean => {
if (!isTimelineEventItemAnAlert(data)) {
return false;
}
const findEndpointAlert = find({ field: 'event.module' }, data)?.values;
return findEndpointAlert ? findEndpointAlert[0] === 'sentinel_one' : false;
};
/**
* Checks to see if the given alert was generated out of the SentinelOne Alerts dataset, coming from
* sentinel_one Fleet integration
* @param ecsData
*/
export const isAlertFromSentinelOneAlert = ({
ecsData,
}: {
ecsData: Ecs | null | undefined;
}): boolean => {
if (ecsData == null) {
return false;
}
const eventModules = getOr([], 'kibana.alert.original_event.module', ecsData);
const kinds = getOr([], 'kibana.alert.original_event.dataset', ecsData);
return eventModules.includes('sentinel_one') && kinds.includes('alert');
};

View file

@ -1416,7 +1416,7 @@ describe('Exception helpers', () => {
'execute',
'upload_file',
];
const alertData = {
const alertData: AlertData = {
'kibana.alert.rule.category': 'Custom Query Rule',
'kibana.alert.rule.consumer': 'siem',
'kibana.alert.rule.execution.uuid': '28b687e3-8e16-48aa-91b8-bf044d366c2d',
@ -1471,6 +1471,7 @@ describe('Exception helpers', () => {
port: 443,
},
source: {
// @ts-expect-error
ports: [1, 2, 4],
},
flow: {
@ -1774,6 +1775,10 @@ describe('Exception helpers', () => {
overrideField: 'agent.status',
label: 'Agent status',
},
{
id: 'observer.serial_number',
label: 'Agent status',
},
{
id: 'cloud.provider',
},
@ -1794,6 +1799,10 @@ describe('Exception helpers', () => {
label: 'Agent status',
overrideField: 'agent.status',
},
{
id: 'observer.serial_number',
label: 'Agent status',
},
{
id: 'user.name',
},

View file

@ -871,7 +871,7 @@ export const buildRuleExceptionWithConditions = ({
};
/**
Generate exception conditions based on the highlighted fields of the alert that
Generate exception conditions based on the highlighted fields of the alert that
have corresponding values in the alert data.
For the initial implementation the nested conditions are not considered
Converting a singular value to a string or an array of strings
@ -939,10 +939,10 @@ export const getPrepopulatedRuleExceptionWithHighlightFields = ({
};
/**
Filters out the irrelevant highlighted fields for Rule exceptions using
Filters out the irrelevant highlighted fields for Rule exceptions using
1. The "highlightedFieldsPrefixToExclude" array
2. Agent.id field in case the alert was not generated from Endpoint
3. Threshold Rule
3. Threshold Rule
*/
export const filterHighlightedFields = (
fields: EventSummaryField[],

View file

@ -6,10 +6,12 @@
*/
import React, { useMemo } from 'react';
import { find } from 'lodash/fp';
import type { Maybe } from '@kbn/observability-plugin/common/typings';
import { getSentinelOneAgentId } from '../../../common/utils/sentinelone_alert_check';
import { useCasesFromAlerts } from '../../containers/detection_engine/alerts/use_cases_from_alerts';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { IsolateSentinelOneHost } from './isolate_sentinelone';
import { UnisolateSentinelOneHost } from './unisolate_sentinelone';
import { getFieldValue } from './helpers';
import { IsolateHost } from './isolate';
import { UnisolateHost } from './unisolate';
@ -20,28 +22,48 @@ export const HostIsolationPanel = React.memo(
successCallback,
isolateAction,
}: {
details: Maybe<TimelineEventsDetailsItem[]>;
details: TimelineEventsDetailsItem[] | null;
cancelCallback: () => void;
successCallback?: () => void;
isolateAction: string;
}) => {
const endpointId = useMemo(() => {
const findEndpointId = find({ category: 'agent', field: 'agent.id' }, details)?.values;
return findEndpointId ? findEndpointId[0] : '';
}, [details]);
const endpointId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, details),
[details]
);
const hostName = useMemo(() => {
const findHostName = find({ category: 'host', field: 'host.name' }, details)?.values;
return findHostName ? findHostName[0] : '';
}, [details]);
const sentinelOneAgentId = useMemo(() => getSentinelOneAgentId(details), [details]);
const alertId = useMemo(() => {
const findAlertId = find({ category: '_id', field: '_id' }, details)?.values;
return findAlertId ? findAlertId[0] : '';
}, [details]);
const hostName = useMemo(
() => getFieldValue({ category: 'host', field: 'host.name' }, details),
[details]
);
const alertId = useMemo(
() => getFieldValue({ category: '_id', field: '_id' }, details),
[details]
);
const { casesInfo } = useCasesFromAlerts({ alertId });
if (sentinelOneAgentId) {
return isolateAction === 'isolateHost' ? (
<IsolateSentinelOneHost
sentinelOneAgentId={sentinelOneAgentId}
hostName={hostName}
cancelCallback={cancelCallback}
successCallback={successCallback}
/>
) : (
<UnisolateSentinelOneHost
sentinelOneAgentId={sentinelOneAgentId}
hostName={hostName}
cancelCallback={cancelCallback}
successCallback={successCallback}
/>
);
}
return isolateAction === 'isolateHost' ? (
<IsolateHost
endpointId={endpointId}

View file

@ -0,0 +1,91 @@
/*
* 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, useState, useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { SENTINELONE_CONNECTOR_ID, SUB_ACTION } from '@kbn/stack-connectors-plugin/public/common';
import { useLoadConnectors } from '../../../common/components/response_actions/use_load_connectors';
import { useSubActionMutation } from '../../../timelines/components/side_panel/event_details/flyout/use_sub_action_mutation';
import { RETURN_TO_ALERT_DETAILS } from './translations';
import {
EndpointIsolateForm,
ActionCompletionReturnButton,
} from '../../../common/components/endpoint/host_isolation';
export const IsolateSentinelOneHost = React.memo(
({
sentinelOneAgentId,
hostName,
cancelCallback,
successCallback,
}: {
sentinelOneAgentId: string;
hostName: string;
cancelCallback: () => void;
successCallback?: () => void;
}) => {
const { data: connectors } = useLoadConnectors({ actionTypeId: SENTINELONE_CONNECTOR_ID });
const connector = useMemo(() => connectors?.[0], [connectors]);
const [isIsolated, setIsIsolated] = useState(false);
const { mutateAsync: isolateHost, isLoading } = useSubActionMutation({
connectorId: connector?.id as string,
subAction: SUB_ACTION.ISOLATE_HOST,
subActionParams: {
uuid: sentinelOneAgentId,
},
});
const onChange = useCallback(() => {}, []);
const confirmHostIsolation = useCallback(async () => {
const response = await isolateHost();
if (response.status === 'ok') {
setIsIsolated(true);
if (successCallback) {
successCallback();
}
}
}, [isolateHost, successCallback]);
const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]);
const hostIsolatedSuccessButton = useMemo(
() => (
<ActionCompletionReturnButton
onClick={backToAlertDetails}
buttonText={RETURN_TO_ALERT_DETAILS}
/>
),
[backToAlertDetails]
);
const hostNotIsolated = useMemo(
() => (
<>
<EuiSpacer size="m" />
<EndpointIsolateForm
hostName={hostName}
onCancel={backToAlertDetails}
onConfirm={confirmHostIsolation}
isLoading={isLoading}
onChange={onChange}
hideCommentField
/>
</>
),
[hostName, backToAlertDetails, confirmHostIsolation, isLoading, onChange]
);
return isIsolated ? hostIsolatedSuccessButton : hostNotIsolated;
}
);
IsolateSentinelOneHost.displayName = 'IsolateSentinelOneHost';

View file

@ -0,0 +1,97 @@
/*
* 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 { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import type { SentinelOneAgent } from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import { HostStatus } from '../../../../common/endpoint/types';
import { getAgentStatusText } from '../../../common/components/endpoint/agent_status_text';
import { HOST_STATUS_TO_BADGE_COLOR } from '../../../management/pages/endpoint_hosts/view/host_constants';
import { useSentinelOneAgentData } from './use_sentinelone_host_isolation';
import {
ISOLATING_LABEL,
ISOLATED_LABEL,
RELEASING_LABEL,
} from '../../../common/components/endpoint/endpoint_agent_status';
const getSentinelOneAgentStatus = (data?: SentinelOneAgent) => {
if (!data) {
return HostStatus.UNENROLLED;
}
if (!data?.isActive) {
return HostStatus.OFFLINE;
}
return HostStatus.HEALTHY;
};
export enum SENTINEL_ONE_NETWORK_STATUS {
CONNECTING = 'connecting',
CONNECTED = 'connected',
DISCONNECTING = 'disconnecting',
DISCONNECTED = 'disconnected',
}
const EuiFlexGroupStyled = styled(EuiFlexGroup)`
.isolation-status {
margin-left: ${({ theme }) => theme.eui.euiSizeS};
}
`;
export const SentinelOneAgentStatus = React.memo(
({ agentId, dataTestSubj }: { agentId: string; dataTestSubj?: string }) => {
const { data, isFetched } = useSentinelOneAgentData({ agentId });
const label = useMemo(() => {
const networkStatus = data?.data?.data?.[0]?.networkStatus;
if (networkStatus === SENTINEL_ONE_NETWORK_STATUS.DISCONNECTING) {
return ISOLATING_LABEL;
}
if (networkStatus === SENTINEL_ONE_NETWORK_STATUS.DISCONNECTED) {
return ISOLATED_LABEL;
}
if (networkStatus === SENTINEL_ONE_NETWORK_STATUS.CONNECTING) {
return RELEASING_LABEL;
}
}, [data?.data?.data]);
const agentStatus = useMemo(() => getSentinelOneAgentStatus(data?.data?.data?.[0]), [data]);
return (
<EuiFlexGroupStyled
gutterSize="none"
responsive={false}
className="eui-textTruncate"
data-test-subj={dataTestSubj}
>
<EuiFlexItem grow={false}>
{isFetched ? (
<EuiBadge color={HOST_STATUS_TO_BADGE_COLOR[agentStatus]} className="eui-textTruncate">
{getAgentStatusText(agentStatus)}
</EuiBadge>
) : (
'-'
)}
</EuiFlexItem>
{label && (
<EuiFlexItem grow={false} className="eui-textTruncate isolation-status">
<EuiBadge color="hollow" data-test-subj={dataTestSubj}>
<>{label}</>
</EuiBadge>
</EuiFlexItem>
)}
</EuiFlexGroupStyled>
);
}
);
SentinelOneAgentStatus.displayName = 'SentinelOneAgentStatus';

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 React, { useMemo, useState, useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { SENTINELONE_CONNECTOR_ID, SUB_ACTION } from '@kbn/stack-connectors-plugin/public/common';
import { useLoadConnectors } from '../../../common/components/response_actions/use_load_connectors';
import { useSubActionMutation } from '../../../timelines/components/side_panel/event_details/flyout/use_sub_action_mutation';
import { RETURN_TO_ALERT_DETAILS } from './translations';
import {
EndpointUnisolateForm,
ActionCompletionReturnButton,
} from '../../../common/components/endpoint/host_isolation';
export const UnisolateSentinelOneHost = React.memo(
({
sentinelOneAgentId,
hostName,
cancelCallback,
successCallback,
}: {
sentinelOneAgentId: string;
hostName: string;
cancelCallback: () => void;
successCallback?: () => void;
}) => {
const [isUnIsolated, setIsUnIsolated] = useState(false);
const { data: connectors } = useLoadConnectors({ actionTypeId: SENTINELONE_CONNECTOR_ID });
const connector = useMemo(() => connectors?.[0], [connectors]);
const { mutateAsync: unIsolateHost, isLoading } = useSubActionMutation({
// @ts-expect-error update types
connectorId: connector?.id,
subAction: SUB_ACTION.RELEASE_HOST,
subActionParams: {
uuid: sentinelOneAgentId,
},
});
const confirmHostUnIsolation = useCallback(async () => {
const response = await unIsolateHost();
if (response.status === 'ok') {
setIsUnIsolated(true);
if (successCallback) {
successCallback();
}
}
}, [successCallback, unIsolateHost]);
const onChange = useCallback(() => {}, []);
const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]);
const hostUnisolatedSuccessButton = useMemo(() => {
return (
<ActionCompletionReturnButton
onClick={backToAlertDetails}
buttonText={RETURN_TO_ALERT_DETAILS}
/>
);
}, [backToAlertDetails]);
const hostNotUnisolated = useMemo(() => {
return (
<>
<EuiSpacer size="m" />
<EndpointUnisolateForm
hostName={hostName}
onCancel={backToAlertDetails}
onConfirm={confirmHostUnIsolation}
onChange={onChange}
isLoading={isLoading}
hideCommentField
/>
</>
);
}, [hostName, backToAlertDetails, confirmHostUnIsolation, onChange, isLoading]);
return isUnIsolated ? hostUnisolatedSuccessButton : hostNotUnisolated;
}
);
UnisolateSentinelOneHost.displayName = 'UnisolateSentinelOneHost';

View file

@ -5,6 +5,12 @@
* 2.0.
*/
import { useCallback, useMemo } from 'react';
import { useKibana } from '../../../common/lib/kibana/kibana_react';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import {
getSentinelOneAgentId,
isAlertFromSentinelOneEvent,
} from '../../../common/utils/sentinelone_alert_check';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { isIsolationSupported } from '../../../../common/endpoint/service/host_isolation/utils';
import { HostStatus } from '../../../../common/endpoint/types';
@ -14,6 +20,7 @@ import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import { getFieldValue } from './helpers';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
import { useSentinelOneAgentData } from './use_sentinelone_host_isolation';
interface UseHostIsolationActionProps {
closePopover: () => void;
@ -28,17 +35,30 @@ export const useHostIsolationAction = ({
isHostIsolationPanelOpen,
onAddIsolationStatusClick,
}: UseHostIsolationActionProps): AlertTableContextMenuItem[] => {
const hasActionsAllPrivileges = useKibana().services.application?.capabilities?.actions?.save;
const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
const { canIsolateHost, canUnIsolateHost } = useUserPrivileges().endpointPrivileges;
const isEndpointAlert = useMemo(() => {
return isAlertFromEndpointEvent({ data: detailsData || [] });
}, [detailsData]);
const isEndpointAlert = useMemo(
() => isAlertFromEndpointEvent({ data: detailsData || [] }),
[detailsData]
);
const isSentinelOneAlert = useMemo(
() => isAlertFromSentinelOneEvent({ data: detailsData || [] }),
[detailsData]
);
const agentId = useMemo(
() => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData),
[detailsData]
);
const sentinelOneAgentId = useMemo(() => getSentinelOneAgentId(detailsData), [detailsData]);
const hostOsFamily = useMemo(
() => getFieldValue({ category: 'host', field: 'host.os.name' }, detailsData),
[detailsData]
@ -51,22 +71,55 @@ export const useHostIsolationAction = ({
const {
loading: loadingHostIsolationStatus,
isIsolated: isHostIsolated,
isIsolated,
agentStatus,
capabilities,
} = useHostIsolationStatus({
agentId,
});
const { data: sentinelOneResponse } = useSentinelOneAgentData({ agentId: sentinelOneAgentId });
const sentinelOneAgentData = useMemo(
() => sentinelOneResponse?.data?.data?.[0],
[sentinelOneResponse]
);
const isHostIsolated = useMemo(() => {
if (sentinelOneManualHostActionsEnabled && isSentinelOneAlert) {
return sentinelOneAgentData?.networkStatus === 'disconnected';
}
return isIsolated;
}, [
isIsolated,
isSentinelOneAlert,
sentinelOneAgentData?.networkStatus,
sentinelOneManualHostActionsEnabled,
]);
const doesHostSupportIsolation = useMemo(() => {
return isEndpointAlert
? isIsolationSupported({
osName: hostOsFamily,
version: agentVersion,
capabilities,
})
: false;
}, [agentVersion, capabilities, hostOsFamily, isEndpointAlert]);
if (isEndpointAlert) {
return isIsolationSupported({
osName: hostOsFamily,
version: agentVersion,
capabilities,
});
}
if (sentinelOneManualHostActionsEnabled && isSentinelOneAlert && sentinelOneAgentData) {
return sentinelOneAgentData.isActive;
}
return false;
}, [
agentVersion,
capabilities,
hostOsFamily,
isEndpointAlert,
isSentinelOneAlert,
sentinelOneAgentData,
sentinelOneManualHostActionsEnabled,
]);
const isolateHostHandler = useCallback(() => {
closePopover();
@ -77,36 +130,69 @@ export const useHostIsolationAction = ({
}
}, [closePopover, isHostIsolated, onAddIsolationStatusClick]);
return useMemo(() => {
if (
!isEndpointAlert ||
!doesHostSupportIsolation ||
loadingHostIsolationStatus ||
isHostIsolationPanelOpen
) {
return [];
const menuItemDisabled = useMemo(() => {
if (sentinelOneManualHostActionsEnabled && isSentinelOneAlert) {
return (
!sentinelOneAgentData ||
sentinelOneAgentData?.isUninstalled ||
sentinelOneAgentData?.isPendingUninstall
);
}
const menuItems = [
return agentStatus === HostStatus.UNENROLLED;
}, [agentStatus, isSentinelOneAlert, sentinelOneAgentData, sentinelOneManualHostActionsEnabled]);
const menuItems = useMemo(
() => [
{
key: 'isolate-host-action-item',
'data-test-subj': 'isolate-host-action-item',
disabled: agentStatus === HostStatus.UNENROLLED,
disabled: menuItemDisabled,
onClick: isolateHostHandler,
name: isHostIsolated ? UNISOLATE_HOST : ISOLATE_HOST,
},
];
],
[isHostIsolated, isolateHostHandler, menuItemDisabled]
);
return canIsolateHost || (isHostIsolated && canUnIsolateHost) ? menuItems : [];
return useMemo(() => {
if (isHostIsolationPanelOpen) {
return [];
}
if (
isSentinelOneAlert &&
sentinelOneManualHostActionsEnabled &&
sentinelOneAgentId &&
sentinelOneAgentData &&
hasActionsAllPrivileges
) {
return menuItems;
}
if (
isEndpointAlert &&
doesHostSupportIsolation &&
!loadingHostIsolationStatus &&
(canIsolateHost || (isHostIsolated && !canUnIsolateHost))
) {
return menuItems;
}
return [];
}, [
isEndpointAlert,
doesHostSupportIsolation,
loadingHostIsolationStatus,
isHostIsolationPanelOpen,
agentStatus,
isolateHostHandler,
canIsolateHost,
isHostIsolated,
canUnIsolateHost,
doesHostSupportIsolation,
hasActionsAllPrivileges,
isEndpointAlert,
isHostIsolated,
isHostIsolationPanelOpen,
isSentinelOneAlert,
loadingHostIsolationStatus,
menuItems,
sentinelOneAgentData,
sentinelOneAgentId,
sentinelOneManualHostActionsEnabled,
]);
};

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 { isEmpty } from 'lodash';
import type {
SentinelOneGetAgentsParams,
SentinelOneGetAgentsResponse,
} from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import { SENTINELONE_CONNECTOR_ID, SUB_ACTION } from '@kbn/stack-connectors-plugin/public/common';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { useSubAction } from '../../../timelines/components/side_panel/event_details/flyout/use_sub_action';
import { useLoadConnectors } from '../../../common/components/response_actions/use_load_connectors';
import { SENTINEL_ONE_NETWORK_STATUS } from './sentinel_one_agent_status';
/**
* Using SentinelOne connector to pull agent's data from the SentinelOne API. If the agentId is in the transition state
* (isolating/releasing) it will keep pulling the state until it finalizes the action
* @param agentId
*/
export const useSentinelOneAgentData = ({ agentId }: { agentId?: string }) => {
const sentinelOneManualHostActionsEnabled = useIsExperimentalFeatureEnabled(
'sentinelOneManualHostActionsEnabled'
);
const { data: connector } = useLoadConnectors({ actionTypeId: SENTINELONE_CONNECTOR_ID });
return useSubAction<SentinelOneGetAgentsParams, SentinelOneGetAgentsResponse>({
connectorId: connector?.[0]?.id,
subAction: SUB_ACTION.GET_AGENTS,
subActionParams: {
uuid: agentId,
},
disabled: !sentinelOneManualHostActionsEnabled || isEmpty(agentId),
// @ts-expect-error update types
refetchInterval: (lastResponse: { data: SentinelOneGetAgentsResponse }) => {
const networkStatus = lastResponse?.data?.data?.[0]
.networkStatus as SENTINEL_ONE_NETWORK_STATUS;
return [
SENTINEL_ONE_NETWORK_STATUS.CONNECTING,
SENTINEL_ONE_NETWORK_STATUS.DISCONNECTING,
].includes(networkStatus)
? 5000
: false;
},
});
};

View file

@ -9,6 +9,8 @@ import type { VFC } from 'react';
import React, { useCallback } from 'react';
import { EuiFlexItem, EuiLink } from '@elastic/eui';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { SentinelOneAgentStatus } from '../../../../detections/components/host_isolation/sentinel_one_agent_status';
import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../../../common/utils/sentinelone_alert_check';
import { EndpointAgentStatusById } from '../../../../common/components/endpoint/endpoint_agent_status';
import { useRightPanelContext } from '../context';
import {
@ -90,6 +92,8 @@ export const HighlightedFieldsCell: VFC<HighlightedFieldsCellProps> = ({ values,
endpointAgentId={String(value ?? '')}
data-test-subj={HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID}
/>
) : field === SENTINEL_ONE_AGENT_ID_FIELD ? (
<SentinelOneAgentStatus agentId={String(value ?? '')} />
) : (
<span data-test-subj={HIGHLIGHTED_FIELDS_BASIC_CELL_TEST_ID}>{value}</span>
)}

View file

@ -5,6 +5,8 @@
* 2.0.
*/
// @ts-expect-error
import registerDataSession from 'cypress-data-session/src/plugin';
import { merge } from 'lodash';
import { getVideosForFailedSpecs } from './support/filter_videos';
import { setupToolingLogLevel } from './support/setup_tooling_log_level';
@ -74,7 +76,8 @@ export const getCypressBaseConfig = (
experimentalRunAllSpecs: true,
experimentalMemoryManagement: true,
experimentalInteractiveRunEvents: true,
setupNodeEvents: async (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) => {
setupNodeEvents: (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) => {
registerDataSession(on, config);
// IMPORTANT: setting the log level should happen before any tooling is called
setupToolingLogLevel(config);

View file

@ -27,7 +27,15 @@ describe(
// `internal/kibana/settings` is not accessible in serverless
'@brokenInServerless',
],
env: { ftrConfig: { enableExperimental: ['protectionUpdatesEnabled'] } },
env: {
ftrConfig: {
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'sentinelOneManualHostActionsEnabled',
])}`,
],
},
},
},
() => {
describe('Protection updates', () => {

View file

@ -20,7 +20,15 @@ describe(
// The `disableExpandableFlyoutAdvancedSettings()` fails because the API
// `internal/kibana/settings` is not accessible in serverless
tags: ['@ess', '@serverless', '@brokenInServerless'],
env: { ftrConfig: { enableExperimental: ['protectionUpdatesEnabled'] } },
env: {
ftrConfig: {
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'protectionUpdatesEnabled',
])}`,
],
},
},
},
() => {
// Today API wont let us create a policy with a manifest version before October 1st 2023

View file

@ -0,0 +1,158 @@
/*
* 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 { recurse } from 'cypress-recurse';
import { openAlertDetailsView } from '../../screens/alerts';
import { closeAllToasts } from '../../tasks/toasts';
import { toggleRuleOffAndOn, visitRuleAlerts } from '../../tasks/isolate';
import { cleanupRule, loadRule } from '../../tasks/api_fixtures';
import { ROLE, login } from '../../tasks/login';
import { disableExpandableFlyoutAdvancedSettings } from '../../tasks/common';
import { waitForAlertsToPopulate } from '../../tasks/alerts';
describe.skip(
'Isolate command',
{
tags: [
'@ess',
'@serverless',
// Not supported in serverless!
// The `disableExpandableFlyoutAdvancedSettings()` fails because the API
// `internal/kibana/settings` is not accessible in serverless
'@brokenInServerless',
],
env: {
ftrConfig: {
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'sentinelOneManualHostActionsEnabled',
])}`,
`--xpack.stack_connectors.enableExperimental=${JSON.stringify([
'sentinelOneConnectorOn',
])}`,
`--xpack.actions.preconfigured=${JSON.stringify({
'preconfigured-sentinelone': {
name: 'preconfigured-sentinelone',
actionTypeId: '.sentinelone',
config: {
url: process.env.CYPRESS_SENTINELONE_URL,
},
secrets: {
token: process.env.CYPRESS_SENTINELONE_TOKEN,
},
},
})}`,
],
},
},
},
() => {
before(() => {
cy.dataSession(
'SENTINELONE_HOST', // data name
() => cy.task('createSentinelOneHost', {}, { timeout: 1000000 }),
() => true
);
cy.dataSession('SENTINELONE_POLICY', () => cy.task('createSentinelOneAgentPolicy'), true);
});
after(() => {
cy.dataSession('SENTINELONE_HOST').then((createdHost) => {
if (createdHost && !Cypress.env('isInteractive')) {
createdHost.destroy();
}
});
cy.dataSession('SENTINELONE_POLICY').then((agentPolicy) => {
if (agentPolicy) {
agentPolicy.stop();
cy.task('deleteFleetServerPolicy', agentPolicy.policyId);
}
});
});
beforeEach(() => {
login(ROLE.soc_manager);
disableExpandableFlyoutAdvancedSettings();
});
describe('From alerts', () => {
let ruleId: string;
let ruleName: string;
before(() => {
cy.dataSession('SENTINELONE_HOST').then((createdHost) =>
loadRule(
{
index: ['logs-sentinel_one.alert*'],
query: `host.name: ${createdHost.name} and observer.serial_number:*`,
from: 'now-3660s',
},
false
).then((data) => {
ruleId = data.id;
ruleName = data.name;
})
);
});
after(() => {
if (ruleId) {
cleanupRule(ruleId);
}
});
it('should isolate and release host', () => {
toggleRuleOffAndOn(ruleName);
visitRuleAlerts(ruleName);
waitForAlertsToPopulate(1, 10000, 600000);
closeAllToasts();
openAlertDetailsView();
cy.getByTestSubj('isolate-host-action-item').click();
cy.getByTestSubj('hostIsolateConfirmButton').click();
cy.dataSession('SENTINELONE_HOST').then((createdHost) => {
cy.log('create', createdHost);
cy.getByTestSubj('hostIsolateSuccessMessage').should(
'contain.text',
`Isolation on host ${createdHost.name} successfully submitted`
);
});
cy.getByTestSubj('euiFlyoutCloseButton').click();
recurse<string>(
() => {
openAlertDetailsView();
return cy.getByTestSubj('isolate-host-action-item').invoke('text');
},
(text) => text === 'Release host',
{
log: true,
delay: 30000,
timeout: 600000,
post: () => cy.getByTestSubj('euiFlyoutCloseButton').click(),
}
);
cy.getByTestSubj('isolate-host-action-item').should('contain.text', 'Release host', {
timeout: 120000,
});
cy.getByTestSubj('isolate-host-action-item').click();
cy.contains('Confirm').click();
cy.dataSession('SENTINELONE_HOST').then((createdHost) => {
cy.getByTestSubj('hostUnisolateSuccessMessage').should(
'contain.text',
`Release on host ${createdHost.name} successfully submitted`
);
});
});
});
}
);

View file

@ -11,7 +11,11 @@ import type { CasePostRequest } from '@kbn/cases-plugin/common';
import execa from 'execa';
import type { KbnClient } from '@kbn/test';
import type { ToolingLog } from '@kbn/tooling-log';
import { getHostVmClient } from '../../../../scripts/endpoint/common/vm_services';
import {
getHostVmClient,
createVm,
generateVmName,
} from '../../../../scripts/endpoint/common/vm_services';
import { setupStackServicesUsingCypressConfig } from './common';
import type { KibanaKnownUserAccounts } from '../common/constants';
import { KIBANA_KNOWN_DEFAULT_ACCOUNTS } from '../common/constants';
@ -57,6 +61,17 @@ import type { IndexedHostsAndAlertsResponse } from '../../../../common/endpoint/
import { deleteIndexedHostsAndAlerts } from '../../../../common/endpoint/index_data';
import type { IndexedCase } from '../../../../common/endpoint/data_loaders/index_case';
import { deleteIndexedCase, indexCase } from '../../../../common/endpoint/data_loaders/index_case';
import {
installSentinelOneAgent,
S1Client,
} from '../../../../scripts/endpoint/sentinelone_host/common';
import {
addSentinelOneIntegrationToAgentPolicy,
deleteAgentPolicy,
fetchAgentPolicyEnrollmentKey,
getOrCreateDefaultAgentPolicy,
} from '../../../../scripts/endpoint/common/fleet_services';
import { startElasticAgentWithDocker } from '../../../../scripts/endpoint/common/elastic_agent_service';
import type { IndexedFleetEndpointPolicyResponse } from '../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy';
import {
deleteIndexedFleetEndpointPolicies,
@ -291,6 +306,87 @@ export const dataLoadersForRealEndpoints = (
const stackServicesPromise = setupStackServicesUsingCypressConfig(config);
on('task', {
createSentinelOneHost: async () => {
if (!process.env.CYPRESS_SENTINELONE_URL || !process.env.CYPRESS_SENTINELONE_TOKEN) {
throw new Error('CYPRESS_SENTINELONE_URL and CYPRESS_SENTINELONE_TOKEN must be set');
}
const { log } = await stackServicesPromise;
const s1Client = new S1Client({
url: process.env.CYPRESS_SENTINELONE_URL,
apiToken: process.env.CYPRESS_SENTINELONE_TOKEN,
log,
});
const vmName = generateVmName('sentinelone');
const hostVm = await createVm({
type: 'multipass',
name: vmName,
log,
memory: '2G',
disk: '10G',
});
const s1Info = await installSentinelOneAgent({
hostVm,
log,
s1Client,
});
// wait 30s before running malicious action
await new Promise((resolve) => setTimeout(resolve, 30000));
// nslookup triggers an alert on S1
await getHostVmClient(vmName).exec('nslookup amazon.com');
log.info(`Done!
${hostVm.info()}
SentinelOne Agent Status:
${s1Info.status}
`);
return hostVm;
},
createSentinelOneAgentPolicy: async () => {
if (!process.env.CYPRESS_SENTINELONE_URL || !process.env.CYPRESS_SENTINELONE_TOKEN) {
throw new Error('CYPRESS_SENTINELONE_URL and CYPRESS_SENTINELONE_TOKEN must be set');
}
const { log, kbnClient } = await stackServicesPromise;
const agentPolicyId = (await getOrCreateDefaultAgentPolicy({ kbnClient, log })).id;
await addSentinelOneIntegrationToAgentPolicy({
kbnClient,
log,
agentPolicyId,
consoleUrl: process.env.CYPRESS_SENTINELONE_URL,
apiToken: process.env.CYPRESS_SENTINELONE_TOKEN,
});
const enrollmentToken = await fetchAgentPolicyEnrollmentKey(kbnClient, agentPolicyId);
const elasticAgent = await startElasticAgentWithDocker({
kbnClient,
logger: log,
enrollmentToken,
});
return {
...elasticAgent,
policyId: agentPolicyId,
};
},
deleteAgentPolicy: async (agentPolicyId: string) => {
const { kbnClient } = await stackServicesPromise;
await deleteAgentPolicy(kbnClient, agentPolicyId);
},
createEndpointHost: async (
options: Omit<CreateAndEnrollEndpointHostCIOptions, 'log' | 'kbnClient'>
): Promise<CreateAndEnrollEndpointHostCIResponse> => {

View file

@ -23,6 +23,7 @@
// ***********************************************************
import { subj as testSubjSelector } from '@kbn/test-subj-selector';
import 'cypress-data-session';
// @ts-ignore
import registerCypressGrep from '@cypress/grep';

View file

@ -185,3 +185,75 @@ export const changeAlertsFilter = (text: string) => {
cy.getByTestSubj('querySubmitButton').click();
});
};
/* copied from test/security_solution_cypress/cypress/tasks/create_new_rule */
export const DETECTION_PAGE_FILTER_GROUP_WRAPPER = '.filter-group__wrapper';
export const DETECTION_PAGE_FILTERS_LOADING = '.securityPageWrapper .controlFrame--controlLoading';
export const DETECTION_PAGE_FILTER_GROUP_LOADING = '[data-test-subj="filter-group__loading"]';
export const OPTION_LISTS_LOADING = '.optionsList--filterBtnWrapper .euiLoadingSpinner';
export const DATAGRID_CHANGES_IN_PROGRESS = '[data-test-subj="body-data-grid"] .euiProgress';
export const EVENT_CONTAINER_TABLE_LOADING = '[data-test-subj="internalAlertsPageLoading"]';
export const LOADING_INDICATOR = '[data-test-subj="globalLoadingIndicator"]';
export const ALERTS_URL = '/app/security/alerts';
export const GLOBAL_KQL_WRAPPER = '[data-test-subj="filters-global-container"]';
export const REFRESH_BUTTON = `${GLOBAL_KQL_WRAPPER} [data-test-subj="querySubmitButton"]`;
export const EMPTY_ALERT_TABLE = '[data-test-subj="alertsStateTableEmptyState"]';
export const ALERTS_TABLE_COUNT = `[data-test-subj="toolbar-alerts-count"]`;
export const waitForPageFilters = () => {
cy.log('Waiting for Page Filters');
cy.url().then((urlString) => {
const url = new URL(urlString);
if (url.pathname.endsWith(ALERTS_URL)) {
// since these are only valid on the alert page
cy.get(DETECTION_PAGE_FILTER_GROUP_WRAPPER).should('exist');
cy.get(DETECTION_PAGE_FILTER_GROUP_LOADING).should('not.exist');
cy.get(DETECTION_PAGE_FILTERS_LOADING).should('not.exist');
cy.get(OPTION_LISTS_LOADING).should('have.lengthOf', 0);
} else {
cy.log('Skipping Page Filters Wait');
}
});
};
export const waitForAlerts = () => {
/*
* below line commented because alertpagefiltersenabled feature flag
* is disabled by default
* target: enable by default in v8.8
*
* waitforpagefilters();
*
* */
waitForPageFilters();
cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating');
cy.get(DATAGRID_CHANGES_IN_PROGRESS).should('not.be.true');
cy.get(EVENT_CONTAINER_TABLE_LOADING).should('not.exist');
cy.get(LOADING_INDICATOR).should('not.exist');
};
export const waitForAlertsToPopulate = (
alertCountThreshold = 1,
interval = 500,
timeout = 12000
) => {
cy.waitUntil(
() => {
cy.log('Waiting for alerts to appear');
cy.get(REFRESH_BUTTON).click({ force: true });
cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating');
return cy.root().then(($el) => {
const emptyTableState = $el.find(EMPTY_ALERT_TABLE);
if (emptyTableState.length > 0) {
cy.log('Table is empty', emptyTableState.length);
return false;
}
const countEl = $el.find(ALERTS_TABLE_COUNT);
const alertCount = parseInt(countEl.text(), 10) || 0;
return alertCount >= alertCountThreshold;
});
},
{ interval, timeout }
);
waitForAlerts();
};

View file

@ -9,8 +9,8 @@ import { executeAction } from '@kbn/triggers-actions-ui-plugin/public';
import { useQuery } from '@tanstack/react-query';
import { useKibana } from '../../../../../common/lib/kibana/kibana_react';
export interface UseSubActionParams<P> {
connectorId: string;
export interface UseSubActionParams<P, R> {
connectorId?: string;
subAction: string;
subActionParams?: P;
disabled?: boolean;
@ -22,14 +22,14 @@ export const useSubAction = <P, R>({
subActionParams,
disabled = false,
...rest
}: UseSubActionParams<P>) => {
}: UseSubActionParams<P, R>) => {
const { http } = useKibana().services;
return useQuery({
queryKey: ['useSubAction', connectorId, subAction, subActionParams],
queryFn: ({ signal }) =>
executeAction<R>({
id: connectorId,
id: connectorId as string,
params: {
subAction,
subActionParams,

View file

@ -13,18 +13,17 @@ export interface UseSubActionParams<P> {
connectorId: string;
subAction: string;
subActionParams?: P;
disabled?: boolean;
}
export const useSubActionMutation = <P, R>({
connectorId,
subAction,
subActionParams,
disabled = false,
}: UseSubActionParams<P>) => {
const { http } = useKibana().services;
return useMutation({
mutationKey: ['executeSubAction', connectorId, subAction, subActionParams],
mutationFn: () =>
executeAction<R>({
id: connectorId,

View file

@ -12,6 +12,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { isNumber, isEmpty } from 'lodash/fp';
import React from 'react';
import { SENTINEL_ONE_AGENT_ID_FIELD } from '../../../../../common/utils/sentinelone_alert_check';
import { SentinelOneAgentStatus } from '../../../../../detections/components/host_isolation/sentinel_one_agent_status';
import { EndpointAgentStatusById } from '../../../../../common/components/endpoint/endpoint_agent_status';
import { INDICATOR_REFERENCE } from '../../../../../../common/cti/constants';
import { DefaultDraggable } from '../../../../../common/components/draggables';
@ -245,6 +247,8 @@ const FormattedFieldValueComponent: React.FC<{
data-test-subj="endpointHostAgentStatus"
/>
);
} else if (fieldName === SENTINEL_ONE_AGENT_ID_FIELD) {
return <SentinelOneAgentStatus agentId={String(value ?? '')} />;
} else if (
[
RULE_REFERENCE_FIELD_NAME,

View file

@ -0,0 +1,183 @@
/*
* 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 { verifyDockerInstalled } from '@kbn/es';
import assert from 'assert';
import type { KbnClient } from '@kbn/test';
import type { ToolingLog } from '@kbn/tooling-log';
import chalk from 'chalk';
import execa from 'execa';
import { dump } from 'js-yaml';
import {
fetchFleetServerUrl,
getAgentVersionMatchingCurrentStack,
waitForHostToEnroll,
} from './fleet_services';
interface StartedElasticAgent {
/** The type of virtualization used to start the agent */
type: 'docker';
/** The ID of the agent */
id: string;
/** The name of the agent */
name: string;
/** Stop agent */
stop: () => Promise<void>;
/** Any information about the agent */
info?: string;
}
interface StartElasticAgentWithDockerOptions {
kbnClient: KbnClient;
logger: ToolingLog;
containerName?: string;
/** The enrollment token for Elastic Agent */
enrollmentToken?: string;
version?: string;
}
export const startElasticAgentWithDocker = async ({
kbnClient,
logger: log,
enrollmentToken = '',
version,
containerName = 'elastic-agent',
}: StartElasticAgentWithDockerOptions): Promise<StartedElasticAgent> => {
await verifyDockerInstalled(log);
const agentVersion = version || (await getAgentVersionMatchingCurrentStack(kbnClient));
log.info(`Starting a new Elastic agent using Docker (version: ${agentVersion})`);
const response: StartedElasticAgent = await log.indent(4, async () => {
const fleetServerUrl = await fetchFleetServerUrl(kbnClient);
const hostname = `${containerName}.${Math.random().toString(32).substring(2, 6)}`;
let containerId = '';
let elasticAgentVersionInfo = '';
assert.ok(!!fleetServerUrl, '`fleetServerUrl` is required');
assert.ok(!!enrollmentToken, '`enrollmentToken` is required');
try {
const dockerArgs = getElasticAgentDockerArgs({
containerName: hostname,
hostname,
enrollmentToken,
agentVersion,
fleetServerUrl: fleetServerUrl as string,
});
await execa('docker', ['kill', containerName])
.then(() => {
log.verbose(
`Killed an existing container with name [${containerName}]. New one will be started.`
);
})
.catch((error) => {
if (!/no such container/i.test(error.message)) {
log.verbose(`Attempt to kill currently running elastic-agent container with name [${containerName}] was unsuccessful:
${error}`);
}
});
log.verbose(`docker arguments:\n${dockerArgs.join(' ')}`);
containerId = (await execa('docker', dockerArgs)).stdout;
log.info(`Elastic agent container started`);
{
log.info('Waiting for Elastic Agent to show up in Kibana Fleet');
const elasticAgent = await waitForHostToEnroll(kbnClient, log, hostname, 120000);
log.verbose(`Enrolled agent:\n${JSON.stringify(elasticAgent, null, 2)}`);
}
elasticAgentVersionInfo = (
await execa('docker', [
'exec',
containerName,
'/bin/bash',
'-c',
'./elastic-agent version',
]).catch((err) => {
log.verbose(`Failed to retrieve agent version information from running instance.`, err);
return { stdout: 'Unable to retrieve version information' };
})
).stdout;
} catch (error) {
log.error(dump(error));
throw error;
}
const info = `Container Name: ${containerName}
Container Id: ${containerId}
Elastic Agent version:
${elasticAgentVersionInfo.replace(/\n/g, '\n ')}
View running output: ${chalk.cyan(`docker attach ---sig-proxy=false ${containerName}`)}
Shell access: ${chalk.cyan(`docker exec -it ${containerName} /bin/bash`)}
Kill container: ${chalk.cyan(`docker kill ${containerId}`)}
`;
return {
type: 'docker',
name: containerName,
id: containerId,
info,
stop: async () => {
await execa('docker', ['kill', containerId]);
},
};
});
log.info(`Done. Elastic agent up and running`);
return response;
};
interface GetElasticAgentDockerArgsOptions {
containerName: string;
fleetServerUrl: string;
enrollmentToken: string;
agentVersion: string;
/** The hostname. Defaults to `containerName` */
hostname?: string;
}
const getElasticAgentDockerArgs = ({
hostname,
enrollmentToken,
fleetServerUrl,
containerName,
agentVersion,
}: GetElasticAgentDockerArgsOptions): string[] => {
return [
'run',
'--net',
'elastic',
'--detach',
'--add-host',
'host.docker.internal:host-gateway',
'--name',
containerName,
// The container's hostname will appear in Fleet when the agent enrolls
'--hostname',
hostname || containerName,
'--env',
'FLEET_ENROLL=1',
'--env',
`FLEET_URL=${fleetServerUrl}`,
'--env',
`FLEET_ENROLLMENT_TOKEN=${enrollmentToken}`,
'--env',
'FLEET_INSECURE=true',
'--rm',
`docker.elastic.co/beats/elastic-agent:${agentVersion}`,
];
};

View file

@ -53,6 +53,7 @@ import type {
PostAgentUnenrollResponse,
GenerateServiceTokenResponse,
GetOutputsResponse,
DeleteAgentPolicyResponse,
} from '@kbn/fleet-plugin/common/types';
import nodeFetch from 'node-fetch';
import semver from 'semver';
@ -322,6 +323,28 @@ export const fetchAgentPolicy = async (
.catch(catchAxiosErrorFormatAndThrow);
};
/**
* Delete a single Fleet Agent Policy
* @param kbnClient
* @param agentPolicyId
*/
export const deleteAgentPolicy = async (
kbnClient: KbnClient,
agentPolicyId: string
): Promise<DeleteAgentPolicyResponse> => {
return kbnClient
.request<DeleteAgentPolicyResponse>({
method: 'POST',
path: agentPolicyRouteService.getDeletePath(),
body: {
agentPolicyId,
},
headers: { 'elastic-api-version': '2023-10-31' },
})
.then((response) => response.data)
.catch(catchAxiosErrorFormatAndThrow);
};
/**
* Retrieves a list of Fleet Integration policies
* @param kbnClient
@ -533,6 +556,33 @@ export const unEnrollFleetAgent = async (
return data;
};
/**
* Un-enrolls a Fleet agent
*
* @param kbnClient
* @param agentId
* @param force
*/
export const getAgentPolicyEnrollmentKey = async (
kbnClient: KbnClient,
policyId: string
): Promise<string> => {
const { data } = await kbnClient
.request<GetEnrollmentAPIKeysResponse>({
method: 'GET',
path: enrollmentAPIKeyRouteService.getListPath(),
query: {
policy_id: policyId,
},
headers: {
'elastic-api-version': API_VERSIONS.public.v1,
},
})
.catch(catchAxiosErrorFormatAndThrow);
return data.items?.[0]?.api_key;
};
export const generateFleetServiceToken = async (
kbnClient: KbnClient,
logger: ToolingLog

View file

@ -105,21 +105,8 @@ export const getFTRConfig = ({
return value;
});
if (
specFileFTRConfig?.enableExperimental?.length &&
_.some(vars.kbnTestServer.serverArgs, (value) =>
value.includes('--xpack.securitySolution.enableExperimental')
)
) {
vars.kbnTestServer.serverArgs = _.filter(
vars.kbnTestServer.serverArgs,
(value) => !value.includes('--xpack.securitySolution.enableExperimental')
);
vars.kbnTestServer.serverArgs.push(
`--xpack.securitySolution.enableExperimental=${JSON.stringify(
specFileFTRConfig?.enableExperimental
)}`
);
if (specFileFTRConfig?.kbnServerArgs?.length) {
vars.kbnTestServer.serverArgs.push(...specFileFTRConfig?.kbnServerArgs);
}
if (specFileFTRConfig?.license) {

View file

@ -11,7 +11,6 @@ import * as parser from '@babel/parser';
import generate from '@babel/generator';
import type { ExpressionStatement, ObjectExpression, ObjectProperty } from '@babel/types';
import { schema, type TypeOf } from '@kbn/config-schema';
import { getExperimentalAllowedValues } from '../../common/experimental_features';
/**
* Retrieve test files using a glob pattern.
@ -68,9 +67,10 @@ export const parseTestFileConfig = (filePath: string): SecuritySolutionDescribeB
plugins: ['typescript'],
});
const expressionStatement = _.find(ast.program.body, ['type', 'ExpressionStatement']) as
| ExpressionStatement
| undefined;
const expressionStatement = _.find(ast.program.body, {
type: 'ExpressionStatement',
expression: { callee: { name: 'describe' } },
}) as ExpressionStatement | undefined;
const callExpression = expressionStatement?.expression;
// @ts-expect-error
@ -114,21 +114,7 @@ export const parseTestFileConfig = (filePath: string): SecuritySolutionDescribeB
const TestFileFtrConfigSchema = schema.object(
{
license: schema.maybe(schema.string()),
enableExperimental: schema.maybe(
schema.arrayOf(
schema.string({
validate: (value) => {
const allowedValues = getExperimentalAllowedValues();
if (!allowedValues.includes(value)) {
return `Invalid [enableExperimental] value {${value}.\nValid values are: [${allowedValues.join(
', '
)}]`;
}
},
})
)
),
kbnServerArgs: schema.maybe(schema.arrayOf(schema.string())),
productTypes: schema.maybe(
// TODO:PT write validate function to ensure that only the correct combinations are used
schema.arrayOf(

View file

@ -103,7 +103,6 @@
"@kbn/expect",
"@kbn/share-plugin",
"@kbn/analytics",
"@kbn/observability-plugin",
"@kbn/core-notifications-browser",
"@kbn/doc-links",
"@kbn/react-field",
@ -182,6 +181,7 @@
"@kbn/unified-doc-viewer-plugin",
"@kbn/shared-ux-error-boundary",
"@kbn/zod-helpers",
"@kbn/core-http-common"
"@kbn/core-http-common",
"@kbn/stack-connectors-plugin"
]
}

View file

@ -13,8 +13,8 @@ export enum SUB_ACTION {
KILL_PROCESS = 'killProcess',
EXECUTE_SCRIPT = 'executeScript',
GET_AGENTS = 'getAgents',
ISOLATE_AGENT = 'isolateAgent',
RELEASE_AGENT = 'releaseAgent',
ISOLATE_HOST = 'isolateHost',
RELEASE_HOST = 'releaseHost',
GET_REMOTE_SCRIPTS = 'getRemoteScripts',
GET_REMOTE_SCRIPT_STATUS = 'getRemoteScriptStatus',
GET_REMOTE_SCRIPT_RESULTS = 'getRemoteScriptResults',

View file

@ -25,140 +25,148 @@ export const SentinelOneGetAgentsResponseSchema = schema.object({
}),
errors: schema.nullable(schema.arrayOf(schema.string())),
data: schema.arrayOf(
schema.object({
modelName: schema.string(),
firewallEnabled: schema.boolean(),
totalMemory: schema.number(),
osName: schema.string(),
cloudProviders: schema.recordOf(schema.string(), schema.any()),
siteName: schema.string(),
cpuId: schema.string(),
isPendingUninstall: schema.boolean(),
isUpToDate: schema.boolean(),
osArch: schema.string(),
accountId: schema.string(),
locationEnabled: schema.boolean(),
consoleMigrationStatus: schema.string(),
scanFinishedAt: schema.nullable(schema.string()),
operationalStateExpiration: schema.nullable(schema.string()),
agentVersion: schema.string(),
isActive: schema.boolean(),
locationType: schema.string(),
activeThreats: schema.number(),
inRemoteShellSession: schema.boolean(),
allowRemoteShell: schema.boolean(),
serialNumber: schema.nullable(schema.string()),
updatedAt: schema.string(),
lastActiveDate: schema.string(),
firstFullModeTime: schema.nullable(schema.string()),
operationalState: schema.string(),
externalId: schema.string(),
mitigationModeSuspicious: schema.string(),
licenseKey: schema.string(),
cpuCount: schema.number(),
mitigationMode: schema.string(),
networkStatus: schema.string(),
installerType: schema.string(),
uuid: schema.string(),
detectionState: schema.nullable(schema.string()),
infected: schema.boolean(),
registeredAt: schema.string(),
lastIpToMgmt: schema.string(),
storageName: schema.nullable(schema.string()),
osUsername: schema.string(),
groupIp: schema.string(),
createdAt: schema.string(),
remoteProfilingState: schema.string(),
groupUpdatedAt: schema.nullable(schema.string()),
scanAbortedAt: schema.nullable(schema.string()),
isUninstalled: schema.boolean(),
networkQuarantineEnabled: schema.boolean(),
tags: schema.object({
sentinelone: schema.arrayOf(
schema.object(
{
modelName: schema.string(),
firewallEnabled: schema.boolean(),
totalMemory: schema.number(),
osName: schema.string(),
cloudProviders: schema.recordOf(schema.string(), schema.any()),
siteName: schema.string(),
cpuId: schema.string(),
isPendingUninstall: schema.boolean(),
isUpToDate: schema.boolean(),
osArch: schema.string(),
accountId: schema.string(),
locationEnabled: schema.boolean(),
consoleMigrationStatus: schema.string(),
scanFinishedAt: schema.nullable(schema.string()),
operationalStateExpiration: schema.nullable(schema.string()),
agentVersion: schema.string(),
isActive: schema.boolean(),
locationType: schema.string(),
activeThreats: schema.number(),
inRemoteShellSession: schema.boolean(),
allowRemoteShell: schema.boolean(),
serialNumber: schema.nullable(schema.string()),
updatedAt: schema.string(),
lastActiveDate: schema.string(),
firstFullModeTime: schema.nullable(schema.string()),
operationalState: schema.string(),
externalId: schema.string(),
mitigationModeSuspicious: schema.string(),
licenseKey: schema.string(),
cpuCount: schema.number(),
mitigationMode: schema.string(),
networkStatus: schema.string(),
installerType: schema.string(),
uuid: schema.string(),
detectionState: schema.nullable(schema.string()),
infected: schema.boolean(),
registeredAt: schema.string(),
lastIpToMgmt: schema.string(),
storageName: schema.nullable(schema.string()),
osUsername: schema.string(),
groupIp: schema.string(),
createdAt: schema.string(),
remoteProfilingState: schema.string(),
groupUpdatedAt: schema.nullable(schema.string()),
scanAbortedAt: schema.nullable(schema.string()),
isUninstalled: schema.boolean(),
networkQuarantineEnabled: schema.boolean(),
tags: schema.object({
sentinelone: schema.arrayOf(
schema.object({
assignedBy: schema.string(),
assignedAt: schema.string(),
assignedById: schema.string(),
key: schema.string(),
value: schema.string(),
id: schema.string(),
})
),
}),
externalIp: schema.string(),
siteId: schema.string(),
machineType: schema.string(),
domain: schema.string(),
scanStatus: schema.string(),
osStartTime: schema.string(),
accountName: schema.string(),
lastLoggedInUserName: schema.string(),
showAlertIcon: schema.boolean(),
rangerStatus: schema.string(),
groupName: schema.string(),
threatRebootRequired: schema.boolean(),
remoteProfilingStateExpiration: schema.nullable(schema.string()),
policyUpdatedAt: schema.nullable(schema.string()),
activeDirectory: schema.object({
userPrincipalName: schema.nullable(schema.string()),
lastUserDistinguishedName: schema.nullable(schema.string()),
computerMemberOf: schema.arrayOf(schema.object({ type: schema.string() })),
lastUserMemberOf: schema.arrayOf(schema.object({ type: schema.string() })),
mail: schema.nullable(schema.string()),
computerDistinguishedName: schema.nullable(schema.string()),
}),
isDecommissioned: schema.boolean(),
rangerVersion: schema.string(),
userActionsNeeded: schema.arrayOf(
schema.object({
assignedBy: schema.string(),
assignedAt: schema.string(),
assignedById: schema.string(),
key: schema.string(),
value: schema.string(),
type: schema.string(),
example: schema.string(),
enum: schema.arrayOf(schema.string()),
})
),
locations: schema.nullable(
schema.arrayOf(
schema.object({ name: schema.string(), scope: schema.string(), id: schema.string() })
)
),
id: schema.string(),
coreCount: schema.number(),
osRevision: schema.string(),
osType: schema.string(),
groupId: schema.string(),
computerName: schema.string(),
scanStartedAt: schema.string(),
encryptedApplications: schema.boolean(),
storageType: schema.nullable(schema.string()),
networkInterfaces: schema.arrayOf(
schema.object({
gatewayMacAddress: schema.nullable(schema.string()),
inet6: schema.arrayOf(schema.string()),
name: schema.string(),
inet: schema.arrayOf(schema.string()),
physical: schema.string(),
gatewayIp: schema.nullable(schema.string()),
id: schema.string(),
})
),
}),
externalIp: schema.string(),
siteId: schema.string(),
machineType: schema.string(),
domain: schema.string(),
scanStatus: schema.string(),
osStartTime: schema.string(),
accountName: schema.string(),
lastLoggedInUserName: schema.string(),
showAlertIcon: schema.boolean(),
rangerStatus: schema.string(),
groupName: schema.string(),
threatRebootRequired: schema.boolean(),
remoteProfilingStateExpiration: schema.nullable(schema.string()),
policyUpdatedAt: schema.nullable(schema.string()),
activeDirectory: schema.object({
userPrincipalName: schema.nullable(schema.string()),
lastUserDistinguishedName: schema.nullable(schema.string()),
computerMemberOf: schema.arrayOf(schema.object({ type: schema.string() })),
lastUserMemberOf: schema.arrayOf(schema.object({ type: schema.string() })),
mail: schema.nullable(schema.string()),
computerDistinguishedName: schema.nullable(schema.string()),
}),
isDecommissioned: schema.boolean(),
rangerVersion: schema.string(),
userActionsNeeded: schema.arrayOf(
schema.object({
type: schema.string(),
example: schema.string(),
enum: schema.arrayOf(schema.string()),
})
),
locations: schema.nullable(
schema.arrayOf(
schema.object({ name: schema.string(), scope: schema.string(), id: schema.string() })
)
),
id: schema.string(),
coreCount: schema.number(),
osRevision: schema.string(),
osType: schema.string(),
groupId: schema.string(),
computerName: schema.string(),
scanStartedAt: schema.string(),
encryptedApplications: schema.boolean(),
storageType: schema.nullable(schema.string()),
networkInterfaces: schema.arrayOf(
schema.object({
gatewayMacAddress: schema.nullable(schema.string()),
inet6: schema.arrayOf(schema.string()),
name: schema.string(),
inet: schema.arrayOf(schema.string()),
physical: schema.string(),
gatewayIp: schema.nullable(schema.string()),
id: schema.string(),
})
),
fullDiskScanLastUpdatedAt: schema.string(),
appsVulnerabilityStatus: schema.string(),
})
fullDiskScanLastUpdatedAt: schema.string(),
appsVulnerabilityStatus: schema.string(),
},
{ unknowns: 'allow' }
)
),
});
export const SentinelOneIsolateAgentResponseSchema = schema.object({
export const SentinelOneIsolateHostResponseSchema = schema.object({
errors: schema.nullable(schema.arrayOf(schema.string())),
data: schema.object({
affected: schema.number(),
}),
data: schema.object(
{
affected: schema.number(),
},
{ unknowns: 'allow' }
),
});
export const SentinelOneGetRemoteScriptsParamsSchema = schema.object({
query: schema.nullable(schema.string()),
osTypes: schema.nullable(schema.arrayOf(schema.string())),
osTypes: schema.nullable(schema.string()),
});
export const AlertIds = schema.maybe(schema.arrayOf(schema.string()));
export const SentinelOneGetRemoteScriptsResponseSchema = schema.object({
errors: schema.nullable(schema.arrayOf(schema.string())),
pagination: schema.object({
@ -238,6 +246,7 @@ export const SentinelOneExecuteScriptParamsSchema = schema.object({
),
password: schema.maybe(schema.string()),
}),
alertIds: AlertIds,
});
export const SentinelOneGetRemoteScriptStatusParamsSchema = schema.object(
@ -252,40 +261,39 @@ export const SentinelOneGetRemoteScriptStatusResponseSchema = schema.object({
totalItems: schema.number(),
nextCursor: schema.nullable(schema.string()),
}),
errors: schema.arrayOf(schema.object({ type: schema.string() })),
errors: schema.nullable(schema.arrayOf(schema.object({ type: schema.string() }))),
data: schema.arrayOf(
schema.object({
agentIsDecommissioned: schema.boolean(),
agentComputerName: schema.string(),
status: schema.string(),
groupName: schema.string(),
initiatedById: schema.string(),
parentTaskId: schema.string(),
updatedAt: schema.string(),
createdAt: schema.string(),
agentIsActive: schema.boolean(),
agentOsType: schema.string(),
agentMachineType: schema.string(),
id: schema.string(),
siteName: schema.string(),
detailedStatus: schema.string(),
siteId: schema.string(),
scriptResultsSignature: schema.nullable(schema.string()),
initiatedBy: schema.string(),
accountName: schema.string(),
groupId: schema.string(),
statusDescription: schema.object({
readOnly: schema.boolean(),
description: schema.string(),
}),
agentUuid: schema.string(),
accountId: schema.string(),
type: schema.string(),
scriptResultsPath: schema.string(),
scriptResultsBucket: schema.string(),
description: schema.string(),
agentId: schema.string(),
})
schema.object(
{
agentIsDecommissioned: schema.nullable(schema.boolean()),
agentComputerName: schema.nullable(schema.string()),
status: schema.nullable(schema.string()),
groupName: schema.nullable(schema.string()),
initiatedById: schema.nullable(schema.string()),
parentTaskId: schema.nullable(schema.string()),
updatedAt: schema.nullable(schema.string()),
createdAt: schema.nullable(schema.string()),
agentIsActive: schema.nullable(schema.boolean()),
agentOsType: schema.nullable(schema.string()),
agentMachineType: schema.nullable(schema.string()),
id: schema.nullable(schema.string()),
siteName: schema.nullable(schema.string()),
detailedStatus: schema.nullable(schema.string()),
siteId: schema.nullable(schema.string()),
scriptResultsSignature: schema.nullable(schema.nullable(schema.string())),
initiatedBy: schema.nullable(schema.string()),
accountName: schema.nullable(schema.string()),
groupId: schema.nullable(schema.string()),
agentUuid: schema.nullable(schema.string()),
accountId: schema.nullable(schema.string()),
type: schema.nullable(schema.string()),
scriptResultsPath: schema.nullable(schema.string()),
scriptResultsBucket: schema.nullable(schema.string()),
description: schema.nullable(schema.string()),
agentId: schema.nullable(schema.string()),
},
{ unknowns: 'allow' }
)
),
});
@ -439,13 +447,14 @@ export const SentinelOneBaseFilterSchema = schema.object({
lastLoggedInUserName__contains: schema.nullable(schema.string()),
awsRole__contains: schema.nullable(schema.string()),
K8SVersion__contains: schema.nullable(schema.string()),
alertIds: AlertIds,
});
export const SentinelOneKillProcessParamsSchema = SentinelOneBaseFilterSchema.extends({
processName: schema.string(),
});
export const SentinelOneIsolateAgentParamsSchema = SentinelOneBaseFilterSchema;
export const SentinelOneIsolateHostParamsSchema = SentinelOneBaseFilterSchema;
export const SentinelOneGetAgentsParamsSchema = SentinelOneBaseFilterSchema;
@ -472,14 +481,14 @@ export const SentinelOneKillProcessSchema = schema.object({
subActionParams: SentinelOneKillProcessParamsSchema,
});
export const SentinelOneIsolateAgentSchema = schema.object({
subAction: schema.literal(SUB_ACTION.ISOLATE_AGENT),
subActionParams: SentinelOneIsolateAgentParamsSchema,
export const SentinelOneIsolateHostSchema = schema.object({
subAction: schema.literal(SUB_ACTION.ISOLATE_HOST),
subActionParams: SentinelOneIsolateHostParamsSchema,
});
export const SentinelOneReleaseAgentSchema = schema.object({
subAction: schema.literal(SUB_ACTION.RELEASE_AGENT),
subActionParams: SentinelOneIsolateAgentParamsSchema,
export const SentinelOneReleaseHostSchema = schema.object({
subAction: schema.literal(SUB_ACTION.RELEASE_HOST),
subActionParams: SentinelOneIsolateHostParamsSchema,
});
export const SentinelOneExecuteScriptSchema = schema.object({
@ -489,7 +498,7 @@ export const SentinelOneExecuteScriptSchema = schema.object({
export const SentinelOneActionParamsSchema = schema.oneOf([
SentinelOneKillProcessSchema,
SentinelOneIsolateAgentSchema,
SentinelOneReleaseAgentSchema,
SentinelOneIsolateHostSchema,
SentinelOneReleaseHostSchema,
SentinelOneExecuteScriptSchema,
]);

View file

@ -15,7 +15,7 @@ import {
SentinelOneGetRemoteScriptsParamsSchema,
SentinelOneGetRemoteScriptsResponseSchema,
SentinelOneGetRemoteScriptsStatusParams,
SentinelOneIsolateAgentParamsSchema,
SentinelOneIsolateHostParamsSchema,
SentinelOneKillProcessParamsSchema,
SentinelOneSecretsSchema,
SentinelOneActionParamsSchema,
@ -26,9 +26,11 @@ export type SentinelOneSecrets = TypeOf<typeof SentinelOneSecretsSchema>;
export type SentinelOneBaseApiResponse = TypeOf<typeof SentinelOneBaseApiResponseSchema>;
export type SentinelOneGetAgentsParams = TypeOf<typeof SentinelOneGetAgentsParamsSchema>;
export type SentinelOneGetAgentsParams = Partial<TypeOf<typeof SentinelOneGetAgentsParamsSchema>>;
export type SentinelOneGetAgentsResponse = TypeOf<typeof SentinelOneGetAgentsResponseSchema>;
export type SentinelOneAgent = SentinelOneGetAgentsResponse['data'][0];
export type SentinelOneKillProcessParams = TypeOf<typeof SentinelOneKillProcessParamsSchema>;
export type SentinelOneExecuteScriptParams = TypeOf<typeof SentinelOneExecuteScriptParamsSchema>;
@ -45,6 +47,6 @@ export type SentinelOneGetRemoteScriptsResponse = TypeOf<
typeof SentinelOneGetRemoteScriptsResponseSchema
>;
export type SentinelOneIsolateAgentParams = TypeOf<typeof SentinelOneIsolateAgentParamsSchema>;
export type SentinelOneIsolateHostParams = TypeOf<typeof SentinelOneIsolateHostParamsSchema>;
export type SentinelOneActionParams = TypeOf<typeof SentinelOneActionParamsSchema>;

View file

@ -9,3 +9,8 @@ import OpenAILogo from '../connector_types/openai/logo';
export { OPENAI_CONNECTOR_ID, OpenAiProviderType } from '../../common/openai/constants';
export { OpenAILogo };
import SentinelOneLogo from '../connector_types/sentinelone/logo';
export { SENTINELONE_CONNECTOR_ID, SUB_ACTION } from '../../common/sentinelone/constants';
export { SentinelOneLogo };

View file

@ -35,6 +35,7 @@ export function getConnectorType(): ConnectorTypeModel<
id: SENTINELONE_CONNECTOR_ID,
actionTypeTitle: SENTINELONE_TITLE,
iconClass: lazy(() => import('./logo')),
isExperimental: true,
selectMessage: i18n.translate(
'xpack.stackConnectors.security.sentinelone.config.selectMessageText',
{
@ -53,7 +54,7 @@ export function getConnectorType(): ConnectorTypeModel<
// The internal "subAction" param should always be valid, ensure it is only if "subActionParams" are valid
if (!subAction) {
errors.subAction.push(translations.ACTION_REQUIRED);
} else if (!(subAction in SUB_ACTION)) {
} else if (!Object.values(SUB_ACTION).includes(subAction)) {
errors.subAction.push(translations.INVALID_ACTION);
}
return { errors };

View file

@ -68,7 +68,7 @@ const SentinelOneParamsFields: React.FunctionComponent<
} = useSubAction<SentinelOneGetAgentsParams, SentinelOneGetAgentsResponse>({
connectorId,
subAction: SUB_ACTION.GET_AGENTS,
disabled: isTest,
disabled: !isTest,
});
const agentOptions = useMemo(
@ -221,11 +221,11 @@ const SentinelOneParamsFields: React.FunctionComponent<
inputDisplay: i18n.KILL_PROCESS_ACTION_LABEL,
},
{
value: SUB_ACTION.ISOLATE_AGENT,
value: SUB_ACTION.ISOLATE_HOST,
inputDisplay: i18n.ISOLATE_AGENT_ACTION_LABEL,
},
{
value: SUB_ACTION.RELEASE_AGENT,
value: SUB_ACTION.RELEASE_HOST,
inputDisplay: i18n.RELEASE_AGENT_ACTION_LABEL,
},
];

View file

@ -8,14 +8,14 @@
import type {
SentinelOneKillProcessParams,
SentinelOneExecuteScriptParams,
SentinelOneIsolateAgentParams,
SentinelOneIsolateHostParams,
} from '../../../common/sentinelone/types';
import type { SUB_ACTION } from '../../../common/sentinelone/constants';
export type SentinelOneExecuteSubActionParams =
| SentinelOneKillProcessParams
| SentinelOneExecuteScriptParams
| SentinelOneIsolateAgentParams;
| SentinelOneIsolateHostParams;
export interface SentinelOneExecuteActionParams {
subAction: SUB_ACTION;

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { map } from 'lodash';
import { set } from '@kbn/safer-lodash-set/fp';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { ExecutorParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
@ -19,30 +20,40 @@ export const renderParameterTemplates = (
variables: Record<string, unknown>
) => {
const context = variables?.context as Context;
const alertIds = map(context.alerts, '_id');
if (params?.subAction === SUB_ACTION.KILL_PROCESS) {
const processNames = ((params.subActionParams.processName && [
params.subActionParams.processName,
]) ||
map(context.alerts, 'process.name').filter((elm) => elm)) as string[];
return {
subAction: SUB_ACTION.KILL_PROCESS,
subActionParams: {
processName: context.alerts[0].process?.name,
processName: processNames.join(','),
computerName: context.alerts[0].host?.name,
alertIds,
},
};
}
if (params?.subAction === SUB_ACTION.ISOLATE_AGENT) {
if (params?.subAction === SUB_ACTION.ISOLATE_HOST) {
return {
subAction: SUB_ACTION.ISOLATE_AGENT,
subAction: SUB_ACTION.ISOLATE_HOST,
subActionParams: {
computerName: context.alerts[0].host?.name,
alertIds,
},
};
}
if (params?.subAction === SUB_ACTION.RELEASE_AGENT) {
if (params?.subAction === SUB_ACTION.RELEASE_HOST) {
return {
subAction: SUB_ACTION.RELEASE_AGENT,
subAction: SUB_ACTION.RELEASE_HOST,
subActionParams: {
computerName: context.alerts[0].host?.name,
alertIds,
},
};
}
@ -53,6 +64,7 @@ export const renderParameterTemplates = (
subActionParams: {
computerName: context.alerts[0].host?.name,
...params.subActionParams,
alertIds,
},
};
}

View file

@ -17,7 +17,7 @@ import type {
SentinelOneBaseApiResponse,
SentinelOneGetRemoteScriptsParams,
SentinelOneGetRemoteScriptsResponse,
SentinelOneIsolateAgentParams,
SentinelOneIsolateHostParams,
SentinelOneKillProcessParams,
SentinelOneExecuteScriptParams,
} from '../../../common/sentinelone/types';
@ -27,12 +27,13 @@ import {
SentinelOneGetRemoteScriptsParamsSchema,
SentinelOneGetRemoteScriptsResponseSchema,
SentinelOneGetAgentsResponseSchema,
SentinelOneIsolateAgentResponseSchema,
SentinelOneIsolateAgentParamsSchema,
SentinelOneIsolateHostResponseSchema,
SentinelOneIsolateHostParamsSchema,
SentinelOneGetRemoteScriptStatusParamsSchema,
SentinelOneGetRemoteScriptStatusResponseSchema,
SentinelOneGetAgentsParamsSchema,
SentinelOneExecuteScriptResponseSchema,
SentinelOneKillProcessParamsSchema,
} from '../../../common/sentinelone/schema';
import { SUB_ACTION } from '../../../common/sentinelone/constants';
@ -45,8 +46,8 @@ export class SentinelOneConnector extends SubActionConnector<
> {
private urls: {
agents: string;
isolateAgent: string;
releaseAgent: string;
isolateHost: string;
releaseHost: string;
remoteScripts: string;
remoteScriptStatus: string;
remoteScriptsExecute: string;
@ -56,8 +57,8 @@ export class SentinelOneConnector extends SubActionConnector<
super(params);
this.urls = {
isolateAgent: `${this.config.url}${API_PATH}/agents/actions/disconnect`,
releaseAgent: `${this.config.url}${API_PATH}/agents/actions/connect`,
isolateHost: `${this.config.url}${API_PATH}/agents/actions/disconnect`,
releaseHost: `${this.config.url}${API_PATH}/agents/actions/connect`,
remoteScripts: `${this.config.url}${API_PATH}/remote-scripts`,
remoteScriptStatus: `${this.config.url}${API_PATH}/remote-scripts/status`,
remoteScriptsExecute: `${this.config.url}${API_PATH}/remote-scripts/execute`,
@ -87,21 +88,21 @@ export class SentinelOneConnector extends SubActionConnector<
});
this.registerSubAction({
name: SUB_ACTION.ISOLATE_AGENT,
method: 'isolateAgent',
schema: SentinelOneIsolateAgentParamsSchema,
name: SUB_ACTION.ISOLATE_HOST,
method: 'isolateHost',
schema: SentinelOneIsolateHostParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.RELEASE_AGENT,
method: 'releaseAgent',
schema: SentinelOneIsolateAgentParamsSchema,
name: SUB_ACTION.RELEASE_HOST,
method: 'releaseHost',
schema: SentinelOneIsolateHostParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.KILL_PROCESS,
method: 'killProcess',
schema: SentinelOneKillProcessResponseSchema,
schema: SentinelOneKillProcessParamsSchema,
});
this.registerSubAction({
@ -112,7 +113,7 @@ export class SentinelOneConnector extends SubActionConnector<
}
public async executeScript(payload: SentinelOneExecuteScriptParams) {
return this.sentinelOneApiRequest({
await this.sentinelOneApiRequest({
url: this.urls.remoteScriptsExecute,
method: 'post',
data: {
@ -128,7 +129,7 @@ export class SentinelOneConnector extends SubActionConnector<
});
}
public async killProcess({ processName, ...payload }: SentinelOneKillProcessParams) {
public async killProcess({ alertIds, processName, ...payload }: SentinelOneKillProcessParams) {
const agentData = await this.getAgents(payload);
const agentId = agentData.data[0]?.id;
@ -139,14 +140,14 @@ export class SentinelOneConnector extends SubActionConnector<
const terminateScriptResponse = await this.getRemoteScripts({
query: 'terminate',
osTypes: [agentData?.data[0]?.osType],
osTypes: agentData?.data[0]?.osType,
});
if (!processName) {
throw new Error('No process name provided');
}
return this.sentinelOneApiRequest({
await this.sentinelOneApiRequest({
url: this.urls.remoteScriptsExecute,
method: 'post',
data: {
@ -165,32 +166,36 @@ export class SentinelOneConnector extends SubActionConnector<
});
}
public async isolateAgent(payload: SentinelOneIsolateAgentParams) {
public async isolateHost({ alertIds, ...payload }: SentinelOneIsolateHostParams) {
const response = await this.getAgents(payload);
if (response.data.length === 0) {
throw new Error('No agents found');
const errorMessage = 'No agents found';
throw new Error(errorMessage);
}
if (response.data[0].networkStatus === 'disconnected') {
throw new Error('Agent already isolated');
const errorMessage = 'Agent already isolated';
throw new Error(errorMessage);
}
const agentId = response.data[0].id;
return this.sentinelOneApiRequest({
url: this.urls.isolateAgent,
url: this.urls.isolateHost,
method: 'post',
data: {
filter: {
ids: agentId,
},
},
responseSchema: SentinelOneIsolateAgentResponseSchema,
responseSchema: SentinelOneIsolateHostResponseSchema,
});
}
public async releaseAgent(payload: SentinelOneIsolateAgentParams) {
public async releaseHost({ alertIds, ...payload }: SentinelOneIsolateHostParams) {
const response = await this.getAgents(payload);
if (response.data.length === 0) {
@ -204,14 +209,14 @@ export class SentinelOneConnector extends SubActionConnector<
const agentId = response.data[0].id;
return this.sentinelOneApiRequest({
url: this.urls.releaseAgent,
url: this.urls.releaseHost,
method: 'post',
data: {
filter: {
ids: agentId,
},
},
responseSchema: SentinelOneIsolateAgentResponseSchema,
responseSchema: SentinelOneIsolateHostResponseSchema,
});
}

View file

@ -18,8 +18,8 @@ type Capabilities = Record<string, any>;
export const hasShowActionsCapability = (capabilities: Capabilities) => capabilities?.actions?.show;
export const hasSaveActionsCapability = (capabilities: Capabilities) => capabilities?.actions?.save;
export const hasExecuteActionsCapability = (capabilities: Capabilities) =>
capabilities?.actions?.execute;
export const hasExecuteActionsCapability = (capabilities: Capabilities, actionTypeId?: string) =>
actionTypeId === '.sentinelone' ? capabilities?.actions?.save : capabilities?.actions?.execute;
export const hasDeleteActionsCapability = (capabilities: Capabilities) =>
capabilities?.actions?.delete;

View file

@ -24,11 +24,14 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { betaBadgeProps } from '../beta_badge_props';
import { EditConnectorTabs } from '../../../../types';
import { useKibana } from '../../../../common/lib/kibana';
import { hasExecuteActionsCapability } from '../../../lib/capabilities';
const FlyoutHeaderComponent: React.FC<{
isExperimental?: boolean;
isPreconfigured: boolean;
connectorName: string;
connectorTypeId: string;
connectorTypeDesc: string;
selectedTab: EditConnectorTabs;
setTab: () => void;
@ -38,11 +41,17 @@ const FlyoutHeaderComponent: React.FC<{
isExperimental = false,
isPreconfigured,
connectorName,
connectorTypeId,
connectorTypeDesc,
selectedTab,
setTab,
}) => {
const {
application: { capabilities },
} = useKibana().services;
const { euiTheme } = useEuiTheme();
const canExecute = hasExecuteActionsCapability(capabilities, connectorTypeId);
return (
<EuiFlyoutHeader hasBorder data-test-subj="edit-connector-flyout-header">
@ -136,15 +145,17 @@ const FlyoutHeaderComponent: React.FC<{
defaultMessage: 'Configuration',
})}
</EuiTab>
<EuiTab
onClick={setTab}
data-test-subj="testConnectorTab"
isSelected={EditConnectorTabs.Test === selectedTab}
>
{i18n.translate('xpack.triggersActionsUI.sections.testConnectorForm.tabText', {
defaultMessage: 'Test',
})}
</EuiTab>
{canExecute && (
<EuiTab
onClick={setTab}
data-test-subj="testConnectorTab"
isSelected={EditConnectorTabs.Test === selectedTab}
>
{i18n.translate('xpack.triggersActionsUI.sections.testConnectorForm.tabText', {
defaultMessage: 'Test',
})}
</EuiTab>
)}
</EuiTabs>
</EuiFlyoutHeader>
);

View file

@ -64,7 +64,7 @@ describe('EditConnectorFlyout', () => {
appMockRenderer = createAppMockRenderer();
appMockRenderer.coreStart.application.capabilities = {
...appMockRenderer.coreStart.application.capabilities,
actions: { save: true, show: true },
actions: { save: true, show: true, execute: true },
};
appMockRenderer.coreStart.http.put = jest.fn().mockResolvedValue(updateConnectorResponse);
appMockRenderer.coreStart.http.post = jest.fn().mockResolvedValue(executeConnectorResponse);

View file

@ -228,6 +228,7 @@ const EditConnectorFlyoutComponent: React.FC<EditConnectorFlyoutProps> = ({
<FlyoutHeader
isPreconfigured={connector.isPreconfigured}
connectorName={connector.name}
connectorTypeId={connector.actionTypeId}
connectorTypeDesc={actionTypeModel?.selectMessage}
setTab={handleSetTab}
selectedTab={selectedTab}

View file

@ -90,7 +90,6 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
docLinks,
} = useKibana().services;
const canDelete = hasDeleteActionsCapability(capabilities);
const canExecute = hasExecuteActionsCapability(capabilities);
const canSave = hasSaveActionsCapability(capabilities);
const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined);
@ -317,7 +316,7 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
name: '',
render: (item: ActionConnectorTableItem) => {
return (
<EuiFlexGroup justifyContent="flexEnd" alignItems="flexEnd">
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
<DeleteOperation canDelete={canDelete} item={item} onDelete={() => onDelete([item])} />
{item.isMissingSecrets ? (
<>
@ -347,7 +346,13 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
</>
) : (
<RunOperation
canExecute={canExecute && actionTypesIndex && actionTypesIndex[item.actionTypeId]}
canExecute={
!!(
hasExecuteActionsCapability(capabilities, item.actionTypeId) &&
actionTypesIndex &&
actionTypesIndex[item.actionTypeId]
)
}
item={item}
onRun={() => editItem(item, EditConnectorTabs.Test)}
/>

View file

@ -13995,6 +13995,15 @@ cypress-axe@^1.5.0:
resolved "https://registry.yarnpkg.com/cypress-axe/-/cypress-axe-1.5.0.tgz#95082734583da77b51ce9b7784e14a442016c7a1"
integrity sha512-Hy/owCjfj+25KMsecvDgo4fC/781ccL+e8p+UUYoadGVM2ogZF9XIKbiM6KI8Y3cEaSreymdD6ZzccbI2bY0lQ==
cypress-data-session@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/cypress-data-session/-/cypress-data-session-2.7.0.tgz#1dbae30798a7f7351c67b40cc20fdf28af512eac"
integrity sha512-Rj7WZ/Vn/givI6ja+y/c3QlT3jiwpVDReuBuYm66A31/Vi9PfCoI0SSY1pL/dUUrFEcqCLEFZiTiAWhgTtjXwQ==
dependencies:
cypress-plugin-config "^1.2.0"
debug "^4.3.2"
simple-sha256 "^1.1.0"
cypress-file-upload@^5.0.8:
version "5.0.8"
resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1"
@ -14008,6 +14017,11 @@ cypress-multi-reporters@^1.6.3:
debug "^4.3.4"
lodash "^4.17.21"
cypress-plugin-config@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/cypress-plugin-config/-/cypress-plugin-config-1.2.1.tgz#aa7eaa55ab5ce5e186ab7d0e37cc7e42bfb609b4"
integrity sha512-z+bQ7oyfDKun51HiCVNBOR+g38/nYRJ7zVdCZT2/9UozzE8P4iA1zF/yc85ePZLy5NOj/0atutoUPBBR5SqjSQ==
cypress-real-events@^1.10.3:
version "1.10.3"
resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.10.3.tgz#e2e949ea509cc4306df6c238de1a9982d67360e5"
@ -27314,6 +27328,11 @@ simple-git@^3.16.0:
"@kwsites/promise-deferred" "^1.1.1"
debug "^4.3.4"
simple-sha256@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/simple-sha256/-/simple-sha256-1.1.0.tgz#e6d245a932e70ba45fc1699321e3cc3f82a1d605"
integrity sha512-jMdj+5MR1dRiFbrQ8idmkPv+HCoJEmJVBBwc0vQQz4Gtg3PEkjdpVxUM4BSwK898zhNP/ub3vcP9edw47EHuLQ==
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"