[Defend Workflows] Add support for Processes commands in Automated Response Actions (#161645)

This commit is contained in:
Tomasz Ciecierski 2024-02-07 19:08:29 +01:00 committed by GitHub
parent 9999f3674b
commit 0ca5593b41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1595 additions and 297 deletions

View file

@ -7219,20 +7219,60 @@ Object {
"type": "string",
},
"params": Object {
"additionalProperties": false,
"properties": Object {
"command": Object {
"const": "isolate",
"type": "string",
"anyOf": Array [
Object {
"additionalProperties": false,
"properties": Object {
"command": Object {
"const": "isolate",
"type": "string",
},
"comment": Object {
"type": "string",
},
},
"required": Array [
"command",
],
"type": "object",
},
"comment": Object {
"type": "string",
Object {
"additionalProperties": false,
"properties": Object {
"command": Object {
"enum": Array [
"kill-process",
"suspend-process",
],
"type": "string",
},
"comment": Object {
"type": "string",
},
"config": Object {
"additionalProperties": false,
"properties": Object {
"field": Object {
"type": "string",
},
"overwrite": Object {
"default": true,
"type": "boolean",
},
},
"required": Array [
"field",
],
"type": "object",
},
},
"required": Array [
"command",
"config",
],
"type": "object",
},
},
"required": Array [
"command",
],
"type": "object",
},
},
"required": Array [
@ -7857,20 +7897,60 @@ Object {
"type": "string",
},
"params": Object {
"additionalProperties": false,
"properties": Object {
"command": Object {
"const": "isolate",
"type": "string",
"anyOf": Array [
Object {
"additionalProperties": false,
"properties": Object {
"command": Object {
"const": "isolate",
"type": "string",
},
"comment": Object {
"type": "string",
},
},
"required": Array [
"command",
],
"type": "object",
},
"comment": Object {
"type": "string",
Object {
"additionalProperties": false,
"properties": Object {
"command": Object {
"enum": Array [
"kill-process",
"suspend-process",
],
"type": "string",
},
"comment": Object {
"type": "string",
},
"config": Object {
"additionalProperties": false,
"properties": Object {
"field": Object {
"type": "string",
},
"overwrite": Object {
"default": true,
"type": "boolean",
},
},
"required": Array [
"field",
],
"type": "object",
},
},
"required": Array [
"command",
"config",
],
"type": "object",
},
},
"required": Array [
"command",
],
"type": "object",
},
},
"required": Array [

View file

@ -81,22 +81,38 @@ export const RuleResponseOsqueryAction = z.object({
params: OsqueryParamsCamelCase,
});
export type EndpointParams = z.infer<typeof EndpointParams>;
export const EndpointParams = z.object({
export type DefaultParams = z.infer<typeof DefaultParams>;
export const DefaultParams = z.object({
command: z.literal('isolate'),
comment: z.string().optional(),
});
export type ProcessesParams = z.infer<typeof ProcessesParams>;
export const ProcessesParams = z.object({
command: z.enum(['kill-process', 'suspend-process']),
comment: z.string().optional(),
config: z.object({
/**
* Field to use instead of process.pid
*/
field: z.string(),
/**
* Whether to overwrite field with process.pid
*/
overwrite: z.boolean().optional().default(true),
}),
});
export type EndpointResponseAction = z.infer<typeof EndpointResponseAction>;
export const EndpointResponseAction = z.object({
action_type_id: z.literal('.endpoint'),
params: EndpointParams,
params: z.union([DefaultParams, ProcessesParams]),
});
export type RuleResponseEndpointAction = z.infer<typeof RuleResponseEndpointAction>;
export const RuleResponseEndpointAction = z.object({
actionTypeId: z.literal('.endpoint'),
params: EndpointParams,
params: z.union([DefaultParams, ProcessesParams]),
});
export type ResponseAction = z.infer<typeof ResponseAction>;

View file

@ -2,7 +2,7 @@ openapi: 3.0.0
info:
title: Response Actions Schema
version: 'not applicable'
paths: {}
paths: { }
components:
x-codegen-enabled: true
schemas:
@ -113,7 +113,7 @@ components:
- actionTypeId
- params
EndpointParams:
DefaultParams:
type: object
properties:
command:
@ -125,6 +125,32 @@ components:
required:
- command
ProcessesParams:
type: object
properties:
command:
type: string
enum:
- kill-process
- suspend-process
comment:
type: string
config:
required:
- field
type: object
properties:
field:
type: string
description: Field to use instead of process.pid
overwrite:
type: boolean
description: Whether to overwrite field with process.pid
default: true
required:
- command
- config
EndpointResponseAction:
type: object
properties:
@ -133,7 +159,9 @@ components:
enum:
- .endpoint
params:
$ref: '#/components/schemas/EndpointParams'
oneOf:
- $ref: '#/components/schemas/DefaultParams'
- $ref: '#/components/schemas/ProcessesParams'
required:
- action_type_id
- params
@ -147,7 +175,9 @@ components:
enum:
- .endpoint
params:
$ref: '#/components/schemas/EndpointParams'
oneOf:
- $ref: '#/components/schemas/DefaultParams'
- $ref: '#/components/schemas/ProcessesParams'
required:
- actionTypeId
- params

View file

@ -6,7 +6,6 @@
*/
import { arrayQueries, ecsMapping } from '@kbn/osquery-io-ts-types';
import * as t from 'io-ts';
import { ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS } from '../../../../endpoint/service/response_actions/constants';
// to enable using RESPONSE_ACTION_API_COMMANDS_NAMES as a type
function keyObject<T extends readonly string[]>(arr: T): { [K in T[number]]: null } {
@ -15,7 +14,9 @@ function keyObject<T extends readonly string[]>(arr: T): { [K in T[number]]: nul
export type EndpointParams = t.TypeOf<typeof EndpointParams>;
export const EndpointParams = t.type({
command: t.keyof(keyObject(ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS)),
// TODO: TC- change these when we go GA with automated process actions
command: t.keyof(keyObject(['isolate', 'kill-process', 'suspend-process'])),
// command: t.keyof(keyObject(ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS)),
comment: t.union([t.string, t.undefined]),
});

View file

@ -287,6 +287,28 @@ export class EndpointRuleAlertGenerator extends BaseDataGenerator {
comment: 'test',
},
},
{
params: {
command: 'suspend-process',
comment: 'Suspend host',
config: {
field: 'entity_id',
overwrite: false,
},
},
action_type_id: '.endpoint',
},
{
params: {
command: 'kill-process',
comment: 'Kill host',
config: {
field: '',
overwrite: true,
},
},
action_type_id: '.endpoint',
},
],
rule_id: ELASTIC_SECURITY_RULE_ID,
rule_name_override: 'message',

View file

@ -9,6 +9,7 @@ import type { Client } from '@elastic/elasticsearch';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
import type { BulkRequest } from '@elastic/elasticsearch/lib/api/types';
import type { ResponseActionsApiCommandNames } from '../service/response_actions/constants';
import { EndpointError } from '../errors';
import { usageTracker } from './usage_tracker';
import type {
@ -117,6 +118,15 @@ interface BuildIEndpointAndFleetActionsBulkOperationsResponse
operations: Required<BulkRequest>['operations'];
}
const getAutomatedActionsSample = (): Array<{
command: ResponseActionsApiCommandNames;
config?: { overwrite: boolean };
}> => [
{ command: 'isolate' },
{ command: 'suspend-process', config: { overwrite: true } },
{ command: 'kill-process', config: { overwrite: true } },
];
export const buildIEndpointAndFleetActionsBulkOperations = ({
endpoints,
count = 1,
@ -138,13 +148,14 @@ export const buildIEndpointAndFleetActionsBulkOperations = ({
for (const endpoint of endpoints) {
const agentId = endpoint.elastic.agent.id;
const automatedActions = getAutomatedActionsSample();
for (let i = 0; i < count; i++) {
// start with endpoint action
const logsEndpointAction: LogsEndpointAction = endpointActionGenerator.generate({
EndpointActions: {
data: {
comment: 'data generator: this host is bad',
...(alertIds ? { command: 'isolate' } : {}),
...(alertIds ? automatedActions[i] : {}),
},
},
});

View file

@ -31,7 +31,12 @@ export const RESPONSE_ACTION_API_COMMANDS_NAMES = [
export type ResponseActionsApiCommandNames = typeof RESPONSE_ACTION_API_COMMANDS_NAMES[number];
export const ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS = ['isolate'] as const;
export const ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS: ResponseActionsApiCommandNames[] = [
'isolate',
// TODO: TC- Uncomment these when we go GA with automated process actions
// 'kill-process',
// 'suspend-process'
];
export type EnabledAutomatedResponseActionsCommands =
typeof ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS[number];

View file

@ -70,6 +70,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
responseActionUploadEnabled: true,
/*
* Enables Automated Endpoint Process actions
*/
automatedProcessActionsEnabled: false,
/**
* Enables the ability to send Response actions to SentinelOne
*/

View file

@ -13,6 +13,7 @@ import { SuperSelectField } from '@kbn/es-ui-shared-plugin/static/forms/componen
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink } from '@elastic/eui';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { getRbacControl } from '../../../../common/endpoint/service/response_actions/utils';
import { useKibana } from '../../../common/lib/kibana';
import { CHOOSE_FROM_THE_LIST, LEARN_MORE } from './translations';
@ -44,14 +45,29 @@ const ActionTypeFieldComponent = ({
},
} = useKibana().services;
const automatedProcessActionsEnabled = useIsExperimentalFeatureEnabled(
'automatedProcessActionsEnabled'
);
const enabledActions = useMemo(
() =>
[
...ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS,
...(automatedProcessActionsEnabled ? ['kill-process', 'suspend-process'] : []),
] as ['isolate', 'kill-process', 'suspend-process'],
[automatedProcessActionsEnabled]
);
const fieldOptions = useMemo(
() =>
ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS.map((name) => {
enabledActions.map((name) => {
const missingRbac = !getRbacControl({
commandName: RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP[name],
privileges: endpointPrivileges,
});
const commandAlreadyExists = map(data.responseActions, 'params.command').includes(name);
const currentActions = map(data.responseActions, 'params.command');
// we enable just one instance of each action
const commandAlreadyExists = currentActions.includes(name);
const isDisabled = commandAlreadyExists || missingRbac;
return {
@ -62,7 +78,7 @@ const ActionTypeFieldComponent = ({
'data-test-subj': `command-type-${name}`,
};
}),
[data.responseActions, endpointPrivileges]
[data.responseActions, enabledActions, endpointPrivileges]
);
return (

View file

@ -71,6 +71,31 @@ const EndpointActionCalloutComponent = ({ basePath, editDisabled }: EndpointCall
</>
);
}
if (currentCommand === 'kill-process' || currentCommand === 'suspend-process') {
return (
<>
<EuiSpacer size="s" />
<EuiCallOut
color="warning"
iconType="warning"
title={
<FormattedMessage
id="xpack.securitySolution.responseActionsList.endpoint.cautionTitle"
defaultMessage="Proceed with caution"
/>
}
>
<EuiText size={'xs'}>
<FormattedMessage
id="xpack.securitySolution.responseActionsList.endpoint.processesCautionDescription"
defaultMessage="Only select this option if youre certain that you want to terminate the process running on this host."
/>
</EuiText>
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
}
return <></>;
};

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 from 'react';
import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { EuiSpacer } from '@elastic/eui';
import { get } from 'lodash';
import { OverwriteField } from './overwrite_process_field';
import { FieldNameField } from './field_name';
interface AdditionalConfigFieldProps {
basePath: string;
disabled: boolean;
readDefaultValueOnForm: boolean;
}
export const ConfigFieldsComponent = ({
basePath,
disabled,
readDefaultValueOnForm,
}: AdditionalConfigFieldProps) => {
const commandPath = `${basePath}.command`;
const overWritePath = `${basePath}.config.overwrite`;
const [data] = useFormData({ watch: [commandPath, overWritePath] });
const currentCommand = get(data, commandPath);
const currentOverwrite = get(data, overWritePath);
if (currentCommand === 'kill-process' || currentCommand === 'suspend-process') {
return (
<>
<EuiSpacer />
<OverwriteField
path={`${basePath}.config.overwrite`}
disabled={disabled}
readDefaultValueOnForm={readDefaultValueOnForm}
/>
<EuiSpacer />
<FieldNameField
path={`${basePath}.config.field`}
disabled={disabled}
readDefaultValueOnForm={readDefaultValueOnForm}
isRequired={!currentOverwrite}
/>
<EuiSpacer />
</>
);
}
return null;
};
export const ConfigFields = React.memo(ConfigFieldsComponent);

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
import { ConfigFields } from './config_fields';
import type { ArrayItem } from '../../../shared_imports';
import { CommentField } from './comment_field';
import { ActionTypeField } from './action_type_field';
@ -29,6 +30,12 @@ export const EndpointResponseAction = React.memo((props: EndpointResponseActionP
<EndpointActionCallout basePath={paramsPath} editDisabled={props.editDisabled} />
<ConfigFields
basePath={paramsPath}
disabled={props.editDisabled}
readDefaultValueOnForm={!props.item.isNew}
/>
<CommentField
basePath={paramsPath}
disabled={props.editDisabled}

View file

@ -0,0 +1,143 @@
/*
* 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, useEffect } from 'react';
import {
getFieldValidityAndErrorMessage,
UseField,
useFormData,
useFormContext,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { get } from 'lodash';
import { EuiComboBox, EuiSpacer, EuiFormRow } from '@elastic/eui';
import ECSSchema from './v.8.10.0_process.json';
interface FieldNameFieldProps {
path: string;
disabled: boolean;
readDefaultValueOnForm: boolean;
isRequired: boolean;
}
const ECSSchemaOptions = ECSSchema.map((ecs) => ({
label: ecs.field,
value: ecs,
}));
const SINGLE_SELECTION = Object.freeze({ asPlainText: true });
const FIELD_LABEL: string = 'Custom field name';
const FieldNameFieldComponent = ({
path,
disabled,
readDefaultValueOnForm,
isRequired,
}: FieldNameFieldProps) => {
const [data] = useFormData();
const fieldValue = get(data, path);
const context = useFormContext();
const currentFieldNameField = context.getFields()[path];
useEffect(() => {
// hackish way to clear errors on this field - because we base this validation on the value of overwrite toggle
if (currentFieldNameField && !isRequired) {
currentFieldNameField?.clearErrors();
}
}, [currentFieldNameField, isRequired]);
const renderEntityIdNote = useMemo(() => {
const contains = fieldValue?.includes('entity_id');
if (contains) {
return (
<FormattedMessage
id="xpack.securitySolution.responseActions.endpoint.fieldDescription"
defaultMessage="Entity_id is an Elastic Defend agent specific field, if the alert does not come from Elastic Defend agent we will not be able to send the action."
/>
);
}
return null;
}, [fieldValue]);
const CONFIG = useMemo(() => {
return {
label: FIELD_LABEL,
helpText: renderEntityIdNote,
validations: [
{
validator: ({ value }: { value: string }) => {
if (isRequired && value === '') {
return {
code: 'ERR_FIELD_MISSING',
path,
message: i18n.translate(
'xpack.securitySolution.responseActions.endpoint.validations.fieldNameIsRequiredErrorMessage',
{
defaultMessage:
'{field} is a required field when process.pid toggle is turned off',
values: { field: FIELD_LABEL },
}
),
};
}
},
},
],
};
}, [isRequired, path, renderEntityIdNote]);
const optionsAsComboBoxOptions = useMemo(() => {
return ECSSchemaOptions.map(({ label }) => ({
label,
value: label,
}));
}, []);
return (
<>
<UseField<string> path={path} readDefaultValueOnForm={readDefaultValueOnForm} config={CONFIG}>
{(field) => {
const { value, setValue } = field;
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const valueInList = !!optionsAsComboBoxOptions.find((option) => option.label === value);
return (
<EuiFormRow
label={field.label}
helpText={field.helpText}
fullWidth
error={errorMessage}
isInvalid={isInvalid}
>
<EuiComboBox
isInvalid={isInvalid}
isDisabled={disabled || !isRequired}
singleSelection={SINGLE_SELECTION}
noSuggestions={false}
options={optionsAsComboBoxOptions}
fullWidth
selectedOptions={value && valueInList ? [{ value, label: value }] : undefined}
onChange={(newValue) => {
if (newValue.length === 0) {
// Don't allow clearing the type. One must always be selected
return;
}
setValue(newValue[0].label);
}}
data-test-subj="config-custom-field-name"
/>
</EuiFormRow>
);
}}
</UseField>
<EuiSpacer size="s" />
</>
);
};
export const FieldNameField = React.memo(FieldNameFieldComponent);

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { i18n } from '@kbn/i18n';
interface OverwriteFieldProps {
path: string;
disabled: boolean;
readDefaultValueOnForm: boolean;
}
const OverwriteFieldComponent = ({
path,
disabled,
readDefaultValueOnForm,
}: OverwriteFieldProps) => {
const CONFIG = useMemo(() => {
return {
defaultValue: true,
label: i18n.translate('xpack.securitySolution.responseActions.endpoint.overwriteFieldLabel', {
defaultMessage: 'Use process.pid as process identifier',
}),
};
}, []);
return (
<UseField
component={ToggleField}
euiFieldProps={{
'data-test-subj': 'config-overwrite-toggle',
}}
path={path}
readDefaultValueOnForm={readDefaultValueOnForm}
config={CONFIG}
isDisabled={disabled}
/>
);
};
export const OverwriteField = React.memo(OverwriteFieldComponent);

View file

@ -6,7 +6,7 @@
*/
import type { ReactNode } from 'react';
import React from 'react';
import { EuiText, EuiTitle, EuiSpacer, EuiToolTip } from '@elastic/eui';
import { EuiText, EuiSpacer, EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { EnabledAutomatedResponseActionsCommands } from '../../../../common/endpoint/service/response_actions/constants';
@ -20,11 +20,11 @@ const EndpointActionTextComponent = ({ name, isDisabled }: EndpointActionTextPro
const content = (
<>
<EuiTitle size="xs">
<EuiText>{title}</EuiText>
</EuiTitle>
<EuiText size="s">
<b>{title}</b>
</EuiText>
<EuiSpacer size="xs" />
<EuiText>{description}</EuiText>
<EuiText size="xs">{description}</EuiText>
</>
);
if (isDisabled) {
@ -62,6 +62,48 @@ const useGetCommandText = (
/>
),
};
case 'kill-process':
return {
title: (
<FormattedMessage
id="xpack.securitySolution.responseActions.endpoint.killProcess"
defaultMessage="Kill process"
/>
),
description: (
<FormattedMessage
id="xpack.securitySolution.responseActions.endpoint.killProcessDescription"
defaultMessage="Kill/terminate a process"
/>
),
tooltip: (
<FormattedMessage
id="xpack.securitySolution.responseActions.endpoint.killProcessTooltip"
defaultMessage="Insufficient privileges to kill process. Contact your Kibana administrator if you think you should have this permission."
/>
),
};
case 'suspend-process':
return {
title: (
<FormattedMessage
id="xpack.securitySolution.responseActions.endpoint.suspendProcess"
defaultMessage="Suspend process"
/>
),
description: (
<FormattedMessage
id="xpack.securitySolution.responseActions.endpoint.suspendProcessDescription"
defaultMessage="Temporarily suspend a process"
/>
),
tooltip: (
<FormattedMessage
id="xpack.securitySolution.responseActions.endpoint.suspendProcessTooltip"
defaultMessage="Insufficient privileges to supend process. Contact your Kibana administrator if you think you should have this permission."
/>
),
};
default:
return {
title: '',

View file

@ -0,0 +1,122 @@
[
{
"field": "process.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.entry_leader.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.entry_leader.parent.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.entry_leader.parent.pid",
"description": "Process id."
},
{
"field": "process.entry_leader.parent.session_leader.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.entry_leader.parent.session_leader.pid",
"description": "Process id."
},
{
"field": "process.entry_leader.parent.session_leader.vpid",
"description": "Virtual process id."
},
{
"field": "process.entry_leader.parent.vpid",
"description": "Virtual process id."
},
{
"field": "process.entry_leader.pid",
"description": "Process id."
},
{
"field": "process.entry_leader.vpid",
"description": "Virtual process id."
},
{
"field": "process.group_leader.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.group_leader.pid",
"description": "Process id."
},
{
"field": "process.group_leader.vpid",
"description": "Virtual process id."
},
{
"field": "process.parent.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.parent.group_leader.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.parent.group_leader.pid",
"description": "Process id."
},
{
"field": "process.parent.group_leader.vpid",
"description": "Virtual process id."
},
{
"field": "process.parent.pid",
"description": "Process id."
},
{
"field": "process.parent.vpid",
"description": "Virtual process id."
},
{
"field": "process.pid",
"description": "Process id."
},
{
"field": "process.session_leader.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.session_leader.parent.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.session_leader.parent.pid",
"description": "Process id."
},
{
"field": "process.session_leader.parent.session_leader.entity_id",
"description": "Unique identifier for the process."
},
{
"field": "process.session_leader.parent.session_leader.pid",
"description": "Process id."
},
{
"field": "process.session_leader.parent.session_leader.vpid",
"description": "Virtual process id."
},
{
"field": "process.session_leader.parent.vpid",
"description": "Virtual process id."
},
{
"field": "process.session_leader.pid",
"description": "Process id."
},
{
"field": "process.session_leader.vpid",
"description": "Virtual process id."
},
{
"field": "process.vpid",
"description": "Virtual process id."
}
]

View file

@ -53,14 +53,24 @@ export const ResponseActionsForm = ({
const fieldErrors = reduce<string[], Array<{ type: string; errors: string[] }>>(
map(items, 'path'),
(acc, path) => {
if (fields[`${path}.params`]?.errors?.length) {
acc.push({
type: upperFirst((fields[`${path}.actionTypeId`].value as string).substring(1)),
errors: map(fields[`${path}.params`].errors, 'message'),
});
return acc;
}
map(fields, (_, name) => {
const paramsPath = `${path}.params`;
if (name.includes(paramsPath)) {
if (fields[name]?.errors?.length) {
const responseActionType = upperFirst(
(fields[`${path}.actionTypeId`].value as string).substring(1)
);
acc.push({
type: responseActionType,
errors: map(fields[name].errors, 'message'),
});
}
return acc;
}
return acc;
});
return acc;
},
[]

View file

@ -17,8 +17,8 @@ export const useSupportedResponseActionTypes = () => {
>();
const isEndpointEnabled = useIsExperimentalFeatureEnabled('endpointResponseActionsEnabled');
const { canIsolateHost } = useUserPrivileges().endpointPrivileges;
const { canIsolateHost, canKillProcess, canSuspendProcess } =
useUserPrivileges().endpointPrivileges;
const enabledFeatures = useMemo(
() => ({
endpoint: isEndpointEnabled,
@ -28,9 +28,9 @@ export const useSupportedResponseActionTypes = () => {
const userHasPermissionsToExecute = useMemo(
() => ({
endpoint: canIsolateHost,
endpoint: canIsolateHost || canKillProcess || canSuspendProcess,
}),
[canIsolateHost]
[canIsolateHost, canKillProcess, canSuspendProcess]
);
useEffect(() => {

View file

@ -11,7 +11,7 @@ import { closeAllToasts } from '../../tasks/toasts';
import { toggleRuleOffAndOn, visitRuleAlerts } from '../../tasks/isolate';
import { cleanupRule, loadRule } from '../../tasks/api_fixtures';
import { login } from '../../tasks/login';
import { disableExpandableFlyoutAdvancedSettings, loadPage } from '../../tasks/common';
import { loadPage } from '../../tasks/common';
import type { IndexedFleetEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy';
import { createAgentPolicyTask, getEndpointIntegrationVersion } from '../../tasks/fleet';
import { changeAlertsFilter } from '../../tasks/alerts';
@ -23,14 +23,16 @@ import { enableAllPolicyProtections } from '../../tasks/endpoint_policy';
describe(
'Automated Response Actions',
{
tags: [
'@ess',
'@serverless',
// Not supported in serverless!
// The `disableExpandableFlyoutAdvancedSettings()` fails because the API
// `internal/kibana/settings` is not accessible in serverless
'@brokenInServerless',
],
tags: ['@ess', '@serverless'],
env: {
ftrConfig: {
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'automatedProcessActionsEnabled',
])}`,
],
},
},
},
() => {
let indexedPolicy: IndexedFleetEndpointPolicyResponse;
@ -67,16 +69,11 @@ describe(
}
});
const hostname = new URL(Cypress.env('FLEET_SERVER_URL')).port;
const fleetHostname = `dev-fleet-server.${hostname}`;
beforeEach(() => {
login();
disableExpandableFlyoutAdvancedSettings();
});
// FLAKY: https://github.com/elastic/kibana/issues/169828
describe.skip('From alerts', () => {
describe('From alerts', () => {
let ruleId: string;
let ruleName: string;
@ -102,18 +99,14 @@ describe(
visitRuleAlerts(ruleName);
closeAllToasts();
changeAlertsFilter('event.category: "file"');
cy.getByTestSubj('expand-event').first().click();
cy.getByTestSubj('responseActionsViewTab').click();
cy.getByTestSubj('response-actions-notification').should('not.have.text', '0');
changeAlertsFilter('process.name: "sshd"');
cy.getByTestSubj('expand-event').eq(0).click();
cy.getByTestSubj('securitySolutionFlyoutNavigationExpandDetailButton').click();
cy.getByTestSubj('securitySolutionFlyoutResponseTab').click();
cy.getByTestSubj(`response-results-${createdHost.hostname}-details-tray`)
.should('contain', 'isolate completed successfully')
.and('contain', createdHost.hostname);
cy.getByTestSubj(`response-results-${fleetHostname}-details-tray`)
.should('contain', 'The host does not have Elastic Defend integration installed')
.and('contain', 'dev-fleet-server');
cy.contains(/isolate is pending|isolate completed successfully/g);
cy.contains(/kill-process is pending|kill-process completed successfully/g);
cy.contains('The action was called with a non-existing event field name: entity_id');
});
});
}

View file

@ -17,22 +17,26 @@ import { cleanupRule, generateRandomStringName, loadRule } from '../../tasks/api
import { ResponseActionTypesEnum } from '../../../../../common/api/detection_engine';
import { login, ROLE } from '../../tasks/login';
export const RESPONSE_ACTIONS_ERRORS = 'response-actions-error';
describe(
'Form',
{
tags: [
'@ess',
'@serverless',
// Not supported in serverless! Test suite uses custom roles
'@brokenInServerless',
],
tags: ['@ess', '@serverless'],
env: {
ftrConfig: {
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'automatedProcessActionsEnabled',
])}`,
],
},
},
},
() => {
// FLAKY: https://github.com/elastic/kibana/issues/169334
describe.skip('User with no access can not create an endpoint response action', () => {
describe('User with no access can not create an endpoint response action', () => {
beforeEach(() => {
login(ROLE.endpoint_response_actions_no_access);
login(ROLE.rule_author);
});
it('no endpoint response action option during rule creation', () => {
@ -43,11 +47,12 @@ describe(
describe('User with access can create and save an endpoint response action', () => {
const testedCommand = 'isolate';
const secondTestedCommand = 'suspend-process';
let ruleId: string;
const [ruleName, ruleDescription] = generateRandomStringName(2);
beforeEach(() => {
login(ROLE.endpoint_response_actions_access);
login(ROLE.soc_manager);
});
afterEach(() => {
if (ruleId) {
@ -75,20 +80,53 @@ describe(
});
cy.getByTestSubj(`command-type-${testedCommand}`).should('not.have.attr', 'disabled');
cy.getByTestSubj(`command-type-${testedCommand}`).click();
addEndpointResponseAction();
focusAndOpenCommandDropdown(1);
cy.getByTestSubj(`command-type-${secondTestedCommand}`).click();
cy.getByTestSubj('config-overwrite-toggle').click();
cy.getByTestSubj('config-custom-field-name').should('have.value', '');
cy.intercept('POST', '/api/detection_engine/rules', (request) => {
const result = {
const isolateResult = {
action_type_id: ResponseActionTypesEnum['.endpoint'],
params: {
command: testedCommand,
comment: 'example1',
},
};
expect(request.body.response_actions[0]).to.deep.equal(result);
const processResult = {
action_type_id: ResponseActionTypesEnum['.endpoint'],
params: {
command: secondTestedCommand,
comment: 'example1',
config: {
field: 'process.entity_id',
overwrite: false,
},
},
};
expect(request.body.response_actions[0]).to.deep.equal(isolateResult);
expect(request.body.response_actions[1]).to.deep.equal(processResult);
request.continue((response) => {
ruleId = response.body.id;
response.send(response.body);
});
});
cy.getByTestSubj(RESPONSE_ACTIONS_ERRORS).should('not.exist');
cy.getByTestSubj('create-enabled-false').click();
cy.getByTestSubj(RESPONSE_ACTIONS_ERRORS).within(() => {
cy.contains(
'Custom field name is a required field when process.pid toggle is turned off'
);
});
cy.getByTestSubj(`response-actions-list-item-1`).within(() => {
cy.getByTestSubj('config-custom-field-name').type('process.entity_id{downArrow}{enter}');
});
cy.getByTestSubj('create-enabled-false').click();
cy.contains(`${ruleName} was created`);
});
@ -97,11 +135,11 @@ describe(
describe('User with access can edit and delete an endpoint response action', () => {
let ruleId: string;
let ruleName: string;
const testedCommand = 'isolate';
const newDescription = 'Example isolate host description';
const newDescription = 'Example suspend process description';
const secondTestedCommand = 'suspend-process';
beforeEach(() => {
login(ROLE.endpoint_response_actions_access);
login(ROLE.soc_manager);
loadRule().then((res) => {
ruleId = res.id;
ruleName = res.name;
@ -115,24 +153,30 @@ describe(
visitRuleActions(ruleId);
cy.getByTestSubj('edit-rule-actions-tab').click();
cy.getByTestSubj(`response-actions-list-item-0`).within(() => {
cy.getByTestSubj('input').should('have.value', 'Isolate host');
cy.getByTestSubj('input').should('have.value', 'Isolate host');
cy.getByTestSubj(`response-actions-list-item-1`).within(() => {
cy.getByTestSubj('input').should('have.value', 'Suspend host');
cy.getByTestSubj('input').type(`{selectall}{backspace}${newDescription}`);
cy.getByTestSubj('commandTypeField').click();
cy.getByTestSubj('config-overwrite-toggle').click();
cy.getByTestSubj('config-custom-field-name').should('have.value', '');
cy.getByTestSubj('config-overwrite-toggle').click();
cy.getByTestSubj('config-custom-field-name').type('process.entity_id{downArrow}{enter}');
});
validateAvailableCommands();
cy.intercept('PUT', '/api/detection_engine/rules').as('updateResponseAction');
cy.getByTestSubj('ruleEditSubmitButton').click();
cy.wait('@updateResponseAction').should(({ request }) => {
const query = {
action_type_id: ResponseActionTypesEnum['.endpoint'],
params: {
command: testedCommand,
command: secondTestedCommand,
comment: newDescription,
config: {
field: 'process.entity_id',
overwrite: false,
},
},
action_type_id: ResponseActionTypesEnum['.endpoint'],
};
expect(request.body.response_actions[0]).to.deep.equal(query);
expect(request.body.response_actions[1]).to.deep.equal(query);
});
cy.contains(`${ruleName} was saved`).should('exist');
});
@ -147,7 +191,7 @@ describe(
cy.intercept('PUT', '/api/detection_engine/rules').as('deleteResponseAction');
cy.getByTestSubj('ruleEditSubmitButton').click();
cy.wait('@deleteResponseAction').should(({ request }) => {
expect(request.body.response_actions).to.be.equal(undefined);
expect(request.body.response_actions.length).to.be.equal(2);
});
cy.contains(`${ruleName} was saved`).should('exist');
});
@ -157,9 +201,10 @@ describe(
const [ruleName, ruleDescription] = generateRandomStringName(2);
beforeEach(() => {
login(ROLE.endpoint_response_actions_no_access);
login(ROLE.rule_author);
});
// let currentUrl
it('response actions are disabled', () => {
fillUpNewRule(ruleName, ruleDescription);
cy.getByTestSubj('response-actions-wrapper').within(() => {
@ -174,7 +219,7 @@ describe(
let ruleId: string;
beforeEach(() => {
login(ROLE.endpoint_response_actions_no_access);
login(ROLE.rule_author);
loadRule().then((res) => {
ruleId = res.id;
});
@ -200,8 +245,8 @@ describe(
// Try removing action
cy.getByTestSubj('remove-response-action').click({ force: true });
});
cy.getByTestSubj(`response-actions-list-item-0`).should('exist');
tryAddingDisabledResponseAction(1);
cy.getByTestSubj(`response-actions-list-item-2`).should('exist');
tryAddingDisabledResponseAction(3);
});
});
}

View file

@ -6,7 +6,6 @@
*/
import { navigateToAlertsList } from '../../screens/alerts';
import { disableExpandableFlyoutAdvancedSettings } from '../../tasks/common';
import { closeAllToasts } from '../../tasks/toasts';
import { fillUpNewRule } from '../../tasks/response_actions';
import { login, ROLE } from '../../tasks/login';
@ -39,7 +38,6 @@ describe('No License', { tags: '@ess', env: { ftrConfig: { license: 'basic' } }
const [endpointAgentId, endpointHostname] = generateRandomStringName(2);
beforeEach(() => {
login();
disableExpandableFlyoutAdvancedSettings();
indexEndpointRuleAlerts({
endpointAgentId,
endpointHostname,
@ -73,8 +71,8 @@ describe('No License', { tags: '@ess', env: { ftrConfig: { license: 'basic' } }
navigateToAlertsList(`query=(language:kuery,query:'agent.id: "${endpointAgentId}" ')`);
closeAllToasts();
cy.getByTestSubj('expand-event').first().click();
cy.getByTestSubj('response-actions-notification').should('not.have.text', '0');
cy.getByTestSubj('responseActionsViewTab').click();
cy.getByTestSubj('securitySolutionFlyoutNavigationExpandDetailButton').click();
cy.getByTestSubj('securitySolutionFlyoutResponseTab').click();
cy.contains('Permission denied');
cy.contains(
'To access these results, ask your administrator for Elastic Defend Kibana privileges.'

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import { disableExpandableFlyoutAdvancedSettings } from '../../tasks/common';
import { navigateToAlertsList } from '../../screens/alerts';
import { generateRandomStringName } from '../../tasks/utils';
import { APP_ALERTS_PATH } from '../../../../../common/constants';
import { closeAllToasts } from '../../tasks/toasts';
import { indexEndpointHosts } from '../../tasks/index_endpoint_hosts';
import type { ReturnTypeFromChainable } from '../../types';
@ -50,57 +49,36 @@ describe('Results', { tags: ['@ess', '@serverless'] }, () => {
}
});
describe(
'see results when has RBAC',
{
// Not supported in serverless!
// The `disableExpandableFlyoutAdvancedSettings()` fails because the API
// `internal/kibana/settings` is not accessible in serverless
tags: ['@brokenInServerless'],
},
() => {
before(() => {
login(ROLE.endpoint_response_actions_access);
disableExpandableFlyoutAdvancedSettings();
});
describe('see results when has RBAC', () => {
before(() => {
login(ROLE.soc_manager);
});
it('see endpoint action', () => {
cy.visit(APP_ALERTS_PATH);
closeAllToasts();
cy.getByTestSubj('expand-event').first().click();
cy.getByTestSubj('response-actions-notification').should('not.have.text', '0');
cy.getByTestSubj('responseActionsViewTab').click();
cy.getByTestSubj('endpoint-results-comment');
cy.contains(/isolate is pending|isolate completed successfully/g);
});
}
);
describe(
'do not see results results when does not have RBAC',
{
// Not supported in serverless!
// The `disableExpandableFlyoutAdvancedSettings()` fails because the API
// `internal/kibana/settings` is not accessible in serverless
tags: ['@brokenInServerless'],
},
() => {
before(() => {
login(ROLE.endpoint_response_actions_no_access);
disableExpandableFlyoutAdvancedSettings();
});
it('see endpoint action', () => {
navigateToAlertsList(`query=(language:kuery,query:'_id: ${alertData?.alerts[0]._id}')`);
closeAllToasts();
cy.getByTestSubj('expand-event').first().click();
cy.getByTestSubj('securitySolutionFlyoutNavigationExpandDetailButton').click();
cy.getByTestSubj('securitySolutionFlyoutResponseTab').click();
cy.contains(/isolate is pending|isolate completed successfully/g);
});
});
describe('do not see results results when does not have RBAC', () => {
before(() => {
login(ROLE.t1_analyst);
});
it('show the permission denied callout', () => {
cy.visit(APP_ALERTS_PATH);
closeAllToasts();
it('show the permission denied callout', () => {
navigateToAlertsList(`query=(language:kuery,query:'_id: ${alertData?.alerts[0]._id}')`);
closeAllToasts();
cy.getByTestSubj('expand-event').first().click();
cy.getByTestSubj('response-actions-notification').should('not.have.text', '0');
cy.getByTestSubj('responseActionsViewTab').click();
cy.contains('Permission denied');
cy.contains(
'To access these results, ask your administrator for Elastic Defend Kibana privileges.'
);
});
}
);
cy.getByTestSubj('expand-event').first().click();
cy.getByTestSubj('securitySolutionFlyoutNavigationExpandDetailButton').click();
cy.getByTestSubj('securitySolutionFlyoutResponseTab').click();
cy.contains('Permission denied');
cy.contains(
'To access these results, ask your administrator for Elastic Defend Kibana privileges.'
);
});
});
});

View file

@ -69,6 +69,28 @@ export const loadRule = (body = {}, includeResponseActions = true) =>
params: { command: 'isolate', comment: 'Isolate host' },
action_type_id: '.endpoint',
},
{
params: {
command: 'suspend-process',
comment: 'Suspend host',
config: {
field: 'entity_id',
overwrite: false,
},
},
action_type_id: '.endpoint',
},
{
params: {
command: 'kill-process',
comment: 'Kill host',
config: {
field: '',
overwrite: true,
},
},
action_type_id: '.endpoint',
},
],
}
: {}),

View file

@ -25,11 +25,19 @@ import type { ResponseActionsApiCommandNames } from '../../../../common/endpoint
import { ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS } from '../../../../common/endpoint/service/response_actions/constants';
export const validateAvailableCommands = () => {
cy.get('[data-test-subj^="command-type"]').should(
'have.length',
ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS.length
);
ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS.forEach((command) => {
// TODO: TC- use ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS when we go GA with automated process actions
const config = Cypress.config();
const automatedActionsPAttern = /automatedProcessActionsEnabled/;
const automatedProcessActionsEnabled =
config.env.ftrConfig.kbnServerArgs[0].match(automatedActionsPAttern);
const enabledActions = [
...ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS,
...(automatedProcessActionsEnabled ? ['kill-process', 'suspend-process'] : []),
];
cy.get('[data-test-subj^="command-type"]').should('have.length', enabledActions.length);
enabledActions.forEach((command) => {
cy.getByTestSubj(`command-type-${command}`);
});
};

View file

@ -151,7 +151,7 @@ const combineResponse = (
return {
id: action.EndpointActions.action_id,
agents: action.agent.id as string[],
agents: Array.isArray(action.agent.id) ? action.agent.id : [action.agent.id],
agentType: 'endpoint',
parameters,
...(alertId?.length ? { alertIds: alertId } : {}),
@ -169,7 +169,7 @@ const combineResponse = (
completedAt: responseData?.completedAt,
isCompleted: !!responseData?.isCompleted,
isExpired: !!responseData?.isExpired,
wasSuccessful: !!responseData?.isCompleted,
wasSuccessful: responseData.status === 'successful',
status: responseData.status,
agentState: {},
errors: action.error ? [action.error.message as string] : undefined,

View file

@ -7,17 +7,19 @@
import type { LicenseType } from '@kbn/licensing-plugin/common/types';
import type { EcsError } from '@kbn/ecs';
import { validateAgents, validateEndpointLicense } from './validate';
import { validateAgents, validateAlertError, validateEndpointLicense } from './validate';
import type { LicenseService } from '../../../../../common/license/license';
export const addErrorsToActionIfAny = ({
agents,
licenseService,
minimumLicenseRequired = 'basic',
error,
}: {
agents: string[];
licenseService: LicenseService;
minimumLicenseRequired: LicenseType;
error?: string;
}):
| {
error: {
@ -28,7 +30,8 @@ export const addErrorsToActionIfAny = ({
| undefined => {
const licenseError = validateEndpointLicense(licenseService, minimumLicenseRequired);
const agentsError = validateAgents(agents);
const alertActionError = licenseError || agentsError;
const actionError = validateAlertError(error);
const alertActionError = licenseError || agentsError || actionError;
if (alertActionError) {
return {

View file

@ -9,7 +9,10 @@ import { i18n } from '@kbn/i18n';
import type { LicenseType } from '@kbn/licensing-plugin/server';
import type { LicenseService } from '../../../../../common/license';
export const validateEndpointLicense = (license: LicenseService, licenseType: LicenseType) => {
export const validateEndpointLicense = (
license: LicenseService,
licenseType: LicenseType
): string | undefined => {
const hasEnterpriseLicense = license.isAtLeast(licenseType);
if (!hasEnterpriseLicense) {
@ -17,12 +20,18 @@ export const validateEndpointLicense = (license: LicenseService, licenseType: Li
}
};
export const validateAgents = (agents: string[]) => {
export const validateAgents = (agents: string[]): string | undefined => {
if (!agents.length) {
return HOST_NOT_ENROLLED;
}
};
export const validateAlertError = (field?: string): string | undefined => {
if (field) {
return FIELD_NOT_EXIST(field);
}
};
export const LICENSE_TOO_LOW = i18n.translate(
'xpack.securitySolution.responseActionsList.error.licenseTooLow',
{
@ -36,3 +45,9 @@ export const HOST_NOT_ENROLLED = i18n.translate(
defaultMessage: 'The host does not have Elastic Defend integration installed',
}
);
export const FIELD_NOT_EXIST = (field: string): string =>
i18n.translate('xpack.securitySolution.responseActionsList.error.nonExistingFieldName', {
defaultMessage: 'The action was called with a non-existing event field name: {field}',
values: { field },
});

View file

@ -72,6 +72,7 @@ export const writeActionToIndices = async ({
agents,
licenseService,
minimumLicenseRequired,
error: payload.error,
}),
...addRuleInfoToAction(payload),
};

View file

@ -181,11 +181,12 @@ describe('Create rule route', () => {
// @ts-expect-error We're writting to a read only property just for the purpose of the test
clients.config.experimentalFeatures.endpointResponseActionsEnabled = true;
});
const getResponseAction = (command: string = 'isolate') => ({
const getResponseAction = (command: string = 'isolate', config?: object) => ({
action_type_id: '.endpoint',
params: {
command,
comment: '',
...(config ? { config } : {}),
},
});
const defaultAction = getResponseAction();
@ -224,8 +225,22 @@ describe('Create rule route', () => {
'User is not authorized to change isolate response actions'
);
});
test('pass when provided with process action', async () => {
const processAction = getResponseAction('kill-process', { overwrite: true, field: '' });
const request = requestMock.create({
method: 'post',
path: DETECTION_ENGINE_RULES_URL,
body: {
...getCreateRulesSchemaMock(),
response_actions: [processAction],
},
});
const result = await server.validate(request);
expect(result.badRequest).not.toHaveBeenCalled();
});
test('fails when provided with an unsupported command', async () => {
const wrongAction = getResponseAction('processes');
const wrongAction = getResponseAction('execute');
const request = requestMock.create({
method: 'post',
@ -237,7 +252,23 @@ describe('Create rule route', () => {
});
const result = await server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith(
'response_actions.0.action_type_id: Invalid literal value, expected ".osquery", response_actions.0.params.command: Invalid literal value, expected "isolate"'
`response_actions.0.action_type_id: Invalid literal value, expected \".osquery\", response_actions.0.params.command: Invalid literal value, expected \"isolate\", response_actions.0.params.command: Invalid enum value. Expected 'kill-process' | 'suspend-process', received 'execute', response_actions.0.params.config: Required`
);
});
test('fails when provided with payload missing data', async () => {
const wrongAction = getResponseAction('kill-process', { overwrite: true });
const request = requestMock.create({
method: 'post',
path: DETECTION_ENGINE_RULES_URL,
body: {
...getCreateRulesSchemaMock(),
response_actions: [wrongAction],
},
});
const result = await server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith(
`response_actions.0.action_type_id: Invalid literal value, expected \".osquery\", response_actions.0.params.command: Invalid literal value, expected \"isolate\", response_actions.0.params.config.field: Required`
);
});
});

View file

@ -23,7 +23,7 @@ import {
getUpdateRulesSchemaMock,
} from '../../../../../../../common/api/detection_engine/model/rule_schema/mocks';
import { getQueryRuleParams } from '../../../../rule_schema/mocks';
import { ResponseActionTypesEnum } from '../../../../../../../common/api/detection_engine/model/rule_response_actions';
import { ResponseActionTypesEnum } from '../../../../../../../common/api/detection_engine';
jest.mock('../../../../../machine_learning/authz');
@ -189,11 +189,12 @@ describe('Update rule route', () => {
// @ts-expect-error We're writting to a read only property just for the purpose of the test
clients.config.experimentalFeatures.endpointResponseActionsEnabled = true;
});
const getResponseAction = (command: string = 'isolate') => ({
const getResponseAction = (command: string = 'isolate', config?: object) => ({
action_type_id: '.endpoint',
params: {
command,
comment: '',
...(config ? { config } : {}),
},
});
const defaultAction = getResponseAction();
@ -249,6 +250,7 @@ describe('Update rule route', () => {
params: {
command: 'isolate',
comment: '',
config: undefined,
},
},
],
@ -272,7 +274,7 @@ describe('Update rule route', () => {
);
});
test('fails when provided with an unsupported command', async () => {
const wrongAction = getResponseAction('processes');
const wrongAction = getResponseAction('execute');
const request = requestMock.create({
method: 'post',
@ -284,7 +286,23 @@ describe('Update rule route', () => {
});
const result = await server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith(
`response_actions.0.action_type_id: Invalid literal value, expected \".osquery\", response_actions.0.params.command: Invalid literal value, expected \"isolate\"`
`response_actions.0.action_type_id: Invalid literal value, expected \".osquery\", response_actions.0.params.command: Invalid literal value, expected \"isolate\", response_actions.0.params.command: Invalid enum value. Expected 'kill-process' | 'suspend-process', received 'execute', response_actions.0.params.config: Required`
);
});
test('fails when provided with payload missing data', async () => {
const wrongAction = getResponseAction('kill-process', { overwrite: true });
const request = requestMock.create({
method: 'post',
path: DETECTION_ENGINE_RULES_URL,
body: {
...getCreateRulesSchemaMock(),
response_actions: [wrongAction],
},
});
const result = await server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith(
`response_actions.0.action_type_id: Invalid literal value, expected \".osquery\", response_actions.0.params.command: Invalid literal value, expected \"isolate\", response_actions.0.params.config.field: Required`
);
});
});

View file

@ -0,0 +1,24 @@
/*
* 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 {
DefaultParams,
ProcessesParams,
RuleResponseEndpointAction,
} from '../../../../common/api/detection_engine';
export const isIsolateAction = (
params: RuleResponseEndpointAction['params']
): params is DefaultParams => {
return params.command === 'isolate';
};
export const isProcessesAction = (
params: RuleResponseEndpointAction['params']
): params is ProcessesParams => {
return params.command === 'kill-process' || params.command === 'suspend-process';
};

View file

@ -5,49 +5,81 @@
* 2.0.
*/
import { each, map, uniq } from 'lodash';
import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import type { ResponseActionAlerts } from './types';
import { each } from 'lodash';
import type { ExperimentalFeatures } from '../../../../common';
import { isIsolateAction, isProcessesAction } from './endpoint_params_type_guards';
import type { RuleResponseEndpointAction } from '../../../../common/api/detection_engine';
import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services';
import type { RuleResponseEndpointAction } from '../../../../common/api/detection_engine/model/rule_response_actions';
import { getProcessAlerts, getIsolateAlerts, getErrorProcessAlerts } from './utils';
import type { ResponseActionAlerts, AlertsAction } from './types';
export const endpointResponseAction = (
responseAction: RuleResponseEndpointAction,
endpointAppContextService: EndpointAppContextService,
{ alerts }: ResponseActionAlerts
{ alerts }: ResponseActionAlerts,
experimentalFeatures: ExperimentalFeatures
) => {
const { comment, command } = responseAction.params;
const commonData = {
comment,
command,
rule_id: alerts[0][ALERT_RULE_UUID],
rule_name: alerts[0][ALERT_RULE_NAME],
agent_type: 'endpoint' as const,
};
const agentIds = uniq(map(alerts, 'agent.id'));
const alertIds = map(alerts, '_id');
const hosts = alerts.reduce<Record<string, string>>((acc, alert) => {
if (alert.agent?.name && !acc[alert.agent.id]) {
acc[alert.agent.id] = alert.agent.name;
}
return acc;
}, {});
return Promise.all(
each(agentIds, async (agent) =>
endpointAppContextService.getActionCreateService().createActionFromAlert(
if (isIsolateAction(responseAction.params)) {
const alertsPerAgent = getIsolateAlerts(alerts);
each(alertsPerAgent, (actionPayload) => {
return endpointAppContextService.getActionCreateService().createActionFromAlert(
{
hosts: {
[agent]: {
name: hosts[agent],
},
},
agent_type: 'endpoint',
endpoint_ids: [agent],
alert_ids: alertIds,
...actionPayload,
...commonData,
},
[agent]
)
)
);
actionPayload.endpoint_ids
);
});
}
const automatedProcessActionsEnabled = experimentalFeatures?.automatedProcessActionsEnabled;
if (automatedProcessActionsEnabled) {
const createProcessActionFromAlerts = (
actionAlerts: Record<string, Record<string, AlertsAction>>
) => {
const createAction = async (alert: AlertsAction) => {
const { hosts, parameters, error } = alert;
const actionData = {
hosts,
endpoint_ids: alert.endpoint_ids,
alert_ids: alert.alert_ids,
error,
parameters,
...commonData,
};
return endpointAppContextService
.getActionCreateService()
.createActionFromAlert(actionData, alert.endpoint_ids);
};
return each(actionAlerts, (actionPerAgent) => {
return each(actionPerAgent, createAction);
});
};
if (isProcessesAction(responseAction.params)) {
const foundFields = getProcessAlerts(alerts, responseAction.params.config);
const notFoundField = getErrorProcessAlerts(alerts, responseAction.params.config);
const processActions = createProcessActionFromAlerts(foundFields);
const processActionsWithError = createProcessActionFromAlerts(notFoundField);
return Promise.all([processActions, processActionsWithError]);
}
}
};

View file

@ -6,105 +6,213 @@
*/
import { getScheduleNotificationResponseActionsService } from './schedule_notification_response_actions';
import type { RuleResponseAction } from '../../../../common/api/detection_engine/model/rule_response_actions';
import { ResponseActionTypesEnum } from '../../../../common/api/detection_engine/model/rule_response_actions';
import type { RuleResponseAction } from '../../../../common/api/detection_engine';
import { ResponseActionTypesEnum } from '../../../../common/api/detection_engine';
import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
describe('ScheduleNotificationResponseActions', () => {
const signalOne = { agent: { id: 'agent-id-1' }, _id: 'alert-id-1', user: { id: 'S-1-5-20' } };
const signalOne = {
agent: { id: 'agent-id-1' },
_id: 'alert-id-1',
user: { id: 'S-1-5-20' },
process: {
pid: 123,
},
[ALERT_RULE_UUID]: 'rule-id-1',
[ALERT_RULE_NAME]: 'rule-name-1',
};
const signalTwo = { agent: { id: 'agent-id-2' }, _id: 'alert-id-2' };
const signals = [signalOne, signalTwo];
const defaultQueryParams = {
ecsMapping: { testField: { field: 'testField', value: 'testValue' } },
savedQueryId: 'testSavedQueryId',
query: undefined,
queries: [],
packId: undefined,
};
const defaultPackParams = {
packId: 'testPackId',
queries: [],
query: undefined,
ecsMapping: { testField: { field: 'testField', value: 'testValue' } },
savedQueryId: undefined,
};
const defaultQueries = {
ecs_mapping: undefined,
platform: 'windows',
version: '1.0.0',
snapshot: true,
removed: false,
};
const getSignals = () => [signalOne, signalTwo];
const defaultResultParams = {
agent_ids: ['agent-id-1', 'agent-id-2'],
alert_ids: ['alert-id-1', 'alert-id-2'],
};
const defaultQueryResultParams = {
...defaultResultParams,
ecs_mapping: { testField: { field: 'testField', value: 'testValue' } },
ecsMapping: undefined,
saved_query_id: 'testSavedQueryId',
savedQueryId: undefined,
queries: [],
};
const defaultPackResultParams = {
...defaultResultParams,
query: undefined,
saved_query_id: undefined,
ecs_mapping: { testField: { field: 'testField', value: 'testValue' } },
};
const osqueryActionMock = {
create: jest.fn(),
stop: jest.fn(),
};
const endpointActionMock = jest.fn();
const endpointActionMock = {
getActionCreateService: jest.fn().mockReturnValue({
createActionFromAlert: jest.fn(),
}),
};
const scheduleNotificationResponseActions = getScheduleNotificationResponseActionsService({
osqueryCreateActionService: osqueryActionMock,
endpointAppContextService: endpointActionMock as never,
experimentalFeatures: {
automatedProcessActionsEnabled: true,
endpointResponseActionsEnabled: true,
} as never,
});
const simpleQuery = 'select * from uptime';
it('should handle osquery response actions with query', async () => {
const responseActions: RuleResponseAction[] = [
{
actionTypeId: ResponseActionTypesEnum['.osquery'],
params: {
...defaultQueryParams,
query: simpleQuery,
},
},
];
scheduleNotificationResponseActions({ signals, responseActions });
describe('Osquery', () => {
const simpleQuery = 'select * from uptime';
const defaultQueryParams = {
ecsMapping: { testField: { field: 'testField', value: 'testValue' } },
savedQueryId: 'testSavedQueryId',
query: undefined,
queries: [],
packId: undefined,
};
const defaultPackParams = {
packId: 'testPackId',
queries: [],
query: undefined,
ecsMapping: { testField: { field: 'testField', value: 'testValue' } },
savedQueryId: undefined,
};
const defaultQueries = {
ecs_mapping: undefined,
platform: 'windows',
version: '1.0.0',
snapshot: true,
removed: false,
};
expect(osqueryActionMock.create).toHaveBeenCalledWith({
...defaultQueryResultParams,
query: simpleQuery,
const defaultResultParams = {
agent_ids: ['agent-id-1', 'agent-id-2'],
alert_ids: ['alert-id-1', 'alert-id-2'],
};
const defaultQueryResultParams = {
...defaultResultParams,
ecs_mapping: { testField: { field: 'testField', value: 'testValue' } },
ecsMapping: undefined,
saved_query_id: 'testSavedQueryId',
savedQueryId: undefined,
queries: [],
};
const defaultPackResultParams = {
...defaultResultParams,
query: undefined,
saved_query_id: undefined,
ecs_mapping: { testField: { field: 'testField', value: 'testValue' } },
};
it('should handle osquery response actions with query', async () => {
const signals = getSignals();
const responseActions: RuleResponseAction[] = [
{
actionTypeId: ResponseActionTypesEnum['.osquery'],
params: {
...defaultQueryParams,
query: simpleQuery,
},
},
];
scheduleNotificationResponseActions({ signals, responseActions });
expect(osqueryActionMock.create).toHaveBeenCalledWith({
...defaultQueryResultParams,
query: simpleQuery,
});
});
//
});
it('should handle osquery response actions with packs', async () => {
const responseActions: RuleResponseAction[] = [
{
actionTypeId: ResponseActionTypesEnum['.osquery'],
params: {
...defaultPackParams,
queries: [
{
...defaultQueries,
id: 'query-1',
query: simpleQuery,
},
],
packId: 'testPackId',
},
},
];
scheduleNotificationResponseActions({ signals, responseActions });
expect(osqueryActionMock.create).toHaveBeenCalledWith({
...defaultPackResultParams,
queries: [{ ...defaultQueries, id: 'query-1', query: simpleQuery }],
it('should handle osquery response actions with packs', async () => {
const signals = getSignals();
const responseActions: RuleResponseAction[] = [
{
actionTypeId: ResponseActionTypesEnum['.osquery'],
params: {
...defaultPackParams,
queries: [
{
...defaultQueries,
id: 'query-1',
query: simpleQuery,
},
],
packId: 'testPackId',
},
},
];
scheduleNotificationResponseActions({ signals, responseActions });
expect(osqueryActionMock.create).toHaveBeenCalledWith({
...defaultPackResultParams,
queries: [{ ...defaultQueries, id: 'query-1', query: simpleQuery }],
});
});
});
describe('Endpoint', () => {
it('should handle endpoint isolate actions', async () => {
const signals = getSignals();
const responseActions: RuleResponseAction[] = [
{
actionTypeId: ResponseActionTypesEnum['.endpoint'],
params: {
command: 'isolate',
comment: 'test isolate comment',
},
},
];
scheduleNotificationResponseActions({ signals, responseActions });
expect(
endpointActionMock.getActionCreateService().createActionFromAlert
).toHaveBeenCalledTimes(signals.length);
expect(
endpointActionMock.getActionCreateService().createActionFromAlert
).toHaveBeenCalledWith(
{
alert_ids: ['alert-id-1'],
command: 'isolate',
comment: 'test isolate comment',
endpoint_ids: ['agent-id-1'],
agent_type: 'endpoint',
hosts: {
'agent-id-1': {
id: 'agent-id-1',
name: '',
},
},
rule_id: 'rule-id-1',
rule_name: 'rule-name-1',
},
['agent-id-1']
);
});
it('should handle endpoint kill-process actions', async () => {
const signals = getSignals();
const responseActions: RuleResponseAction[] = [
{
actionTypeId: ResponseActionTypesEnum['.endpoint'],
params: {
command: 'kill-process',
comment: 'test process comment',
config: {
overwrite: true,
field: '',
},
},
},
];
scheduleNotificationResponseActions({
signals,
responseActions,
});
expect(
endpointActionMock.getActionCreateService().createActionFromAlert
).toHaveBeenCalledWith(
{
agent_type: 'endpoint',
alert_ids: ['alert-id-1'],
command: 'kill-process',
comment: 'test process comment',
endpoint_ids: ['agent-id-1'],
error: undefined,
hosts: {
'agent-id-1': {
id: 'agent-id-1',
name: undefined,
},
},
parameters: {
pid: 123,
},
rule_id: 'rule-id-1',
rule_name: 'rule-name-1',
},
['agent-id-1']
);
});
});
});

View file

@ -6,23 +6,26 @@
*/
import { each } from 'lodash';
import type { ExperimentalFeatures } from '../../../../common';
import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services';
import type { SetupPlugins } from '../../../plugin_contract';
import { ResponseActionTypesEnum } from '../../../../common/api/detection_engine/model/rule_response_actions';
import { osqueryResponseAction } from './osquery_response_action';
import { endpointResponseAction } from './endpoint_response_action';
import type { ScheduleNotificationActions } from '../rule_types/types';
import type { Alert, AlertWithAgent } from './types';
import type { AlertWithAgent, Alert } from './types';
interface ScheduleNotificationResponseActionsService {
endpointAppContextService: EndpointAppContextService;
osqueryCreateActionService?: SetupPlugins['osquery']['createActionService'];
experimentalFeatures: ExperimentalFeatures;
}
export const getScheduleNotificationResponseActionsService =
({
osqueryCreateActionService,
endpointAppContextService,
experimentalFeatures,
}: ScheduleNotificationResponseActionsService) =>
({ signals, responseActions }: ScheduleNotificationActions) => {
const alerts = (signals as Alert[]).filter((alert) => alert.agent?.id) as AlertWithAgent[];
@ -37,9 +40,14 @@ export const getScheduleNotificationResponseActionsService =
});
}
if (responseAction.actionTypeId === ResponseActionTypesEnum['.endpoint']) {
endpointResponseAction(responseAction, endpointAppContextService, {
alerts,
});
endpointResponseAction(
responseAction,
endpointAppContextService,
{
alerts,
},
experimentalFeatures
);
}
});
};

View file

@ -6,11 +6,17 @@
*/
import type { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common';
import type { CreateActionPayload } from '../../../endpoint/services/actions/create/types';
export type Alert = ParsedTechnicalFields & {
_id: string;
agent?: AlertAgent;
process?: { pid: string };
host?: {
name: string;
};
process?: {
pid: string;
};
};
export interface AlertAgent {
@ -25,3 +31,8 @@ export interface AlertWithAgent extends Alert {
export interface ResponseActionAlerts {
alerts: AlertWithAgent[];
}
export type AlertsAction = Pick<
CreateActionPayload,
'alert_ids' | 'endpoint_ids' | 'hosts' | 'parameters' | 'error'
>;

View file

@ -0,0 +1,242 @@
/*
* 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 { getErrorProcessAlerts, getIsolateAlerts, getProcessAlerts } from './utils';
import type { AlertWithAgent } from './types';
const getSampleAlerts = (): AlertWithAgent[] => {
const alert = {
_id: 'alert1',
process: {
pid: 1,
},
agent: {
name: 'jammy-1',
id: 'agent-id-1',
},
};
const alert2 = {
_id: 'alert2',
process: {
pid: 2,
},
agent: {
name: 'jammy-2',
id: 'agent-id-2',
},
};
const alert3 = {
_id: 'alert3',
process: {
pid: 2,
},
agent: {
name: 'jammy-1',
id: 'agent-id-1',
},
};
const alert4 = {
_id: 'alert4',
agent: {
name: 'jammy-1',
id: 'agent-id-1',
},
};
const alert5 = {
_id: 'alert5',
process: {
entity_id: 2,
},
agent: {
name: 'jammy-1',
id: 'agent-id-1',
},
};
// Casted as unknown first because we do not need all the data to test the functionality
return [alert, alert2, alert3, alert4, alert5] as unknown as AlertWithAgent[];
};
describe('EndpointResponseActionsUtils', () => {
describe('getIsolateAlerts', () => {
const alerts = getSampleAlerts();
it('should return proper number of actions divided per agents with specified alert_ids', async () => {
const isolateAlerts = getIsolateAlerts(alerts);
const result = {
'agent-id-1': {
alert_ids: ['alert1', 'alert3', 'alert4', 'alert5'],
endpoint_ids: ['agent-id-1'],
hosts: {
'agent-id-1': {
id: 'agent-id-1',
name: 'jammy-1',
},
},
},
'agent-id-2': {
alert_ids: ['alert2'],
endpoint_ids: ['agent-id-2'],
hosts: {
'agent-id-2': {
id: 'agent-id-2',
name: 'jammy-2',
},
},
},
};
expect(isolateAlerts).toEqual(result);
});
});
describe('getProcessAlerts', () => {
const alerts = getSampleAlerts();
it('should return actions that are valid based on default field (pid)', async () => {
const processAlerts = getProcessAlerts(alerts, {
overwrite: true,
field: '',
});
const result = {
'agent-id-1': {
'1': {
alert_ids: ['alert1'],
endpoint_ids: ['agent-id-1'],
hosts: {
'agent-id-1': {
id: 'agent-id-1',
name: 'jammy-1',
},
},
parameters: {
pid: 1,
},
},
'2': {
alert_ids: ['alert3'],
endpoint_ids: ['agent-id-1'],
hosts: {
'agent-id-1': {
id: 'agent-id-1',
name: 'jammy-1',
},
},
parameters: {
pid: 2,
},
},
},
'agent-id-2': {
'2': {
alert_ids: ['alert2'],
endpoint_ids: ['agent-id-2'],
hosts: {
'agent-id-2': {
id: 'agent-id-2',
name: 'jammy-2',
},
},
parameters: {
pid: 2,
},
},
},
};
expect(processAlerts).toEqual(result);
});
it('should return actions that do not have value from default field (pid)', async () => {
const processAlerts = getProcessAlerts(alerts, {
overwrite: false,
field: 'process.entity_id',
});
const result = {
'agent-id-1': {
'2': {
alert_ids: ['alert5'],
endpoint_ids: ['agent-id-1'],
hosts: {
'agent-id-1': {
id: 'agent-id-1',
name: 'jammy-1',
},
},
parameters: {
entity_id: 2,
},
},
},
};
expect(processAlerts).toEqual(result);
});
});
describe('getErrorProcessAlerts', () => {
const alerts = getSampleAlerts();
it('should return actions that do not have value from default field (pid)', async () => {
const processAlerts = getErrorProcessAlerts(alerts, {
overwrite: true,
field: '',
});
const result = {
'agent-id-1': {
'process.pid': {
alert_ids: ['alert4', 'alert5'],
endpoint_ids: ['agent-id-1'],
error: 'process.pid',
hosts: {
'agent-id-1': {
id: 'agent-id-1',
name: 'jammy-1',
},
},
parameters: {},
},
},
};
expect(processAlerts).toEqual(result);
});
it('should return actions that do not have value from custom field name', async () => {
const processAlerts = getErrorProcessAlerts(alerts, {
overwrite: false,
field: 'process.entity_id',
});
const result = {
'agent-id-1': {
'process.entity_id': {
alert_ids: ['alert1', 'alert3', 'alert4'],
endpoint_ids: ['agent-id-1'],
error: 'process.entity_id',
hosts: {
'agent-id-1': {
id: 'agent-id-1',
name: 'jammy-1',
},
},
parameters: {},
},
},
'agent-id-2': {
'process.entity_id': {
alert_ids: ['alert2'],
endpoint_ids: ['agent-id-2'],
error: 'process.entity_id',
hosts: {
'agent-id-2': {
id: 'agent-id-2',
name: 'jammy-2',
},
},
parameters: {},
},
},
};
expect(processAlerts).toEqual(result);
});
});
});

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { get } from 'lodash';
import type { AlertAgent, AlertWithAgent, AlertsAction } from './types';
import type { ProcessesParams } from '../../../../common/api/detection_engine';
interface ProcessAlertsAcc {
[key: string]: Record<string, AlertsAction>;
}
export const getProcessAlerts = (
alerts: AlertWithAgent[],
config: ProcessesParams['config']
): ProcessAlertsAcc => {
if (!config) {
return {};
}
const { overwrite, field } = config;
return alerts.reduce((acc: ProcessAlertsAcc, alert) => {
const valueFromAlert: number = overwrite ? alert.process?.pid : get(alert, field);
if (valueFromAlert) {
const isEntityId = !overwrite && field.includes('entity_id');
const paramKey = isEntityId ? 'entity_id' : 'pid';
const { _id, agent } = alert;
const { id: agentId, name } = agent as AlertAgent;
const hostName = alert.host?.name;
const currentAgent = acc[agentId];
const currentValue = currentAgent?.[valueFromAlert];
return {
...acc,
[agentId]: {
...(currentAgent || {}),
[valueFromAlert]: {
...(currentValue || {}),
alert_ids: [...(currentValue?.alert_ids || []), _id],
parameters: { [paramKey]: valueFromAlert },
endpoint_ids: [agentId],
hosts: {
...currentValue?.hosts,
[agentId]: { name: name || hostName, id: agentId },
},
},
},
};
}
return acc;
}, {});
};
export const getErrorProcessAlerts = (
alerts: AlertWithAgent[],
config: ProcessesParams['config']
): ProcessAlertsAcc => {
if (!config) {
return {};
}
const { overwrite, field } = config;
return alerts.reduce((acc: ProcessAlertsAcc, alert) => {
const valueFromAlert: number = overwrite ? alert.process?.pid : get(alert, field);
if (!valueFromAlert) {
const { _id, agent } = alert;
const { id: agentId, name } = agent as AlertAgent;
const hostName = alert.host?.name;
const errorField = overwrite ? 'process.pid' : field;
const currentAgent = acc[agentId];
const currentValue = currentAgent?.[errorField];
return {
...acc,
[agentId]: {
...(currentAgent || {}),
[errorField]: {
...(currentValue || {}),
alert_ids: [...(currentValue?.alert_ids || []), _id],
parameters: {},
endpoint_ids: [agentId],
hosts: {
...currentValue?.hosts,
[agentId]: { name: name || hostName || '', id: agentId },
},
error: errorField,
},
},
};
}
return acc;
}, {});
};
export const getIsolateAlerts = (alerts: AlertWithAgent[]): Record<string, AlertsAction> =>
alerts.reduce((acc: Record<string, AlertsAction>, alert) => {
const { id: agentId, name: agentName } = alert.agent || {};
const hostName = alert.host?.name;
return {
...acc,
[agentId]: {
...(acc?.[agentId] || {}),
hosts: {
...(acc[agentId]?.hosts || {}),
[agentId]: {
name: agentName || hostName || '',
id: agentId,
},
},
endpoint_ids: [agentId],
alert_ids: [...(acc[agentId]?.alert_ids || []), alert._id],
},
};
}, {});

View file

@ -294,6 +294,7 @@ export class Plugin implements ISecuritySolutionPlugin {
scheduleNotificationResponseActionsService: getScheduleNotificationResponseActionsService({
endpointAppContextService: this.endpointAppContextService,
osqueryCreateActionService: plugins.osquery.createActionService,
experimentalFeatures: config.experimentalFeatures,
}),
};