[Attack Discovery][Scheduling] Add cases action support (#222827)

## Summary

With these changes we add Case Action support to Attack Discovery
Schedule rule types.

Attack discovery alerts act differently from SIEM alerts and include the
reference to list of SIEM alerts that led to the attack - described
within the attack discovery alert document. Thus, we would like to
attach referenced SIEM alerts instead of the attack alert document
itself to the created Case. Also, as part of the Case creation we would
like to be able to add a comment generated by LLM that describes steps
and nuance of the discovery.

## NOTES

The attack discovery scheduling feature is hidden behind the feature
flag (in `kibana.dev.yml`):

```
feature_flags.overrides:
  securitySolution.attackDiscoveryAlertsEnabled: true
```

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Christos Nasikas <xristosnasikas@gmail.com>
This commit is contained in:
Ievgen Sorokopud 2025-06-24 19:56:22 +02:00 committed by GitHub
parent e91f76ebf7
commit 081872cf5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1208 additions and 137 deletions

View file

@ -10,8 +10,37 @@ import {
getAttackDiscoveryMarkdown,
getMarkdownFields,
getMarkdownWithOriginalValues,
} from './get_attack_discovery_markdown';
import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery';
} from '.';
import { AttackDiscovery } from '../../schemas';
export const mockAttackDiscovery: AttackDiscovery = {
alertIds: [
'639801cdb10a93610be4a91fe0eac94cd3d4d292cf0c2a6d7b3674d7f7390357',
'bdcf649846dc3ed0ca66537e1c1dc62035a35a208ba4d9853a93e9be4b0dbea3',
'cdbd13134bbd371cd045e5f89970b21ab866a9c3817b2aaba8d8e247ca88b823',
'58571e1653b4201c4f35d49b6eb4023fc0219d5885ff7c385a9253a692a77104',
'06fcb3563de7dad14137c0bb4e5bae17948c808b8a3b8c60d9ec209a865b20ed',
'8bd3fcaeca5698ee26df402c8bc40df0404d34a278bc0bd9355910c8c92a4aee',
'59ff4efa1a03b0d1cb5c0640f5702555faf5c88d273616c1b6e22dcfc47ac46c',
'f352f8ca14a12062cde77ff2b099202bf74f4a7d757c2ac75ac63690b2f2f91a',
],
detailsMarkdown:
'The following attack progression appears to have occurred on the host {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} involving the user {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}:\n\n- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation.\n- This application spawned child processes to copy a malicious file named "unix1" to the user\'s home directory and make it executable.\n- The malicious "unix1" file was then executed, attempting to access the user\'s login keychain and potentially exfiltrate credentials.\n- The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing.\n\nThis appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.',
entitySummaryMarkdown:
'Suspicious activity involving the host {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} and user {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}.',
id: 'e6d1f8ef-7c1d-42d6-ba6a-11610bab72b1',
mitreAttackTactics: [
'Initial Access',
'Execution',
'Persistence',
'Privilege Escalation',
'Credential Access',
],
summaryMarkdown:
'A multi-stage malware attack was detected on {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} involving {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}. A suspicious application delivered malware, attempted credential theft, and established persistence.',
timestamp: '2024-06-25T21:14:40.098Z',
title: 'Malware Attack With Credential Theft Attempt',
};
describe('getAttackDiscoveryMarkdown', () => {
describe('getMarkdownFields', () => {

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import { getTacticLabel, getTacticMetadata } from '../../../../helpers';
import { AttackDiscovery, Replacements } from '../../schemas';
import { getTacticLabel, getTacticMetadata } from '../attack_discovery_helpers';
export const getMarkdownFields = (markdown: string): string => {
const regex = new RegExp('{{\\s*(\\S+)\\s+(\\S+)\\s*}}', 'gm');

View file

@ -69,6 +69,13 @@ export {
export { getAttackDiscoveryLoadingMessage } from './impl/utils/get_attack_discovery_loading_message';
export {
getAttackChainMarkdown,
getAttackDiscoveryMarkdown,
getMarkdownFields,
getMarkdownWithOriginalValues,
} from './impl/utils/get_attack_discovery_markdown';
export {
getTacticLabel,
getTacticMetadata,

View file

@ -2549,6 +2549,243 @@ Object {
],
"type": "array",
},
"groupedAlerts": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"default": Array [],
"error": [Function],
"presence": "optional",
},
"items": Array [
Object {
"flags": Object {
"default": Object {
"special": "deep",
},
"error": [Function],
"presence": "optional",
},
"keys": Object {
"alerts": Object {
"flags": Object {
"error": [Function],
},
"items": Array [
Object {
"flags": Object {
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-get-additional-properties": [Function],
},
],
"rules": Array [
Object {
"args": Object {
"key": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"value": Object {
"flags": Object {
"error": [Function],
},
"metas": Array [
Object {
"x-oas-any-type": true,
},
],
"type": "any",
},
},
"name": "entries",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "record",
},
],
"rules": Array [
Object {
"args": Object {
"limit": 1000,
},
"name": "max",
},
],
"type": "array",
},
"comments": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"items": Array [
Object {
"flags": Object {
"error": [Function],
"presence": "optional",
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
],
"metas": Array [
Object {
"x-oas-optional": true,
},
],
"rules": Array [
Object {
"args": Object {
"limit": 5000,
},
"name": "max",
},
],
"type": "array",
},
"grouping": Object {
"flags": Object {
"error": [Function],
},
"metas": Array [
Object {
"x-oas-get-additional-properties": [Function],
},
],
"rules": Array [
Object {
"args": Object {
"key": Object {
"flags": Object {
"error": [Function],
},
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
"value": Object {
"flags": Object {
"error": [Function],
},
"metas": Array [
Object {
"x-oas-any-type": true,
},
],
"type": "any",
},
},
"name": "entries",
},
],
"type": "record",
},
"title": Object {
"flags": Object {
"default": [Function],
"error": [Function],
"presence": "optional",
},
"metas": Array [
Object {
"x-oas-max-length": 160,
},
Object {
"x-oas-optional": true,
},
],
"rules": Array [
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
Object {
"args": Object {
"method": [Function],
},
"name": "custom",
},
],
"type": "string",
},
},
"type": "object",
},
],
"rules": Array [
Object {
"args": Object {
"limit": 0,
},
"name": "min",
},
Object {
"args": Object {
"limit": 10,
},
"name": "max",
},
],
"type": "array",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"groupingBy": Object {
"flags": Object {
"error": [Function],
@ -2586,6 +2823,38 @@ Object {
],
"type": "array",
},
"internallyManagedAlerts": Object {
"flags": Object {
"default": null,
"error": [Function],
"presence": "optional",
},
"matches": Array [
Object {
"schema": Object {
"flags": Object {
"default": false,
"error": [Function],
"presence": "optional",
},
"type": "boolean",
},
},
Object {
"schema": Object {
"allow": Array [
null,
],
"flags": Object {
"error": [Function],
"only": true,
},
"type": "any",
},
},
],
"type": "alternatives",
},
"maximumCasesToOpen": Object {
"flags": Object {
"default": 5,

View file

@ -11,6 +11,7 @@ import type { CombinedSummarizedAlerts } from '../types';
type Rule = Pick<SanitizedRule<RuleTypeParams>, 'id' | 'name' | 'tags' | 'consumer'> & {
producer: string;
ruleTypeId: string;
};
export interface ConnectorAdapterParams {

View file

@ -144,6 +144,7 @@ export class SystemActionScheduler<
name: this.context.rule.name,
consumer: this.context.rule.consumer,
producer: this.context.ruleType.producer,
ruleTypeId: this.context.rule.alertTypeId,
},
ruleUrl: ruleUrl?.absoluteUrl,
spaceId: this.context.taskInstance.params.spaceId,

View file

@ -17,6 +17,7 @@ interface Props {
isLoading: boolean;
templates: CasesConfigurationUI['templates'];
initialTemplate?: CasesConfigurationUI['templates'][number];
isDisabled?: boolean;
onTemplateChange: ({
caseFields,
key,
@ -27,6 +28,7 @@ export const TemplateSelectorComponent: React.FC<Props> = ({
isLoading,
templates,
initialTemplate,
isDisabled,
onTemplateChange,
}) => {
const [selectedTemplate, onSelectTemplate] = useState<string | undefined>(
@ -72,7 +74,7 @@ export const TemplateSelectorComponent: React.FC<Props> = ({
<EuiSelect
onChange={onChange}
options={options}
disabled={isLoading}
disabled={isLoading || isDisabled}
isLoading={isLoading}
data-test-subj="create-case-template-select"
fullWidth

View file

@ -19,8 +19,10 @@ import {
EuiSpacer,
EuiComboBox,
EuiCallOut,
EuiToolTip,
} from '@elastic/eui';
import { useAlertsDataView } from '@kbn/alerts-ui-shared/src/common/hooks/use_alerts_data_view';
import { ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import type { CasesActionParams } from './types';
import { CASES_CONNECTOR_SUB_ACTION } from '../../../../common/constants';
@ -74,6 +76,8 @@ export const CasesParamsFieldsComponent: React.FunctionComponent<
[configurations, owner]
);
const isAttackDiscoveryRuleType = ruleTypeId === ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID;
const { timeWindow, reopenClosedCases, groupingBy, templateId } = useMemo(
() =>
actionParams.subActionParams ?? {
@ -169,11 +173,13 @@ export const CasesParamsFieldsComponent: React.FunctionComponent<
const selectedOptions = groupingBy.map((field) => ({ value: field, label: field }));
const selectedTemplate = currentConfiguration.templates.find((t) => t.key === templateId);
const defaultTemplate = {
key: DEFAULT_EMPTY_TEMPLATE_KEY,
name: i18n.DEFAULT_EMPTY_TEMPLATE_NAME,
caseFields: null,
};
const defaultTemplate = useMemo(() => {
return {
key: DEFAULT_EMPTY_TEMPLATE_KEY,
name: i18n.DEFAULT_EMPTY_TEMPLATE_NAME,
caseFields: null,
};
}, []);
const onTemplateChange = useCallback(
({ key, caseFields }: Pick<CasesConfigurationUITemplate, 'caseFields' | 'key'>) => {
@ -182,8 +188,8 @@ export const CasesParamsFieldsComponent: React.FunctionComponent<
[editSubActionProperty]
);
return (
<>
const groupByComponent = useMemo(() => {
return (
<EuiFlexGroup>
<EuiFlexItem grow={true}>
<EuiFormRow fullWidth label={i18n.GROUP_BY_ALERT} labelAppend={OptionalFieldLabel}>
@ -201,54 +207,71 @@ export const CasesParamsFieldsComponent: React.FunctionComponent<
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
id="timeWindow"
error={errors.timeWindow as string[]}
isInvalid={
errors.timeWindow !== undefined &&
Number(errors.timeWindow.length) > 0 &&
timeWindow !== undefined
}
>
<EuiFlexGroup alignItems="flexEnd" gutterSize="s">
<EuiFlexItem grow={4}>
<EuiFieldNumber
prepend={i18n.TIME_WINDOW}
data-test-subj="time-window-size-input"
value={timeWindowSize}
min={1}
step={1}
onChange={(e) => {
handleTimeWindowChange('timeWindowSize', e.target.value);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={3}>
<EuiSelect
fullWidth
data-test-subj="time-window-unit-select"
value={timeWindowUnit}
onChange={(e) => {
handleTimeWindowChange('timeWindowUnit', e.target.value);
}}
options={getTimeUnitOptions(timeWindowSize)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiSpacer size="s" />
{showTimeWindowWarning && (
<EuiCallOut
data-test-subj="show-time-window-warning"
title={i18n.TIME_WINDOW_WARNING}
color="warning"
iconType="alert"
size="s"
/>
)}
<EuiSpacer size="m" />
);
}, [loadingAlertDataViews, onChangeComboBox, options, selectedOptions]);
const timeWindowComponent = useMemo(() => {
return (
<>
<EuiFormRow
fullWidth
id="timeWindow"
error={errors.timeWindow as string[]}
isInvalid={
errors.timeWindow !== undefined &&
Number(errors.timeWindow.length) > 0 &&
timeWindow !== undefined
}
>
<EuiFlexGroup alignItems="flexEnd" gutterSize="s">
<EuiFlexItem grow={4}>
<EuiFieldNumber
prepend={i18n.TIME_WINDOW}
data-test-subj="time-window-size-input"
value={timeWindowSize}
min={1}
step={1}
onChange={(e) => {
handleTimeWindowChange('timeWindowSize', e.target.value);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={3}>
<EuiSelect
fullWidth
data-test-subj="time-window-unit-select"
value={timeWindowUnit}
onChange={(e) => {
handleTimeWindowChange('timeWindowUnit', e.target.value);
}}
options={getTimeUnitOptions(timeWindowSize)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiSpacer size="s" />
{showTimeWindowWarning && (
<EuiCallOut
data-test-subj="show-time-window-warning"
title={i18n.TIME_WINDOW_WARNING}
color="warning"
iconType="alert"
size="s"
/>
)}
</>
);
}, [
errors.timeWindow,
handleTimeWindowChange,
showTimeWindowWarning,
timeWindow,
timeWindowSize,
timeWindowUnit,
]);
const templateSelectorComponent = useMemo(() => {
return (
<EuiFlexGroup>
<EuiFlexItem grow={true}>
<TemplateSelector
@ -257,10 +280,23 @@ export const CasesParamsFieldsComponent: React.FunctionComponent<
templates={[defaultTemplate, ...currentConfiguration.templates]}
onTemplateChange={onTemplateChange}
initialTemplate={selectedTemplate}
isDisabled={isAttackDiscoveryRuleType}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
);
}, [
currentConfiguration.id,
currentConfiguration.templates,
defaultTemplate,
isAttackDiscoveryRuleType,
isLoadingCaseConfiguration,
onTemplateChange,
selectedTemplate,
]);
const reopenClosedCasesComponent = useMemo(() => {
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiCheckbox
@ -274,6 +310,26 @@ export const CasesParamsFieldsComponent: React.FunctionComponent<
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [editSubActionProperty, index, reopenClosedCases]);
if (isAttackDiscoveryRuleType) {
return (
<EuiToolTip content={i18n.ATTACK_DISCOVERY_TEMPLATE_TOOLTIP}>
{templateSelectorComponent}
</EuiToolTip>
);
}
return (
<>
{groupByComponent}
<EuiSpacer size="m" />
{timeWindowComponent}
<EuiSpacer size="m" />
{templateSelectorComponent}
<EuiSpacer size="m" />
{reopenClosedCasesComponent}
</>
);
};

View file

@ -93,3 +93,11 @@ export const TIME_WINDOW_WARNING = i18n.translate(
'Setting a time window of 20 minutes or less may increase the number of cases generated by this action.',
}
);
export const ATTACK_DISCOVERY_TEMPLATE_TOOLTIP = i18n.translate(
'xpack.cases.systemActions.casesConnector.attackDiscoveryTemplateTooltip',
{
defaultMessage:
'Attack Discovery Schedules fully manage Case actions, automatically filling in all fields for new Cases.',
}
);

View file

@ -0,0 +1,71 @@
/*
* 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 { AttackDiscoveryExpandedAlerts } from './types';
export const attackDiscoveryAlerts: AttackDiscoveryExpandedAlerts = [
{
_id: '012479c7-bcb6-4945-a6cf-65e40931a156',
_index: '.internal.alerts-security.attack.discovery.alerts-default-000001',
kibana: {
alert: {
attack_discovery: {
alert_ids: [
'5dd43c0aa62e75fa7613ae9345384fd402fc9b074a82c89c01c3e8075a4b1e5d',
'83047a3ca7e9d852aa48f46fb6329884ed25d5155642c551629402228657ef37',
'854da5fb177c06630de28e87bb9c0baeee3143e1fc37f8755d83075af59a22e0',
],
details_markdown: `- The attack chain spans multiple hosts, including {{ host.name debdbf9b-9e88-442d-8885-7cd6a18fddbc }}.`,
entity_summary_markdown: `Credential access on {{ host.name debdbf9b-9e88-442d-8885-7cd6a18fddbc }}.`,
mitre_attack_tactics: ['Credential Access', 'Lateral Movement', 'Defense Evasion'],
replacements: [
{ uuid: '3bc96e6a-d2ad-411e-8202-f2c6ee892f5b', value: 'g1thqubmti' },
{ uuid: 'debdbf9b-9e88-442d-8885-7cd6a18fddbc', value: 'Host-7y3d5eahjg' },
{ uuid: '686626cb-1a91-45b1-ba46-78cc783c2176', value: 'mimzsybazr' },
],
summary_markdown: `Credential dumping tools like {{ process.name mimikatz.exe }} and {{ process.name lsass.exe }}.`,
title: 'Coordinated credential access across hosts',
},
rule: {
parameters: {
alertsIndexPattern: '.alerts-security.alerts-default',
},
rule_type_id: 'attack-discovery',
},
},
},
},
{
_id: 'ee0f98c7-6a0d-4a87-ad15-4128daf53c84',
_index: '.internal.alerts-security.attack.discovery.alerts-default-000002',
kibana: {
alert: {
attack_discovery: {
alert_ids: [
'2a15777907cd95ec65a97a505c3d522c0342ae4d3bf2aee610e5ab72bdb5825a',
'4a61c1e09acad151735ad557cf45f8c08bea2ac668e346f86af92de35c79a505',
],
details_markdown: `This attack chain spans multiple hosts and platforms, with strong evidence linking the events.`,
entity_summary_markdown: `Malware and suspicious activity on {{ host.name a83cddca-0560-4ca8-bd1e-9f1591ac9253 }}, {{ host.name fa52a0b7-799f-4f4f-948e-9c182dec12b6 }}.`,
mitre_attack_tactics: ['Initial Access', 'Execution'],
replacements: [
{ uuid: '4663c6e2-6812-4867-b2e5-78f9b5b0c18e', value: 'fze61k1ys8' },
{ uuid: '483e2e72-7a56-41aa-9eb0-d760d038e488', value: 'Host-zc6tlnwgdd' },
],
summary_markdown: `Coordinated multi-host attack: malware, credential access, and suspicious binaries on {{ host.name a83cddca-0560-4ca8-bd1e-9f1591ac9253 }}, {{ host.name fa52a0b7-799f-4f4f-948e-9c182dec12b6 }}.`,
title: 'Coordinated multi-host malware campaign',
},
rule: {
parameters: {
alertsIndexPattern: '.alerts-security.alerts-default',
},
rule_type_id: 'attack-discovery',
},
},
},
},
];

View file

@ -0,0 +1,102 @@
/*
* 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 { groupAttackDiscoveryAlerts } from '.';
import { attackDiscoveryAlerts } from './index.mock';
describe('groupAttackDiscoveryAlerts', () => {
const getAttackDiscoveryDocument = () => attackDiscoveryAlerts[0];
it('returns an empty group if there are no alerts', () => {
expect(groupAttackDiscoveryAlerts([])).toEqual([]);
});
it('returns a group for a valid attack discovery alert', () => {
const doc = getAttackDiscoveryDocument();
const groups = groupAttackDiscoveryAlerts([doc]);
expect(groups.length).toEqual(1);
expect(groups[0].alerts).toEqual([
{
_id: '5dd43c0aa62e75fa7613ae9345384fd402fc9b074a82c89c01c3e8075a4b1e5d',
_index: '.alerts-security.alerts-default',
},
{
_id: '83047a3ca7e9d852aa48f46fb6329884ed25d5155642c551629402228657ef37',
_index: '.alerts-security.alerts-default',
},
{
_id: '854da5fb177c06630de28e87bb9c0baeee3143e1fc37f8755d83075af59a22e0',
_index: '.alerts-security.alerts-default',
},
]);
expect(groups[0].grouping).toEqual({
attack_discovery: '012479c7-bcb6-4945-a6cf-65e40931a156',
});
expect(
groups[0].comments?.[0].startsWith('## Coordinated credential access across hosts')
).toBeTruthy();
expect(groups[0].title).toEqual('Coordinated credential access across hosts');
});
it('returns a group for each valid attack discovery alert', () => {
const doc1 = getAttackDiscoveryDocument();
const doc2 = {
...getAttackDiscoveryDocument(),
_id: 'another-id',
kibana: {
...getAttackDiscoveryDocument().kibana,
alert: {
...getAttackDiscoveryDocument().kibana.alert,
attack_discovery: {
...getAttackDiscoveryDocument().kibana.alert.attack_discovery,
title: 'Another attack',
alert_ids: ['id1', 'id2'],
},
},
},
};
const groups = groupAttackDiscoveryAlerts([doc1, doc2]);
expect(groups.length).toEqual(2);
expect(groups[0].alerts).toEqual([
{
_id: '5dd43c0aa62e75fa7613ae9345384fd402fc9b074a82c89c01c3e8075a4b1e5d',
_index: '.alerts-security.alerts-default',
},
{
_id: '83047a3ca7e9d852aa48f46fb6329884ed25d5155642c551629402228657ef37',
_index: '.alerts-security.alerts-default',
},
{
_id: '854da5fb177c06630de28e87bb9c0baeee3143e1fc37f8755d83075af59a22e0',
_index: '.alerts-security.alerts-default',
},
]);
expect(groups[0].grouping).toEqual({
attack_discovery: '012479c7-bcb6-4945-a6cf-65e40931a156',
});
expect(
groups[0].comments?.[0].startsWith('## Coordinated credential access across hosts')
).toBeTruthy();
expect(groups[0].title).toEqual('Coordinated credential access across hosts');
expect(groups[1].alerts).toEqual([
{ _id: 'id1', _index: '.alerts-security.alerts-default' },
{ _id: 'id2', _index: '.alerts-security.alerts-default' },
]);
expect(groups[1].grouping).toEqual({ attack_discovery: 'another-id' });
expect(groups[1].comments?.[0].startsWith('## Another attack')).toBeTruthy();
expect(groups[1].title).toEqual('Another attack');
});
it('throws if input does not comply with `AttackDiscoveryExpandedAlertsSchema`', () => {
const invalidAlerts = [{ _id: '1', _index: 'alerts' }];
expect(() => groupAttackDiscoveryAlerts(invalidAlerts)).toThrow(
'[0.kibana.alert.attack_discovery.alert_ids]: expected value of type [array] but got [undefined]'
);
});
});

View file

@ -0,0 +1,73 @@
/*
* 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 { getAttackDiscoveryMarkdown } from '@kbn/elastic-assistant-common';
import { MAX_DOCS_PER_PAGE, MAX_TITLE_LENGTH } from '../../../../common/constants';
import { AttackDiscoveryExpandedAlertsSchema } from './schema';
import type { CaseAlert, CasesGroupedAlerts } from '../types';
import { MAX_OPEN_CASES } from '../constants';
export const groupAttackDiscoveryAlerts = (alerts: CaseAlert[]): CasesGroupedAlerts[] => {
/**
* First we should validate that the alerts array schema complies with the attack discovery object.
*/
const attackDiscoveryAlerts = AttackDiscoveryExpandedAlertsSchema.validate(
alerts,
{},
undefined,
{ stripUnknownKeys: true }
);
if (attackDiscoveryAlerts.length > MAX_OPEN_CASES) {
throw new Error(
`Circuit breaker: Attack discovery alerts grouping would create more than the maximum number of allowed cases ${MAX_OPEN_CASES}.`
);
}
/**
* For each attack discovery alert we would like to create one separate case.
*/
const groupedAlerts = attackDiscoveryAlerts.map((attackAlert) => {
const alertsIndexPattern = attackAlert.kibana.alert.rule.parameters.alertsIndexPattern;
const attackDiscoveryId = attackAlert._id;
const attackDiscovery = attackAlert.kibana.alert.attack_discovery;
const alertIds = attackDiscovery.alert_ids;
const caseTitle = attackDiscovery.title.slice(0, MAX_TITLE_LENGTH);
const caseComments = [
getAttackDiscoveryMarkdown({
attackDiscovery: {
id: attackDiscoveryId,
alertIds,
detailsMarkdown: attackDiscovery.details_markdown,
entitySummaryMarkdown: attackDiscovery.entity_summary_markdown,
mitreAttackTactics: attackDiscovery.mitre_attack_tactics,
summaryMarkdown: attackDiscovery.summary_markdown,
title: caseTitle,
},
replacements: attackDiscovery.replacements?.reduce((acc: Record<string, string>, r) => {
acc[r.uuid] = r.value;
return acc;
}, {}),
}),
].slice(0, MAX_DOCS_PER_PAGE / 2);
/**
* Each attack discovery alert references a list of SIEM alerts that led to the attack.
* These SIEM alerts will be added to the case.
*/
return {
alerts: alertIds.map((siemAlertId) => ({ _id: siemAlertId, _index: alertsIndexPattern })),
grouping: { attack_discovery: attackDiscoveryId },
comments: caseComments,
title: caseTitle,
};
});
return groupedAlerts;
};

View file

@ -0,0 +1,114 @@
/*
* 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 { AttackDiscoveryExpandedAlertSchema, AttackDiscoveryExpandedAlertsSchema } from './schema';
describe('AttackDiscoveryExpandedAlertSchema', () => {
const getAttackDiscoveryDocument = () => {
return {
_id: '012479c7-bcb6-4945-a6cf-65e40931a156',
_index: '.internal.alerts-security.attack.discovery.alerts-default-000001',
kibana: {
alert: {
attack_discovery: {
alert_ids: [
'5dd43c0aa62e75fa7613ae9345384fd402fc9b074a82c89c01c3e8075a4b1e5d',
'83047a3ca7e9d852aa48f46fb6329884ed25d5155642c551629402228657ef37',
'854da5fb177c06630de28e87bb9c0baeee3143e1fc37f8755d83075af59a22e0',
],
details_markdown: `- The attack chain spans multiple hosts, including {{ host.name debdbf9b-9e88-442d-8885-7cd6a18fddbc }}.`,
entity_summary_markdown: `Credential access on {{ host.name debdbf9b-9e88-442d-8885-7cd6a18fddbc }}.`,
mitre_attack_tactics: ['Credential Access', 'Lateral Movement', 'Defense Evasion'],
replacements: [
{ uuid: '3bc96e6a-d2ad-411e-8202-f2c6ee892f5b', value: 'g1thqubmti' },
{ uuid: 'debdbf9b-9e88-442d-8885-7cd6a18fddbc', value: 'Host-7y3d5eahjg' },
{ uuid: '686626cb-1a91-45b1-ba46-78cc783c2176', value: 'mimzsybazr' },
],
summary_markdown: `Credential dumping tools like {{ process.name mimikatz.exe }} and {{ process.name lsass.exe }}.`,
title: 'Coordinated credential access across hosts',
},
rule: {
parameters: {
alertsIndexPattern: '.alerts-security.alerts-default',
},
rule_type_id: 'attack-discovery',
},
},
},
};
};
it('accepts valid attack discovery alert document and strips unknown keys', () => {
expect(AttackDiscoveryExpandedAlertSchema.validate(getAttackDiscoveryDocument()))
.toMatchInlineSnapshot(`
Object {
"_id": "012479c7-bcb6-4945-a6cf-65e40931a156",
"_index": ".internal.alerts-security.attack.discovery.alerts-default-000001",
"kibana": Object {
"alert": Object {
"attack_discovery": Object {
"alert_ids": Array [
"5dd43c0aa62e75fa7613ae9345384fd402fc9b074a82c89c01c3e8075a4b1e5d",
"83047a3ca7e9d852aa48f46fb6329884ed25d5155642c551629402228657ef37",
"854da5fb177c06630de28e87bb9c0baeee3143e1fc37f8755d83075af59a22e0",
],
"details_markdown": "- The attack chain spans multiple hosts, including {{ host.name debdbf9b-9e88-442d-8885-7cd6a18fddbc }}.",
"entity_summary_markdown": "Credential access on {{ host.name debdbf9b-9e88-442d-8885-7cd6a18fddbc }}.",
"mitre_attack_tactics": Array [
"Credential Access",
"Lateral Movement",
"Defense Evasion",
],
"replacements": Array [
Object {
"uuid": "3bc96e6a-d2ad-411e-8202-f2c6ee892f5b",
"value": "g1thqubmti",
},
Object {
"uuid": "debdbf9b-9e88-442d-8885-7cd6a18fddbc",
"value": "Host-7y3d5eahjg",
},
Object {
"uuid": "686626cb-1a91-45b1-ba46-78cc783c2176",
"value": "mimzsybazr",
},
],
"summary_markdown": "Credential dumping tools like {{ process.name mimikatz.exe }} and {{ process.name lsass.exe }}.",
"title": "Coordinated credential access across hosts",
},
"rule": Object {
"parameters": Object {
"alertsIndexPattern": ".alerts-security.alerts-default",
},
"rule_type_id": "attack-discovery",
},
},
},
}
`);
});
it('throws if attack discovery alert document has unknown fields', () => {
expect(() =>
AttackDiscoveryExpandedAlertSchema.validate({
...getAttackDiscoveryDocument(),
field1: 'hello',
host: { name: 'test1' },
})
).toThrow();
});
it('accepts valid array of attack discovery alert documents', () => {
expect(() =>
AttackDiscoveryExpandedAlertsSchema.validate([
getAttackDiscoveryDocument(),
getAttackDiscoveryDocument(),
getAttackDiscoveryDocument(),
])
).not.toThrow();
});
});

View file

@ -0,0 +1,38 @@
/*
* 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 { schema } from '@kbn/config-schema';
export const AttackDiscoveryExpandedAlertSchema = schema.object({
_id: schema.string(),
_index: schema.string(),
kibana: schema.object({
alert: schema.object({
attack_discovery: schema.object({
alert_ids: schema.arrayOf(schema.string()),
details_markdown: schema.string(),
entity_summary_markdown: schema.maybe(schema.string()),
mitre_attack_tactics: schema.maybe(schema.arrayOf(schema.string())),
replacements: schema.maybe(
schema.arrayOf(schema.object({ value: schema.string(), uuid: schema.string() }))
),
summary_markdown: schema.string(),
title: schema.string(),
}),
rule: schema.object({
parameters: schema.object({
alertsIndexPattern: schema.string(),
}),
rule_type_id: schema.string(),
}),
}),
}),
});
export const AttackDiscoveryExpandedAlertsSchema = schema.arrayOf(
AttackDiscoveryExpandedAlertSchema
);

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TypeOf } from '@kbn/config-schema';
import type { AttackDiscoveryExpandedAlertsSchema } from './schema';
export type AttackDiscoveryExpandedAlerts = TypeOf<typeof AttackDiscoveryExpandedAlertsSchema>;

View file

@ -37,6 +37,8 @@ describe('CasesConnector', () => {
tags: ['rule', 'test'],
ruleUrl: 'https://example.com/rules/rule-test-id',
};
const groupedAlerts = null;
const internallyManagedAlerts = false;
const owner = 'cases';
const timeWindow = '7d';
@ -89,10 +91,12 @@ describe('CasesConnector', () => {
it('creates the CasesConnectorExecutor correctly', async () => {
await connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -110,10 +114,12 @@ describe('CasesConnector', () => {
it('executes the CasesConnectorExecutor correctly', async () => {
await connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -121,10 +127,12 @@ describe('CasesConnector', () => {
expect(mockExecute).toBeCalledWith({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -134,10 +142,12 @@ describe('CasesConnector', () => {
it('creates the cases client correctly', async () => {
await connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -152,10 +162,12 @@ describe('CasesConnector', () => {
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -173,10 +185,12 @@ describe('CasesConnector', () => {
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -194,10 +208,12 @@ describe('CasesConnector', () => {
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -217,10 +233,12 @@ describe('CasesConnector', () => {
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -240,10 +258,12 @@ describe('CasesConnector', () => {
await connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -262,10 +282,12 @@ describe('CasesConnector', () => {
await expect(() =>
connector.run({
alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,
@ -283,10 +305,12 @@ describe('CasesConnector', () => {
it('does not execute with no alerts', async () => {
await connector.run({
alerts: [],
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen,
templateId,

View file

@ -26,11 +26,13 @@ import {
cases,
createdOracleRecord,
groupedAlertsWithOracleKey,
groupedAlerts,
groupingBy,
oracleRecords,
rule,
owner,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
updatedCounterOracleRecord,
alertsNested,
@ -76,10 +78,12 @@ describe('CasesConnectorExecutor', () => {
const params: CasesConnectorRunParams = {
alerts,
groupedAlerts,
groupingBy,
owner,
rule,
timeWindow,
internallyManagedAlerts,
reopenClosedCases,
maximumCasesToOpen: 5,
templateId: null,

View file

@ -16,6 +16,7 @@ import { getFlattenedObject } from '@kbn/std';
import type {
CustomFieldsConfiguration,
TemplatesConfiguration,
UserCommentAttachmentPayload,
} from '../../../common/types/domain';
import {
MAX_ALERTS_PER_CASE,
@ -33,7 +34,12 @@ import {
MAX_CONCURRENT_ES_REQUEST,
MAX_OPEN_CASES,
} from './constants';
import type { BulkCreateOracleRecordRequest, CasesConnectorRunParams, OracleRecord } from './types';
import type {
BulkCreateOracleRecordRequest,
CasesConnectorRunParams,
CasesGroupedAlerts,
OracleRecord,
} from './types';
import type { CasesOracleService } from './cases_oracle_service';
import {
convertValueToString,
@ -60,12 +66,7 @@ interface CasesConnectorExecutorParams {
spaceId: string;
}
interface GroupedAlerts {
alerts: CasesConnectorRunParams['alerts'];
grouping: Record<string, unknown>;
}
type GroupedAlertsWithOracleKey = GroupedAlerts & { oracleKey: string };
type GroupedAlertsWithOracleKey = CasesGroupedAlerts & { oracleKey: string };
type GroupedAlertsWithOracleRecords = GroupedAlertsWithOracleKey & { oracleRecord: OracleRecord };
type GroupedAlertsWithCaseId = GroupedAlertsWithOracleRecords & { caseId: string };
type GroupedAlertsWithCases = GroupedAlertsWithCaseId & { theCase: Case };
@ -92,9 +93,9 @@ export class CasesConnectorExecutor {
}
public async execute(params: CasesConnectorRunParams) {
const { alerts, groupingBy } = params;
const { alerts, groupedAlerts: casesGroupedAlerts, groupingBy } = params;
const groupedAlerts = this.groupAlerts({ params, alerts, groupingBy });
const groupedAlerts = casesGroupedAlerts ?? this.groupAlerts({ params, alerts, groupingBy });
const groupedAlertsWithCircuitBreakers = this.applyCircuitBreakers(params, groupedAlerts);
if (groupedAlertsWithCircuitBreakers.length === 0) {
@ -165,10 +166,10 @@ export class CasesConnectorExecutor {
);
/**
* Now that all cases are fetched or created per grouping, we attach the alerts
* to the corresponding cases.
* Now that all cases are fetched or created per grouping, we attach
* comments (if available) and alerts to the corresponding cases.
*/
await this.attachAlertsToCases(groupedAlertsWithClosedCasesHandled, params);
await this.attachCommentAndAlertsToCases(groupedAlertsWithClosedCasesHandled, params);
}
private groupAlerts({
@ -177,7 +178,7 @@ export class CasesConnectorExecutor {
groupingBy,
}: Pick<CasesConnectorRunParams, 'alerts' | 'groupingBy'> & {
params: CasesConnectorRunParams;
}): GroupedAlerts[] {
}): CasesGroupedAlerts[] {
if (this.logger.isLevelEnabled('debug')) {
this.logger.debug(
`[CasesConnector][CasesConnectorExecutor][groupAlerts] Grouping ${alerts.length} alerts`,
@ -189,7 +190,7 @@ export class CasesConnectorExecutor {
}
const uniqueGroupingByFields = Array.from(new Set<string>(groupingBy));
const groupingMap = new Map<string, GroupedAlerts>();
const groupingMap = new Map<string, CasesGroupedAlerts>();
/**
* We are interested in alerts that have a value for any
@ -249,8 +250,8 @@ export class CasesConnectorExecutor {
private applyCircuitBreakers(
params: CasesConnectorRunParams,
groupedAlerts: GroupedAlerts[]
): GroupedAlerts[] {
groupedAlerts: CasesGroupedAlerts[]
): CasesGroupedAlerts[] {
if (groupedAlerts.length > params.maximumCasesToOpen || groupedAlerts.length > MAX_OPEN_CASES) {
const maxCasesCircuitBreaker = Math.min(params.maximumCasesToOpen, MAX_OPEN_CASES);
@ -265,7 +266,7 @@ export class CasesConnectorExecutor {
return groupedAlerts;
}
private removeGrouping(groupedAlerts: GroupedAlerts[]): GroupedAlerts[] {
private removeGrouping(groupedAlerts: CasesGroupedAlerts[]): CasesGroupedAlerts[] {
const allAlerts = groupedAlerts.map(({ alerts }) => alerts).flat();
return [{ alerts: allAlerts, grouping: {} }];
@ -273,7 +274,7 @@ export class CasesConnectorExecutor {
private generateOracleKeys(
params: CasesConnectorRunParams,
groupedAlerts: GroupedAlerts[]
groupedAlerts: CasesGroupedAlerts[]
): Map<string, GroupedAlertsWithOracleKey> {
if (this.logger.isLevelEnabled('debug')) {
this.logger.debug(
@ -286,7 +287,7 @@ export class CasesConnectorExecutor {
const oracleMap = new Map<string, GroupedAlertsWithOracleKey>();
for (const { grouping, alerts } of groupedAlerts) {
for (const { grouping, alerts, comments, title } of groupedAlerts) {
const getRecordIdParams = {
ruleId: rule.id,
grouping,
@ -306,7 +307,7 @@ export class CasesConnectorExecutor {
);
}
oracleMap.set(oracleKey, { oracleKey, grouping, alerts });
oracleMap.set(oracleKey, { oracleKey, grouping, alerts, comments, title });
}
if (this.logger.isLevelEnabled('debug')) {
@ -616,6 +617,8 @@ export class CasesConnectorExecutor {
caseId,
alerts: entry.alerts,
grouping: entry.grouping,
comments: entry.comments,
title: entry.title,
oracleKey: recordId,
oracleRecord: entry.oracleRecord,
});
@ -756,7 +759,7 @@ export class CasesConnectorExecutor {
customFieldsConfigurations?: CustomFieldsConfiguration,
templatesConfigurations?: TemplatesConfiguration
): Omit<BulkCreateCasesRequest['cases'][number], 'id'> & { id: string } {
const { grouping, caseId, oracleRecord } = groupingData;
const { grouping, caseId, oracleRecord, title } = groupingData;
const flattenGrouping = getFlattenedObject(grouping);
const selectedTemplate = templatesConfigurations?.find(
@ -786,6 +789,7 @@ export class CasesConnectorExecutor {
caseFieldsFromTemplate?.description ?? this.getCaseDescription(params, flattenGrouping),
tags: this.getCaseTags(params, flattenGrouping, caseFieldsFromTemplate?.tags),
title:
title ??
caseFieldsFromTemplate?.title ??
this.getCasesTitle(params, flattenGrouping, oracleRecord.counter),
connector: caseFieldsFromTemplate?.connector ?? {
@ -810,7 +814,7 @@ export class CasesConnectorExecutor {
private getCasesTitle(
params: CasesConnectorRunParams,
grouping: GroupedAlerts['grouping'],
grouping: CasesGroupedAlerts['grouping'],
oracleCounter: number
) {
const totalDots = 3;
@ -860,7 +864,10 @@ export class CasesConnectorExecutor {
return `${ruleNameTrimmedWithDots}${suffix}`;
}
private getCaseDescription(params: CasesConnectorRunParams, grouping: GroupedAlerts['grouping']) {
private getCaseDescription(
params: CasesConnectorRunParams,
grouping: CasesGroupedAlerts['grouping']
) {
const ruleName = params.rule.ruleUrl
? `['${params.rule.name}'](${params.rule.ruleUrl})`
: params.rule.name;
@ -882,7 +889,7 @@ export class CasesConnectorExecutor {
private getCaseTags(
params: CasesConnectorRunParams,
grouping: GroupedAlerts['grouping'],
grouping: CasesGroupedAlerts['grouping'],
templateCaseTags?: string[]
) {
const ruleTags = Array.isArray(params.rule.tags) ? params.rule.tags : [];
@ -898,7 +905,7 @@ export class CasesConnectorExecutor {
.map((tag) => tag.slice(0, MAX_LENGTH_PER_TAG));
}
private getGroupingAsTags(grouping: GroupedAlerts['grouping']): string[] {
private getGroupingAsTags(grouping: CasesGroupedAlerts['grouping']): string[] {
return Object.entries(grouping)
.map(([key, value]) => [key, `${key}:${convertValueToString(value)}`])
.flat();
@ -1096,7 +1103,7 @@ export class CasesConnectorExecutor {
return casesMapWithNewCases;
}
private async attachAlertsToCases(
private async attachCommentAndAlertsToCases(
groupedAlertsWithCases: Map<string, GroupedAlertsWithCases>,
params: CasesConnectorRunParams
): Promise<void> {
@ -1105,7 +1112,7 @@ export class CasesConnectorExecutor {
this.getLogMetadata(params, { tags: ['case-connector:attachAlertsToCases'] })
);
const { rule } = params;
const { internallyManagedAlerts, rule } = params;
const [casesUnderAlertLimit, casesOverAlertLimit] = partition(
Array.from(groupedAlertsWithCases.values()),
@ -1135,23 +1142,34 @@ export class CasesConnectorExecutor {
);
const bulkCreateAlertsRequest: BulkCreateAlertsReq[] = casesUnderAlertLimit.map(
({ theCase, alerts }) => ({
caseId: theCase.id,
attachments: [
{
type: AttachmentType.alert,
rule: { id: rule.id, name: rule.name },
/**
* Map traverses the array in ascending order.
* The order is guaranteed to be the same for
* both calls by the ECMA-262 spec.
*/
alertId: alerts.map((alert) => alert._id),
index: alerts.map((alert) => alert._index),
({ theCase, alerts, comments }) => {
const extraComments: UserCommentAttachmentPayload[] =
comments?.map((comment) => ({
type: AttachmentType.user,
comment,
owner: theCase.owner,
},
],
})
})) ?? [];
return {
caseId: theCase.id,
attachments: [
...extraComments,
{
type: AttachmentType.alert,
rule: internallyManagedAlerts
? { id: null, name: null }
: { id: rule.id, name: rule.name },
/**
* Map traverses the array in ascending order.
* The order is guaranteed to be the same for
* both calls by the ECMA-262 spec.
*/
alertId: alerts.map((alert) => alert._id),
index: alerts.map((alert) => alert._index),
owner: theCase.owner,
},
],
};
}
);
await pMap(

View file

@ -87,6 +87,9 @@ export const alertsWithNoGrouping = [
{ _id: 'alert-id-5', _index: 'alert-index-5' },
];
export const groupedAlerts = null;
export const internallyManagedAlerts = false;
export const groupingBy = ['host.name', 'dest.ip'];
export const rule = {
id: 'rule-test-id',

View file

@ -9,8 +9,13 @@ import type { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_acti
import type { CasesConnectorConfig, CasesConnectorSecrets } from './types';
import { getCasesConnectorAdapter, getCasesConnectorType } from '.';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import type { Logger } from '@kbn/core/server';
import { ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID } from '@kbn/elastic-assistant-common';
import { attackDiscoveryAlerts } from './attack_discovery/index.mock';
describe('getCasesConnectorType', () => {
const mockLogger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
let caseConnectorType: SubActionConnectorType<CasesConnectorConfig, CasesConnectorSecrets>;
beforeEach(() => {
@ -68,6 +73,7 @@ describe('getCasesConnectorType', () => {
tags: ['my-tag'],
consumer: 'test-consumer',
producer: 'test-producer',
ruleTypeId: 'test-rule-1',
};
const getParams = (overrides = {}) => ({
@ -82,26 +88,26 @@ describe('getCasesConnectorType', () => {
});
it('sets the correct connectorTypeId', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(adapter.connectorTypeId).toEqual('.cases');
});
describe('ruleActionParamsSchema', () => {
it('validates getParams() correctly', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(adapter.ruleActionParamsSchema.validate(getParams())).toEqual(getParams());
});
it('throws if missing getParams()', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(() => adapter.ruleActionParamsSchema.validate({})).toThrow();
});
it('does not accept more than one groupingBy key', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(() =>
adapter.ruleActionParamsSchema.validate(
@ -111,7 +117,7 @@ describe('getCasesConnectorType', () => {
});
it('should fail with not valid time window', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(() =>
adapter.ruleActionParamsSchema.validate(getParams({ timeWindow: '10d+3d' }))
@ -121,7 +127,7 @@ describe('getCasesConnectorType', () => {
describe('buildActionParams', () => {
it('builds the action getParams() correctly', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(
adapter.buildActionParams({
@ -146,7 +152,9 @@ describe('getCasesConnectorType', () => {
"_index": "alert-index-2",
},
],
"groupedAlerts": null,
"groupingBy": Array [],
"internallyManagedAlerts": false,
"maximumCasesToOpen": 5,
"owner": "cases",
"reopenClosedCases": false,
@ -166,7 +174,7 @@ describe('getCasesConnectorType', () => {
});
it('builds the action getParams() and templateId correctly', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(
adapter.buildActionParams({
@ -191,7 +199,9 @@ describe('getCasesConnectorType', () => {
"_index": "alert-index-2",
},
],
"groupedAlerts": null,
"groupingBy": Array [],
"internallyManagedAlerts": false,
"maximumCasesToOpen": 5,
"owner": "cases",
"reopenClosedCases": false,
@ -211,7 +221,7 @@ describe('getCasesConnectorType', () => {
});
it('builds the action getParams() correctly without ruleUrl', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(
adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
@ -234,7 +244,9 @@ describe('getCasesConnectorType', () => {
"_index": "alert-index-2",
},
],
"groupedAlerts": null,
"groupingBy": Array [],
"internallyManagedAlerts": false,
"maximumCasesToOpen": 5,
"owner": "cases",
"reopenClosedCases": false,
@ -254,7 +266,7 @@ describe('getCasesConnectorType', () => {
});
it('maps observability consumers to the correct owner', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
for (const consumer of [
AlertConsumers.OBSERVABILITY,
@ -278,7 +290,7 @@ describe('getCasesConnectorType', () => {
});
it('maps security solution consumers to the correct owner', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
for (const consumer of [AlertConsumers.SIEM]) {
const connectorParams = adapter.buildActionParams({
@ -294,7 +306,7 @@ describe('getCasesConnectorType', () => {
});
it('maps stack consumers to the correct owner', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
for (const consumer of [AlertConsumers.ML, AlertConsumers.STACK_ALERTS]) {
const connectorParams = adapter.buildActionParams({
@ -310,7 +322,7 @@ describe('getCasesConnectorType', () => {
});
it('fallback to the cases owner if the consumer is not in the mapping', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
@ -324,7 +336,10 @@ describe('getCasesConnectorType', () => {
});
it('correctly fallsback to security owner if the project is serverless security', () => {
const adapter = getCasesConnectorAdapter({ isServerlessSecurity: true });
const adapter = getCasesConnectorAdapter({
isServerlessSecurity: true,
logger: mockLogger,
});
for (const consumer of [AlertConsumers.ML, AlertConsumers.STACK_ALERTS]) {
const connectorParams = adapter.buildActionParams({
@ -338,11 +353,169 @@ describe('getCasesConnectorType', () => {
expect(connectorParams.subActionParams.owner).toBe('securitySolution');
}
});
it('correctly returns `internallyManagedAlerts` as `false` if rule type is not attack discovery', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
for (const consumer of [AlertConsumers.SIEM]) {
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule: { ...rule, consumer },
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.internallyManagedAlerts).toBe(false);
}
});
it('correctly returns `groupedAlerts` as `null` if rule type is not attack discovery', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
for (const consumer of [AlertConsumers.SIEM]) {
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule: { ...rule, consumer },
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.groupedAlerts).toBeNull();
}
});
it('correctly returns `groupedAlerts` as `null` in case there are no alerts', () => {
const noAlerts = {
all: { data: [], count: 0 },
new: { data: [], count: 0 },
ongoing: { data: [], count: 0 },
recovered: { data: [], count: 0 },
};
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
for (const consumer of [AlertConsumers.SIEM]) {
const connectorParams = adapter.buildActionParams({
alerts: noAlerts,
rule: { ...rule, consumer },
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.groupedAlerts).toBeNull();
}
});
});
describe('Attack discovery rule type', () => {
const attackDiscoveryRule = {
id: 'rule-id',
name: 'my rule name',
tags: ['my-tag'],
consumer: AlertConsumers.SIEM,
producer: 'test-producer',
ruleTypeId: ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID,
};
const alertsMock = {
all: { data: attackDiscoveryAlerts, count: attackDiscoveryAlerts.length },
new: { data: attackDiscoveryAlerts, count: attackDiscoveryAlerts.length },
ongoing: { data: [], count: 0 },
recovered: { data: [], count: 0 },
};
it('correctly groups attack discovery alerts', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts: alertsMock,
rule: attackDiscoveryRule,
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.groupedAlerts).toEqual([
{
alerts: [
{
_id: '5dd43c0aa62e75fa7613ae9345384fd402fc9b074a82c89c01c3e8075a4b1e5d',
_index: '.alerts-security.alerts-default',
},
{
_id: '83047a3ca7e9d852aa48f46fb6329884ed25d5155642c551629402228657ef37',
_index: '.alerts-security.alerts-default',
},
{
_id: '854da5fb177c06630de28e87bb9c0baeee3143e1fc37f8755d83075af59a22e0',
_index: '.alerts-security.alerts-default',
},
],
comments: expect.anything(),
grouping: { attack_discovery: '012479c7-bcb6-4945-a6cf-65e40931a156' },
title: 'Coordinated credential access across hosts',
},
{
alerts: [
{
_id: '2a15777907cd95ec65a97a505c3d522c0342ae4d3bf2aee610e5ab72bdb5825a',
_index: '.alerts-security.alerts-default',
},
{
_id: '4a61c1e09acad151735ad557cf45f8c08bea2ac668e346f86af92de35c79a505',
_index: '.alerts-security.alerts-default',
},
],
comments: expect.anything(),
grouping: { attack_discovery: 'ee0f98c7-6a0d-4a87-ad15-4128daf53c84' },
title: 'Coordinated multi-host malware campaign',
},
]);
expect(connectorParams.subActionParams.internallyManagedAlerts).toBe(true);
});
it('correctly returns `groupedAlerts` as empty array in case there are no alerts', () => {
const noAlerts = {
all: { data: [], count: 0 },
new: { data: [], count: 0 },
ongoing: { data: [], count: 0 },
recovered: { data: [], count: 0 },
};
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
const connectorParams = adapter.buildActionParams({
alerts: noAlerts,
rule: attackDiscoveryRule,
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.groupedAlerts).toEqual([]);
expect(connectorParams.subActionParams.internallyManagedAlerts).toBe(true);
});
it('correctly fallsback to general flow if alerts schema does not pass validation', () => {
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
const connectorParams = adapter.buildActionParams({
// @ts-expect-error: not all fields are needed
alerts,
rule: attackDiscoveryRule,
params: getParams(),
spaceId: 'default',
});
expect(connectorParams.subActionParams.groupedAlerts).toBeNull();
expect(connectorParams.subActionParams.internallyManagedAlerts).toBe(false);
expect(mockLogger.error).toBeCalledWith(
'Could not setup grouped Attack Discovery alerts, because of error: Error: [0.kibana.alert.attack_discovery.alert_ids]: expected value of type [array] but got [undefined]'
);
});
});
describe('getKibanaPrivileges', () => {
it('constructs the correct privileges from the consumer', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(
adapter.getKibanaPrivileges?.({
@ -364,7 +537,7 @@ describe('getCasesConnectorType', () => {
});
it('constructs the correct privileges from the producer if the consumer is not found', () => {
const adapter = getCasesConnectorAdapter({});
const adapter = getCasesConnectorAdapter({ logger: mockLogger });
expect(
adapter.getKibanaPrivileges?.({
@ -386,7 +559,10 @@ describe('getCasesConnectorType', () => {
});
it('correctly overrides the consumer and producer if the project is serverless security', () => {
const adapter = getCasesConnectorAdapter({ isServerlessSecurity: true });
const adapter = getCasesConnectorAdapter({
isServerlessSecurity: true,
logger: mockLogger,
});
expect(
adapter.getKibanaPrivileges?.({

View file

@ -12,8 +12,9 @@ import {
} from '@kbn/actions-plugin/common';
import type { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { SavedObjectsClientContract } from '@kbn/core/server';
import type { Logger, SavedObjectsClientContract } from '@kbn/core/server';
import type { ConnectorAdapter } from '@kbn/alerting-plugin/server';
import { ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID } from '@kbn/elastic-assistant-common';
import { CasesConnector } from './cases_connector';
import { DEFAULT_MAX_OPEN_CASES } from './constants';
import {
@ -28,6 +29,7 @@ import type {
CasesConnectorParams,
CasesConnectorRuleActionParams,
CasesConnectorSecrets,
CasesGroupedAlerts,
} from './types';
import {
CasesConnectorConfigSchema,
@ -36,6 +38,7 @@ import {
} from './schema';
import type { CasesClient } from '../../client';
import { constructRequiredKibanaPrivileges } from './utils';
import { groupAttackDiscoveryAlerts } from './attack_discovery';
interface GetCasesConnectorTypeArgs {
getCasesClient: (request: KibanaRequest) => Promise<CasesClient>;
@ -89,8 +92,10 @@ export const getCasesConnectorType = ({
export const getCasesConnectorAdapter = ({
isServerlessSecurity,
logger,
}: {
isServerlessSecurity?: boolean;
logger: Logger;
}): ConnectorAdapter<CasesConnectorRuleActionParams, CasesConnectorParams> => {
return {
connectorTypeId: CASES_CONNECTOR_ID,
@ -98,6 +103,23 @@ export const getCasesConnectorAdapter = ({
buildActionParams: ({ alerts, rule, params, ruleUrl }) => {
const caseAlerts = [...alerts.new.data, ...alerts.ongoing.data];
/**
* We handle attack discovery alerts differently than other alerts and group
* their building block SIEM alerts that led to each attack separately.
*/
let internallyManagedAlerts = false;
let groupedAlerts: CasesGroupedAlerts[] | null = null;
if (rule.ruleTypeId === ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID) {
try {
groupedAlerts = groupAttackDiscoveryAlerts(caseAlerts);
internallyManagedAlerts = true;
} catch (error) {
logger.error(
`Could not setup grouped Attack Discovery alerts, because of error: ${error}`
);
}
}
const owner = getOwnerFromRuleConsumerProducer({
consumer: rule.consumer,
producer: rule.producer,
@ -108,11 +130,13 @@ export const getCasesConnectorAdapter = ({
alerts: caseAlerts,
rule: { id: rule.id, name: rule.name, tags: rule.tags, ruleUrl: ruleUrl ?? null },
groupingBy: params.subActionParams.groupingBy,
groupedAlerts,
owner,
reopenClosedCases: params.subActionParams.reopenClosedCases,
timeWindow: params.subActionParams.timeWindow,
maximumCasesToOpen: DEFAULT_MAX_OPEN_CASES,
templateId: params.subActionParams.templateId,
internallyManagedAlerts,
};
return { subAction: 'run', subActionParams };

View file

@ -10,9 +10,11 @@ import { CasesConnectorRunParamsSchema } from './schema';
describe('CasesConnectorRunParamsSchema', () => {
const getParams = (overrides = {}) => ({
alerts: [{ _id: 'alert-id', _index: 'alert-index' }],
groupedAlerts: null,
groupingBy: ['host.name'],
rule: { id: 'rule-id', name: 'Test rule', tags: [], ruleUrl: 'https://example.com' },
owner: 'cases',
internallyManagedAlerts: false,
...overrides,
});
@ -25,9 +27,11 @@ describe('CasesConnectorRunParamsSchema', () => {
"_index": "alert-index",
},
],
"groupedAlerts": null,
"groupingBy": Array [
"host.name",
],
"internallyManagedAlerts": false,
"maximumCasesToOpen": 5,
"owner": "cases",
"reopenClosedCases": false,

View file

@ -8,7 +8,12 @@
import { schema } from '@kbn/config-schema';
import dateMath from '@kbn/datemath';
import { MAX_OPEN_CASES, DEFAULT_MAX_OPEN_CASES } from './constants';
import { CASES_CONNECTOR_TIME_WINDOW_REGEX } from '../../../common/constants';
import {
CASES_CONNECTOR_TIME_WINDOW_REGEX,
MAX_ALERTS_PER_CASE,
MAX_DOCS_PER_PAGE,
MAX_TITLE_LENGTH,
} from '../../../common/constants';
const AlertSchema = schema.recordOf(schema.string(), schema.any(), {
validate: (value) => {
@ -65,6 +70,13 @@ const TimeWindowSchema = schema.string({
},
});
export const CasesGroupedAlertsSchema = schema.object({
alerts: schema.arrayOf(AlertSchema, { maxSize: MAX_ALERTS_PER_CASE }),
comments: schema.maybe(schema.arrayOf(schema.string(), { maxSize: MAX_DOCS_PER_PAGE / 2 })),
grouping: schema.recordOf(schema.string(), schema.any()),
title: schema.maybe(schema.string({ maxLength: MAX_TITLE_LENGTH })),
});
/**
* The case connector does not have any configuration
* or secrets.
@ -74,6 +86,13 @@ export const CasesConnectorSecretsSchema = schema.object({});
export const CasesConnectorRunParamsSchema = schema.object({
alerts: schema.arrayOf(AlertSchema),
groupedAlerts: schema.nullable(
schema.arrayOf(CasesGroupedAlertsSchema, {
defaultValue: [],
minSize: 0,
maxSize: MAX_OPEN_CASES,
})
),
groupingBy: GroupingSchema,
owner: schema.string(),
rule: RuleSchema,
@ -85,6 +104,7 @@ export const CasesConnectorRunParamsSchema = schema.object({
max: MAX_OPEN_CASES,
}),
templateId: schema.nullable(schema.string()),
internallyManagedAlerts: schema.nullable(schema.boolean({ defaultValue: false })),
});
export const CasesConnectorRuleActionParamsSchema = schema.object({

View file

@ -15,14 +15,20 @@ import type {
CasesConnectorRunParamsSchema,
CasesConnectorRuleActionParamsSchema,
CasesConnectorParamsSchema,
CasesGroupedAlertsSchema,
} from './schema';
export interface CaseAlert {
_id: string;
_index: string;
[x: string]: unknown;
}
export type CasesConnectorConfig = TypeOf<typeof CasesConnectorConfigSchema>;
export type CasesConnectorSecrets = TypeOf<typeof CasesConnectorSecretsSchema>;
export type CasesConnectorRunParams = Omit<
TypeOf<typeof CasesConnectorRunParamsSchema>,
'alerts'
> & { alerts: Array<{ _id: string; _index: string; [x: string]: unknown }> };
> & { alerts: CaseAlert[] };
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
@ -81,3 +87,4 @@ export type BulkUpdateOracleRecordRequest = Array<{
export type CasesConnectorRuleActionParams = TypeOf<typeof CasesConnectorRuleActionParamsSchema>;
export type CasesConnectorParams = TypeOf<typeof CasesConnectorParamsSchema>;
export type CasesGroupedAlerts = TypeOf<typeof CasesGroupedAlertsSchema>;

View file

@ -7,7 +7,7 @@
import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
import type { KibanaRequest } from '@kbn/core-http-server';
import type { CoreSetup, SavedObjectsClientContract } from '@kbn/core/server';
import type { CoreSetup, Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { SECURITY_EXTENSION_ID } from '@kbn/core/server';
import type { AlertingServerSetup } from '@kbn/alerting-plugin/server';
import type { CasesClient } from '../client';
@ -20,6 +20,7 @@ export function registerConnectorTypes({
alerting,
actions,
core,
logger,
getCasesClient,
getSpaceId,
isServerlessSecurity,
@ -27,6 +28,7 @@ export function registerConnectorTypes({
actions: ActionsPluginSetupContract;
alerting: AlertingServerSetup;
core: CoreSetup;
logger: Logger;
getCasesClient: (request: KibanaRequest) => Promise<CasesClient>;
getSpaceId: (request?: KibanaRequest) => string;
isServerlessSecurity?: boolean;
@ -63,5 +65,5 @@ export function registerConnectorTypes({
})
);
alerting.registerConnectorAdapter(getCasesConnectorAdapter({ isServerlessSecurity }));
alerting.registerConnectorAdapter(getCasesConnectorAdapter({ isServerlessSecurity, logger }));
}

View file

@ -181,6 +181,7 @@ export class CasePlugin
actions: plugins.actions,
alerting: plugins.alerting,
core,
logger: this.logger,
getCasesClient,
getSpaceId,
isServerlessSecurity,

View file

@ -89,6 +89,7 @@
"@kbn/logging",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/core-test-helpers-model-versions",
"@kbn/elastic-assistant-common",
"@kbn/test",
"@kbn/dev-utils",
"@kbn/tooling-log"

View file

@ -81,7 +81,7 @@ describe('observabilityAIAssistant rule_connector', () => {
const adapter = getObsAIAssistantConnectorAdapter();
const params = adapter.buildActionParams({
params: { connector: '.azure', message: 'hello' },
rule: { id: 'foo', name: 'bar', tags: [], consumer: '', producer: '' },
rule: { id: 'foo', name: 'bar', tags: [], consumer: '', producer: '', ruleTypeId: 'baz' },
ruleUrl: 'http://myrule.com',
spaceId: 'default',
alerts: {
@ -146,7 +146,7 @@ describe('observabilityAIAssistant rule_connector', () => {
}) => {
return adapter.buildActionParams({
params,
rule: { id: 'foo', name: 'bar', tags: [], consumer: '', producer: '' },
rule: { id: 'foo', name: 'bar', tags: [], consumer: '', producer: '', ruleTypeId: 'baz' },
spaceId: 'default',
alerts: {
all: { count: 1, data: [] },

View file

@ -7,15 +7,15 @@
import { renderHook } from '@testing-library/react';
import { useAssistantOverlay } from '@kbn/elastic-assistant';
import { getAttackDiscoveryMarkdown } from '@kbn/elastic-assistant-common';
import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability';
import { getAttackDiscoveryMarkdown } from '../get_attack_discovery_markdown/get_attack_discovery_markdown';
import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery';
import { useViewInAiAssistant } from './use_view_in_ai_assistant';
jest.mock('@kbn/elastic-assistant');
jest.mock('@kbn/elastic-assistant-common');
jest.mock('../../../../../assistant/use_assistant_availability');
jest.mock('../get_attack_discovery_markdown/get_attack_discovery_markdown');
const mockUseAssistantOverlay = useAssistantOverlay as jest.Mock;
describe('useViewInAiAssistant', () => {
beforeEach(() => {

View file

@ -7,9 +7,12 @@
import { useMemo, useCallback } from 'react';
import { useAssistantOverlay } from '@kbn/elastic-assistant';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import {
getAttackDiscoveryMarkdown,
type AttackDiscovery,
type Replacements,
} from '@kbn/elastic-assistant-common';
import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability';
import { getAttackDiscoveryMarkdown } from '../get_attack_discovery_markdown/get_attack_discovery_markdown';
/**
* This category is provided in the prompt context for the assistant

View file

@ -5,10 +5,11 @@
* 2.0.
*/
import type {
AttackDiscovery,
AttackDiscoveryAlert,
Replacements,
import {
getAttackDiscoveryMarkdown,
type AttackDiscovery,
type AttackDiscoveryAlert,
type Replacements,
} from '@kbn/elastic-assistant-common';
import {
EuiButtonEmpty,
@ -19,7 +20,6 @@ import {
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { getAttackDiscoveryMarkdown } from '../attack_discovery_panel/get_attack_discovery_markdown/get_attack_discovery_markdown';
import { useAddToNewCase } from './use_add_to_case';
import { useAddToExistingCase } from './use_add_to_existing_case';
import { useViewInAiAssistant } from '../attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant';

View file

@ -1295,6 +1295,7 @@ const getRequest = (params: Partial<CasesConnectorRunParams> = {}) => {
subAction: 'run',
subActionParams: {
alerts,
groupedAlerts: null,
groupingBy: [],
rule,
owner,
@ -1302,6 +1303,7 @@ const getRequest = (params: Partial<CasesConnectorRunParams> = {}) => {
reopenClosedCases,
maximumCasesToOpen: 5,
templateId: null,
internallyManagedAlerts: false,
...params,
},
};