diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts similarity index 96% rename from x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts rename to x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts index c8b52779d7b42..975896f381443 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.test.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getOpenAndAcknowledgedAlertsQuery } from './get_open_and_acknowledged_alerts_query'; +import { getOpenAndAcknowledgedAlertsQuery } from '.'; describe('getOpenAndAcknowledgedAlertsQuery', () => { it('returns the expected query', () => { diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts similarity index 87% rename from x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts rename to x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts index 4090e71baa371..6f6e196053ca6 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts @@ -5,8 +5,13 @@ * 2.0. */ -import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { AnonymizationFieldResponse } from '../../schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +/** + * This query returns open and acknowledged (non-building block) alerts in the last 24 hours. + * + * The alerts are ordered by risk score, and then from the most recent to the oldest. + */ export const getOpenAndAcknowledgedAlertsQuery = ({ alertsIndexPattern, anonymizationFields, diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts new file mode 100644 index 0000000000000..899b156d21767 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { getRawDataOrDefault } from '.'; + +describe('getRawDataOrDefault', () => { + it('returns the raw data when it is valid', () => { + const rawData = { + field1: [1, 2, 3], + field2: ['a', 'b', 'c'], + }; + + expect(getRawDataOrDefault(rawData)).toEqual(rawData); + }); + + it('returns an empty object when the raw data is invalid', () => { + const rawData = { + field1: [1, 2, 3], + field2: 'invalid', + }; + + expect(getRawDataOrDefault(rawData)).toEqual({}); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts new file mode 100644 index 0000000000000..edbe320c95305 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/get_raw_data_or_default/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { isRawDataValid } from '../is_raw_data_valid'; +import type { MaybeRawData } from '../types'; + +/** Returns the raw data if it valid, or a default if it's not */ +export const getRawDataOrDefault = (rawData: MaybeRawData): Record => + isRawDataValid(rawData) ? rawData : {}; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts new file mode 100644 index 0000000000000..cc205250e84db --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { isRawDataValid } from '.'; + +describe('isRawDataValid', () => { + it('returns true for valid raw data', () => { + const rawData = { + field1: [1, 2, 3], // the Fields API may return a number array + field2: ['a', 'b', 'c'], // the Fields API may return a string array + }; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns true when a field array is empty', () => { + const rawData = { + field1: [1, 2, 3], // the Fields API may return a number array + field2: ['a', 'b', 'c'], // the Fields API may return a string array + field3: [], // the Fields API may return an empty array + }; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns false when a field does not have an array of values', () => { + const rawData = { + field1: [1, 2, 3], + field2: 'invalid', + }; + + expect(isRawDataValid(rawData)).toBe(false); + }); + + it('returns true for empty raw data', () => { + const rawData = {}; + + expect(isRawDataValid(rawData)).toBe(true); + }); + + it('returns false when raw data is an unexpected type', () => { + const rawData = 1234; + + // @ts-expect-error + expect(isRawDataValid(rawData)).toBe(false); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts new file mode 100644 index 0000000000000..1a9623b15ea98 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/is_raw_data_valid/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { MaybeRawData } from '../types'; + +export const isRawDataValid = (rawData: MaybeRawData): rawData is Record => + typeof rawData === 'object' && Object.keys(rawData).every((x) => Array.isArray(rawData[x])); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts new file mode 100644 index 0000000000000..b118a5c94b26e --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { sizeIsOutOfRange } from '.'; +import { MAX_SIZE, MIN_SIZE } from '../types'; + +describe('sizeIsOutOfRange', () => { + it('returns true when size is undefined', () => { + const size = undefined; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns true when size is less than MIN_SIZE', () => { + const size = MIN_SIZE - 1; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns true when size is greater than MAX_SIZE', () => { + const size = MAX_SIZE + 1; + + expect(sizeIsOutOfRange(size)).toBe(true); + }); + + it('returns false when size is exactly MIN_SIZE', () => { + const size = MIN_SIZE; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); + + it('returns false when size is exactly MAX_SIZE', () => { + const size = MAX_SIZE; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); + + it('returns false when size is within the valid range', () => { + const size = MIN_SIZE + 1; + + expect(sizeIsOutOfRange(size)).toBe(false); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts new file mode 100644 index 0000000000000..b2a93b79cbb42 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/size_is_out_of_range/index.ts @@ -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 { MAX_SIZE, MIN_SIZE } from '../types'; + +/** Return true if the provided size is out of range */ +export const sizeIsOutOfRange = (size?: number): boolean => + size == null || size < MIN_SIZE || size > MAX_SIZE; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts new file mode 100644 index 0000000000000..5c81c99ce5732 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/alerts/helpers/types.ts @@ -0,0 +1,14 @@ +/* + * 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 { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; + +export const MIN_SIZE = 10; +export const MAX_SIZE = 10000; + +/** currently the same shape as "fields" property in the ES response */ +export type MaybeRawData = SearchResponse['fields'] | undefined; diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts index 9599e8596e553..8ade6084fd7de 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.gen.ts @@ -39,7 +39,7 @@ export const AttackDiscovery = z.object({ /** * A short (no more than a sentence) summary of the attack discovery featuring only the host.name and user.name fields (when they are applicable), using the same syntax */ - entitySummaryMarkdown: z.string(), + entitySummaryMarkdown: z.string().optional(), /** * An array of MITRE ATT&CK tactic for the attack discovery */ @@ -55,7 +55,7 @@ export const AttackDiscovery = z.object({ /** * The time the attack discovery was generated */ - timestamp: NonEmptyString, + timestamp: NonEmptyString.optional(), }); /** diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml index dcb72147f9408..3adf2f7836804 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/attack_discovery/common_attributes.schema.yaml @@ -12,9 +12,7 @@ components: required: - 'alertIds' - 'detailsMarkdown' - - 'entitySummaryMarkdown' - 'summaryMarkdown' - - 'timestamp' - 'title' properties: alertIds: diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts index b6d51b9bea3fc..a0cbc22282c7b 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.gen.ts @@ -22,10 +22,12 @@ export type PostEvaluateBody = z.infer; export const PostEvaluateBody = z.object({ graphs: z.array(z.string()), datasetName: z.string(), + evaluatorConnectorId: z.string().optional(), connectorIds: z.array(z.string()), runName: z.string().optional(), alertsIndexPattern: z.string().optional().default('.alerts-security.alerts-default'), langSmithApiKey: z.string().optional(), + langSmithProject: z.string().optional(), replacements: Replacements.optional().default({}), size: z.number().optional().default(20), }); diff --git a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml index d0bec37344165..071d80156890b 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml +++ b/x-pack/packages/kbn-elastic-assistant-common/impl/schemas/evaluation/post_evaluate_route.schema.yaml @@ -61,6 +61,8 @@ components: type: string datasetName: type: string + evaluatorConnectorId: + type: string connectorIds: type: array items: @@ -72,6 +74,8 @@ components: default: ".alerts-security.alerts-default" langSmithApiKey: type: string + langSmithProject: + type: string replacements: $ref: "../conversations/common_attributes.schema.yaml#/components/schemas/Replacements" default: {} diff --git a/x-pack/packages/kbn-elastic-assistant-common/index.ts b/x-pack/packages/kbn-elastic-assistant-common/index.ts index d8b4858d3ba8b..41ed86dacd9db 100644 --- a/x-pack/packages/kbn-elastic-assistant-common/index.ts +++ b/x-pack/packages/kbn-elastic-assistant-common/index.ts @@ -25,3 +25,19 @@ export { export { transformRawData } from './impl/data_anonymization/transform_raw_data'; export { parseBedrockBuffer, handleBedrockChunk } from './impl/utils/bedrock'; export * from './constants'; + +/** currently the same shape as "fields" property in the ES response */ +export { type MaybeRawData } from './impl/alerts/helpers/types'; + +/** + * This query returns open and acknowledged (non-building block) alerts in the last 24 hours. + * + * The alerts are ordered by risk score, and then from the most recent to the oldest. + */ +export { getOpenAndAcknowledgedAlertsQuery } from './impl/alerts/get_open_and_acknowledged_alerts_query'; + +/** Returns the raw data if it valid, or a default if it's not */ +export { getRawDataOrDefault } from './impl/alerts/helpers/get_raw_data_or_default'; + +/** Return true if the provided size is out of range */ +export { sizeIsOutOfRange } from './impl/alerts/helpers/size_is_out_of_range'; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx index 60078178a1771..3b48c8d0861c5 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings.tsx @@ -16,7 +16,7 @@ import * as i18n from '../../../knowledge_base/translations'; export const MIN_LATEST_ALERTS = 10; export const MAX_LATEST_ALERTS = 100; export const TICK_INTERVAL = 10; -export const RANGE_CONTAINER_WIDTH = 300; // px +export const RANGE_CONTAINER_WIDTH = 600; // px const LABEL_WRAPPER_MIN_WIDTH = 95; // px interface Props { @@ -52,6 +52,7 @@ const AlertsSettingsComponent = ({ knowledgeBase, setUpdatedKnowledgeBaseSetting diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx index 1a6f826bd415f..7a3998879078d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/alerts_settings/alerts_settings_management.tsx @@ -40,6 +40,7 @@ export const AlertsSettingsManagement: React.FC = React.memo( knowledgeBase={knowledgeBase} setUpdatedKnowledgeBaseSettings={setUpdatedKnowledgeBaseSettings} compressed={false} + value={knowledgeBase.latestAlerts} /> ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx index cefc008eba992..ffbcad48d1cac 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/evaluation_settings.tsx @@ -17,28 +17,34 @@ import { EuiComboBox, EuiButton, EuiComboBoxOptionOption, + EuiComboBoxSingleSelectionShape, EuiTextColor, EuiFieldText, + EuiFieldNumber, EuiFlexItem, EuiFlexGroup, EuiLink, EuiPanel, } from '@elastic/eui'; - import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { GetEvaluateResponse, PostEvaluateRequestBodyInput, } from '@kbn/elastic-assistant-common'; +import { isEmpty } from 'lodash/fp'; + import * as i18n from './translations'; import { useAssistantContext } from '../../../assistant_context'; +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '../../../assistant_context/constants'; import { useLoadConnectors } from '../../../connectorland/use_load_connectors'; import { getActionTypeTitle, getGenAiConfig } from '../../../connectorland/helpers'; import { PRECONFIGURED_CONNECTOR } from '../../../connectorland/translations'; import { usePerformEvaluation } from '../../api/evaluate/use_perform_evaluation'; import { useEvaluationData } from '../../api/evaluate/use_evaluation_data'; +const AS_PLAIN_TEXT: EuiComboBoxSingleSelectionShape = { asPlainText: true }; + /** * Evaluation Settings -- development-only feature for evaluating models */ @@ -121,6 +127,18 @@ export const EvaluationSettings: React.FC = React.memo(() => { }, [setSelectedModelOptions] ); + + const [selectedEvaluatorModel, setSelectedEvaluatorModel] = useState< + Array> + >([]); + + const onSelectedEvaluatorModelChange = useCallback( + (selected: Array>) => setSelectedEvaluatorModel(selected), + [] + ); + + const [size, setSize] = useState(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`); + const visColorsBehindText = euiPaletteComplementary(connectors?.length ?? 0); const modelOptions = useMemo(() => { return ( @@ -170,19 +188,40 @@ export const EvaluationSettings: React.FC = React.memo(() => { // Perform Evaluation Button const handlePerformEvaluation = useCallback(async () => { + const evaluatorConnectorId = + selectedEvaluatorModel[0]?.key != null + ? { evaluatorConnectorId: selectedEvaluatorModel[0].key } + : {}; + + const langSmithApiKey = isEmpty(traceOptions.langSmithApiKey) + ? undefined + : traceOptions.langSmithApiKey; + + const langSmithProject = isEmpty(traceOptions.langSmithProject) + ? undefined + : traceOptions.langSmithProject; + const evalParams: PostEvaluateRequestBodyInput = { connectorIds: selectedModelOptions.flatMap((option) => option.key ?? []).sort(), graphs: selectedGraphOptions.map((option) => option.label).sort(), datasetName: selectedDatasetOptions[0]?.label, + ...evaluatorConnectorId, + langSmithApiKey, + langSmithProject, runName, + size: Number(size), }; performEvaluation(evalParams); }, [ performEvaluation, runName, selectedDatasetOptions, + selectedEvaluatorModel, selectedGraphOptions, selectedModelOptions, + size, + traceOptions.langSmithApiKey, + traceOptions.langSmithProject, ]); const getSection = (title: string, description: string) => ( @@ -355,6 +394,29 @@ export const EvaluationSettings: React.FC = React.memo(() => { onChange={onGraphOptionsChange} /> + + + + + + + setSize(e.target.value)} value={size} /> + diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts index 62902d0f14095..26eddb8a223c7 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/settings/evaluation_settings/translations.ts @@ -78,6 +78,36 @@ export const CONNECTORS_LABEL = i18n.translate( } ); +export const EVALUATOR_MODEL = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.evaluationSettings.evaluatorModelLabel', + { + defaultMessage: 'Evaluator model (optional)', + } +); + +export const DEFAULT_MAX_ALERTS = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.evaluationSettings.defaultMaxAlertsLabel', + { + defaultMessage: 'Default max alerts', + } +); + +export const EVALUATOR_MODEL_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.evaluationSettings.evaluatorModelDescription', + { + defaultMessage: + 'Judge the quality of all predictions using a single model. (Default: use the same model as the connector)', + } +); + +export const DEFAULT_MAX_ALERTS_DESCRIPTION = i18n.translate( + 'xpack.elasticAssistant.assistant.settings.evaluationSettings.defaultMaxAlertsDescription', + { + defaultMessage: + 'The default maximum number of alerts to send as context, which may be overridden by the Example input', + } +); + export const CONNECTORS_DESCRIPTION = i18n.translate( 'xpack.elasticAssistant.assistant.settings.evaluationSettings.connectorsDescription', { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx index be7724d882278..92a2a3df2683b 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/constants.tsx @@ -10,7 +10,9 @@ import { KnowledgeBaseConfig } from '../assistant/types'; export const ATTACK_DISCOVERY_STORAGE_KEY = 'attackDiscovery'; export const DEFAULT_ASSISTANT_NAMESPACE = 'elasticAssistantDefault'; export const LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY = 'lastConversationId'; +export const MAX_ALERTS_LOCAL_STORAGE_KEY = 'maxAlerts'; export const KNOWLEDGE_BASE_LOCAL_STORAGE_KEY = 'knowledgeBase'; +export const SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY = 'showSettingsTour'; export const STREAMING_LOCAL_STORAGE_KEY = 'streaming'; export const TRACE_OPTIONS_SESSION_STORAGE_KEY = 'traceOptions'; export const CONVERSATION_TABLE_SESSION_STORAGE_KEY = 'conversationTable'; @@ -21,6 +23,9 @@ export const ANONYMIZATION_TABLE_SESSION_STORAGE_KEY = 'anonymizationTable'; /** The default `n` latest alerts, ordered by risk score, sent as context to the assistant */ export const DEFAULT_LATEST_ALERTS = 20; +/** The default maximum number of alerts to be sent as context when generating Attack discoveries */ +export const DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS = 200; + export const DEFAULT_KNOWLEDGE_BASE_SETTINGS: KnowledgeBaseConfig = { latestAlerts: DEFAULT_LATEST_ALERTS, }; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index c7b15f681a717..2319bf67de89a 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -262,7 +262,10 @@ export const AssistantProvider: React.FC = ({ docLinks, getComments, http, - knowledgeBase: { ...DEFAULT_KNOWLEDGE_BASE_SETTINGS, ...localStorageKnowledgeBase }, + knowledgeBase: { + ...DEFAULT_KNOWLEDGE_BASE_SETTINGS, + ...localStorageKnowledgeBase, + }, promptContexts, navigateToApp, nameSpace, diff --git a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx index 63bd86121dcc1..6cfa60eff282d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/knowledge_base/alerts_range.tsx @@ -7,7 +7,7 @@ import { EuiRange, useGeneratedHtmlId } from '@elastic/eui'; import { css } from '@emotion/react'; -import React from 'react'; +import React, { useCallback } from 'react'; import { MAX_LATEST_ALERTS, MIN_LATEST_ALERTS, @@ -16,35 +16,57 @@ import { import { KnowledgeBaseConfig } from '../assistant/types'; import { ALERTS_RANGE } from './translations'; +export type SingleRangeChangeEvent = + | React.ChangeEvent + | React.KeyboardEvent + | React.MouseEvent; + interface Props { - knowledgeBase: KnowledgeBaseConfig; - setUpdatedKnowledgeBaseSettings: React.Dispatch>; compressed?: boolean; + maxAlerts?: number; + minAlerts?: number; + onChange?: (e: SingleRangeChangeEvent) => void; + knowledgeBase?: KnowledgeBaseConfig; + setUpdatedKnowledgeBaseSettings?: React.Dispatch>; + step?: number; + value: string | number; } const MAX_ALERTS_RANGE_WIDTH = 649; // px export const AlertsRange: React.FC = React.memo( - ({ knowledgeBase, setUpdatedKnowledgeBaseSettings, compressed = true }) => { + ({ + compressed = true, + knowledgeBase, + maxAlerts = MAX_LATEST_ALERTS, + minAlerts = MIN_LATEST_ALERTS, + onChange, + setUpdatedKnowledgeBaseSettings, + step = TICK_INTERVAL, + value, + }) => { const inputRangeSliderId = useGeneratedHtmlId({ prefix: 'inputRangeSlider' }); - return ( - + const handleOnChange = useCallback( + (e: SingleRangeChangeEvent) => { + if (knowledgeBase != null && setUpdatedKnowledgeBaseSettings != null) { setUpdatedKnowledgeBaseSettings({ ...knowledgeBase, latestAlerts: Number(e.currentTarget.value), - }) + }); } - showTicks - step={TICK_INTERVAL} - value={knowledgeBase.latestAlerts} + + if (onChange != null) { + onChange(e); + } + }, + [knowledgeBase, onChange, setUpdatedKnowledgeBaseSettings] + ); + + return ( + = React.memo( margin-inline-end: 0; } `} + data-test-subj="alertsRange" + id={inputRangeSliderId} + max={maxAlerts} + min={minAlerts} + onChange={handleOnChange} + showTicks + step={step} + value={value} /> ); } diff --git a/x-pack/packages/kbn-elastic-assistant/index.ts b/x-pack/packages/kbn-elastic-assistant/index.ts index 0baff57648cc8..7ec65c9601268 100644 --- a/x-pack/packages/kbn-elastic-assistant/index.ts +++ b/x-pack/packages/kbn-elastic-assistant/index.ts @@ -77,10 +77,17 @@ export { AssistantAvatar } from './impl/assistant/assistant_avatar/assistant_ava export { ConnectorSelectorInline } from './impl/connectorland/connector_selector_inline/connector_selector_inline'; export { + /** The Attack discovery local storage key */ ATTACK_DISCOVERY_STORAGE_KEY, DEFAULT_ASSISTANT_NAMESPACE, + /** The default maximum number of alerts to be sent as context when generating Attack discoveries */ + DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, DEFAULT_LATEST_ALERTS, KNOWLEDGE_BASE_LOCAL_STORAGE_KEY, + /** The local storage key that specifies the maximum number of alerts to send as context */ + MAX_ALERTS_LOCAL_STORAGE_KEY, + /** The local storage key that specifies whether the settings tour should be shown */ + SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY, } from './impl/assistant_context/constants'; export { useLoadConnectors } from './impl/connectorland/use_load_connectors'; @@ -140,3 +147,16 @@ export { mergeBaseWithPersistedConversations } from './impl/assistant/helpers'; export { UpgradeButtons } from './impl/upgrade/upgrade_buttons'; export { getUserConversations, getPrompts, bulkUpdatePrompts } from './impl/assistant/api'; + +export { + /** A range slider component, typically used to configure the number of alerts sent as context */ + AlertsRange, + /** This event occurs when the `AlertsRange` slider is changed */ + type SingleRangeChangeEvent, +} from './impl/knowledge_base/alerts_range'; +export { + /** A label instructing the user to send fewer alerts */ + SELECT_FEWER_ALERTS, + /** Your anonymization settings will apply to these alerts (label) */ + YOUR_ANONYMIZATION_SETTINGS, +} from './impl/knowledge_base/translations'; diff --git a/x-pack/plugins/elastic_assistant/README.md b/x-pack/plugins/elastic_assistant/README.md index 2a1e47c177591..8cf2c0b8903dd 100755 --- a/x-pack/plugins/elastic_assistant/README.md +++ b/x-pack/plugins/elastic_assistant/README.md @@ -10,15 +10,21 @@ Maintained by the Security Solution team ## Graph structure +### Default Assistant graph + ![DefaultAssistantGraph](./docs/img/default_assistant_graph.png) +### Default Attack discovery graph + +![DefaultAttackDiscoveryGraph](./docs/img/default_attack_discovery_graph.png) + ## Development ### Generate graph structure To generate the graph structure, run `yarn draw-graph` from the plugin directory. -The graph will be generated in the `docs/img` directory of the plugin. +The graphs will be generated in the `docs/img` directory of the plugin. ### Testing -To run the tests for this plugin, run `node scripts/jest --watch x-pack/plugins/elastic_assistant/jest.config.js --coverage` from the Kibana root directory. \ No newline at end of file +To run the tests for this plugin, run `node scripts/jest --watch x-pack/plugins/elastic_assistant/jest.config.js --coverage` from the Kibana root directory. diff --git a/x-pack/plugins/elastic_assistant/docs/img/default_assistant_graph.png b/x-pack/plugins/elastic_assistant/docs/img/default_assistant_graph.png index e4ef8382317e5..159b69c6d9572 100644 Binary files a/x-pack/plugins/elastic_assistant/docs/img/default_assistant_graph.png and b/x-pack/plugins/elastic_assistant/docs/img/default_assistant_graph.png differ diff --git a/x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png b/x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png new file mode 100644 index 0000000000000..658490900cca6 Binary files /dev/null and b/x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png differ diff --git a/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts b/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts index c44912ebf8d94..3b65d307ce385 100644 --- a/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts +++ b/x-pack/plugins/elastic_assistant/scripts/draw_graph_script.ts @@ -5,11 +5,13 @@ * 2.0. */ +import type { ElasticsearchClient } from '@kbn/core/server'; import { ToolingLog } from '@kbn/tooling-log'; import fs from 'fs/promises'; import path from 'path'; import { ActionsClientChatOpenAI, + type ActionsClientLlm, ActionsClientSimpleChatModel, } from '@kbn/langchain/server/language_models'; import type { Logger } from '@kbn/logging'; @@ -17,6 +19,11 @@ import { ChatPromptTemplate } from '@langchain/core/prompts'; import { FakeLLM } from '@langchain/core/utils/testing'; import { createOpenAIFunctionsAgent } from 'langchain/agents'; import { getDefaultAssistantGraph } from '../server/lib/langchain/graphs/default_assistant_graph/graph'; +import { getDefaultAttackDiscoveryGraph } from '../server/lib/attack_discovery/graphs/default_attack_discovery_graph'; + +interface Drawable { + drawMermaidPng: () => Promise; +} // Just defining some test variables to get the graph to compile.. const testPrompt = ChatPromptTemplate.fromMessages([ @@ -34,7 +41,7 @@ const createLlmInstance = () => { return mockLlm; }; -async function getGraph(logger: Logger) { +async function getAssistantGraph(logger: Logger): Promise { const agentRunnable = await createOpenAIFunctionsAgent({ llm: mockLlm, tools: [], @@ -51,16 +58,49 @@ async function getGraph(logger: Logger) { return graph.getGraph(); } -export const draw = async () => { +async function getAttackDiscoveryGraph(logger: Logger): Promise { + const mockEsClient = {} as unknown as ElasticsearchClient; + + const graph = getDefaultAttackDiscoveryGraph({ + anonymizationFields: [], + esClient: mockEsClient, + llm: mockLlm as unknown as ActionsClientLlm, + logger, + replacements: {}, + size: 20, + }); + + return graph.getGraph(); +} + +export const drawGraph = async ({ + getGraph, + outputFilename, +}: { + getGraph: (logger: Logger) => Promise; + outputFilename: string; +}) => { const logger = new ToolingLog({ level: 'info', writeTo: process.stdout, }) as unknown as Logger; logger.info('Compiling graph'); - const outputPath = path.join(__dirname, '../docs/img/default_assistant_graph.png'); + const outputPath = path.join(__dirname, outputFilename); const graph = await getGraph(logger); const output = await graph.drawMermaidPng(); const buffer = Buffer.from(await output.arrayBuffer()); logger.info(`Writing graph to ${outputPath}`); await fs.writeFile(outputPath, buffer); }; + +export const draw = async () => { + await drawGraph({ + getGraph: getAssistantGraph, + outputFilename: '../docs/img/default_assistant_graph.png', + }); + + await drawGraph({ + getGraph: getAttackDiscoveryGraph, + outputFilename: '../docs/img/default_attack_discovery_graph.png', + }); +}; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts index 9e8a0b5d2ac90..ee54e9c451ea2 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/attack_discovery_schema.mock.ts @@ -6,7 +6,7 @@ */ import { estypes } from '@elastic/elasticsearch'; -import { EsAttackDiscoverySchema } from '../ai_assistant_data_clients/attack_discovery/types'; +import { EsAttackDiscoverySchema } from '../lib/attack_discovery/persistence/types'; export const getAttackDiscoverySearchEsMock = () => { const searchResponse: estypes.SearchResponse = { diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts index 7e20e292a9868..473965a835f14 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts @@ -8,7 +8,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; import { AIAssistantDataClient } from '../ai_assistant_data_clients'; -import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; +import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; type ConversationsDataClientContract = PublicMethodsOf; export type ConversationsDataClientMock = jest.Mocked; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts index b52e7db536a3d..d53ceaa586975 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts @@ -26,7 +26,7 @@ import { GetAIAssistantKnowledgeBaseDataClientParams, } from '../ai_assistant_data_clients/knowledge_base'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; -import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; +import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; export const createMockClients = () => { const core = coreMock.createRequestHandlerContext(); diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts index def0a81acea37..ae736c77c30ef 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts @@ -16,7 +16,7 @@ import { getPromptsSearchEsMock } from './prompts_schema.mock'; import { EsAnonymizationFieldsSchema } from '../ai_assistant_data_clients/anonymization_fields/types'; import { getAnonymizationFieldsSearchEsMock } from './anonymization_fields_schema.mock'; import { getAttackDiscoverySearchEsMock } from './attack_discovery_schema.mock'; -import { EsAttackDiscoverySchema } from '../ai_assistant_data_clients/attack_discovery/types'; +import { EsAttackDiscoverySchema } from '../lib/attack_discovery/persistence/types'; export const responseMock = { create: httpServerMock.createResponseFactory, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts index 08912f41a8bbc..4cde64424ed7e 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.ts @@ -11,7 +11,7 @@ import type { AuthenticatedUser, Logger, ElasticsearchClient } from '@kbn/core/s import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import { Subject } from 'rxjs'; -import { attackDiscoveryFieldMap } from '../ai_assistant_data_clients/attack_discovery/field_maps_configuration'; +import { attackDiscoveryFieldMap } from '../lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration'; import { getDefaultAnonymizationFields } from '../../common/anonymization'; import { AssistantResourceNames, GetElser } from '../types'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; @@ -34,7 +34,7 @@ import { AIAssistantKnowledgeBaseDataClient, GetAIAssistantKnowledgeBaseDataClientParams, } from '../ai_assistant_data_clients/knowledge_base'; -import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery'; +import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; import { createGetElserId, createPipeline, pipelineExists } from './helpers'; const TOTAL_FIELDS_LIMIT = 2500; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts new file mode 100644 index 0000000000000..d149b8c4cd44d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_examples.ts @@ -0,0 +1,55 @@ +/* + * 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 { Example } from 'langsmith/schemas'; + +export const exampleWithReplacements: Example = { + id: '5D436078-B2CF-487A-A0FA-7CB46696F54E', + created_at: '2024-10-10T23:01:19.350232+00:00', + dataset_id: '0DA3497B-B084-4105-AFC0-2D8E05DE4B7C', + modified_at: '2024-10-10T23:01:19.350232+00:00', + inputs: {}, + outputs: { + attackDiscoveries: [ + { + title: 'Critical Malware and Phishing Alerts on host e1cb3cf0-30f3-4f99-a9c8-518b955c6f90', + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + timestamp: '2024-10-10T22:59:52.749Z', + detailsMarkdown: + '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', + summaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} involving user {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', + mitreAttackTactics: ['Credential Access', 'Input Capture'], + entitySummaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }} involving user {{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}.', + }, + ], + replacements: { + '039c15c5-3964-43e7-a891-42fe2ceeb9ff': 'james', + '0b53f092-96dd-4282-bfb9-4f75a4530b80': 'root', + '1123bd7b-3afb-45d1-801a-108f04e7cfb7': 'SRVWIN04', + '3b9856bc-2c0d-4f1a-b9ae-32742e15ddd1': 'SRVWIN07', + '5306bcfd-2729-49e3-bdf0-678002778ccf': 'SRVWIN01', + '55af96a7-69b0-47cf-bf11-29be98a59eb0': 'SRVNIX05', + '66919fe3-16a4-4dfe-bc90-713f0b33a2ff': 'Administrator', + '9404361f-53fa-484f-adf8-24508256e70e': 'SRVWIN03', + 'e1cb3cf0-30f3-4f99-a9c8-518b955c6f90': 'SRVMAC08', + 'f59a00e2-f9c4-4069-8390-fd36ecd16918': 'SRVWIN02', + 'fc6d07da-5186-4d59-9b79-9382b0c226b3': 'SRVWIN06', + }, + }, + runs: [], +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts new file mode 100644 index 0000000000000..23c9c08ff5080 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/__mocks__/mock_runs.ts @@ -0,0 +1,53 @@ +/* + * 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 { Run } from 'langsmith/schemas'; + +export const runWithReplacements: Run = { + id: 'B7B03FEE-9AC4-4823-AEDB-F8EC20EAD5C4', + inputs: {}, + name: 'test', + outputs: { + attackDiscoveries: [ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` by the user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: + 'The host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` and user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}` were involved in the attack.', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name e1cb3cf0-30f3-4f99-a9c8-518b955c6f90 }}` involving the user `{{ user.name 039c15c5-3964-43e7-a891-42fe2ceeb9ff }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ], + replacements: { + '039c15c5-3964-43e7-a891-42fe2ceeb9ff': 'james', + '0b53f092-96dd-4282-bfb9-4f75a4530b80': 'root', + '1123bd7b-3afb-45d1-801a-108f04e7cfb7': 'SRVWIN04', + '3b9856bc-2c0d-4f1a-b9ae-32742e15ddd1': 'SRVWIN07', + '5306bcfd-2729-49e3-bdf0-678002778ccf': 'SRVWIN01', + '55af96a7-69b0-47cf-bf11-29be98a59eb0': 'SRVNIX05', + '66919fe3-16a4-4dfe-bc90-713f0b33a2ff': 'Administrator', + '9404361f-53fa-484f-adf8-24508256e70e': 'SRVWIN03', + 'e1cb3cf0-30f3-4f99-a9c8-518b955c6f90': 'SRVMAC08', + 'f59a00e2-f9c4-4069-8390-fd36ecd16918': 'SRVWIN02', + 'fc6d07da-5186-4d59-9b79-9382b0c226b3': 'SRVWIN06', + }, + }, + run_type: 'evaluation', +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts new file mode 100644 index 0000000000000..c6f6f09f1d9ae --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/constants.ts @@ -0,0 +1,911 @@ +/* + * 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 { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; + +export const DEFAULT_EVAL_ANONYMIZATION_FIELDS: AnonymizationFieldResponse[] = [ + { + id: 'Mx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: '_id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'NB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: '@timestamp', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'NR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'cloud.availability_zone', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Nh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'cloud.provider', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Nx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'cloud.region', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'OB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'destination.ip', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'OR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'dns.question.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Oh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'dns.question.type', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Ox09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'event.category', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'PB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'event.dataset', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'PR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'event.module', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Ph09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'event.outcome', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Px09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'file.Ext.original.path', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'QB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'file.hash.sha256', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'QR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'file.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Qh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'file.path', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Qx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'group.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'RB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'group.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'RR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.asset.criticality', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Rh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.name', + allowed: true, + anonymized: true, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Rx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.os.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'SB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.os.version', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'SR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.risk.calculated_level', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Sh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'host.risk.calculated_score_norm', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Sx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.original_time', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'TB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.risk_score', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'TR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.description', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Th09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Tx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.references', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'UB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.framework', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'UR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.tactic.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Uh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.tactic.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Ux09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.tactic.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'VB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'VR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Vh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Vx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'WB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'WR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.rule.threat.technique.subtechnique.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Wh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.severity', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Wx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'kibana.alert.workflow_status', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'XB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'message', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'XR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'network.protocol', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Xh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.args', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Xx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.exists', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'YB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.signing_id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'YR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.status', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Yh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.subject_name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Yx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.code_signature.trusted', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ZB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.command_line', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ZR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.executable', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Zh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.exit_code', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'Zx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.memory_region.bytes_compressed_present', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'aB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.memory_region.malware_signature.all_names', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'aR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.memory_region.malware_signature.primary.matches', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ah09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.memory_region.malware_signature.primary.signature.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ax09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.Ext.token.integrity_level_name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'bB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.hash.md5', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'bR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.hash.sha1', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'bh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.hash.sha256', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'bx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'cB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.args', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'cR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.args_count', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ch09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.code_signature.exists', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'cx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.code_signature.status', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'dB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.code_signature.subject_name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'dR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.code_signature.trusted', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'dh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.command_line', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'dx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.executable', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'eB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.parent.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'eR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.pe.original_file_name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'eh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.pid', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ex09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'process.working_directory', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'fB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.feature', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'fR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.data', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'fh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.entropy', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'fx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.extension', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'gB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.metrics', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'gR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.operation', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'gh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.path', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'gx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.files.score', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'hB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'Ransomware.version', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'hR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'rule.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'hh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'rule.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'hx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'source.ip', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'iB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.framework', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'iR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.tactic.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ih09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.tactic.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'ix09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.tactic.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'jB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'jR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'jh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'jx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.subtechnique.id', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'kB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.subtechnique.name', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'kR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'threat.technique.subtechnique.reference', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'kh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.asset.criticality', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'kx09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.domain', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'lB09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.name', + allowed: true, + anonymized: true, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'lR09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.risk.calculated_level', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, + { + id: 'lh09VpEBOiz7eA-eF2fb', + timestamp: '2024-08-15T13:32:10.073Z', + field: 'user.risk.calculated_score_norm', + allowed: true, + anonymized: false, + createdAt: '2024-08-15T13:32:10.073Z', + namespace: 'default', + }, +]; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts new file mode 100644 index 0000000000000..93d442bad5e9b --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { ExampleInput, ExampleInputWithOverrides } from '.'; + +const validInput = { + attackDiscoveries: null, + attackDiscoveryPrompt: 'prompt', + anonymizedAlerts: [{ pageContent: 'content', metadata: { key: 'value' } }], + combinedGenerations: 'gen1gen2', + combinedRefinements: 'ref1ref2', + errors: ['error1', 'error2'], + generationAttempts: 1, + generations: ['gen1', 'gen2'], + hallucinationFailures: 0, + maxGenerationAttempts: 5, + maxHallucinationFailures: 2, + maxRepeatedGenerations: 3, + refinements: ['ref1', 'ref2'], + refinePrompt: 'refine prompt', + replacements: { key: 'replacement' }, + unrefinedResults: null, +}; + +describe('ExampleInput Schema', () => { + it('validates a correct ExampleInput object', () => { + expect(() => ExampleInput.parse(validInput)).not.toThrow(); + }); + + it('throws given an invalid ExampleInput', () => { + const invalidInput = { + attackDiscoveries: 'invalid', // should be an array or null + }; + + expect(() => ExampleInput.parse(invalidInput)).toThrow(); + }); + + it('removes unknown properties', () => { + const hasUnknownProperties = { + ...validInput, + unknownProperty: 'unknown', // <-- should be removed + }; + + const parsed = ExampleInput.parse(hasUnknownProperties); + + expect(parsed).not.toHaveProperty('unknownProperty'); + }); +}); + +describe('ExampleInputWithOverrides Schema', () => { + it('validates a correct ExampleInputWithOverrides object', () => { + const validInputWithOverrides = { + ...validInput, + overrides: { + attackDiscoveryPrompt: 'ad prompt override', + refinePrompt: 'refine prompt override', + }, + }; + + expect(() => ExampleInputWithOverrides.parse(validInputWithOverrides)).not.toThrow(); + }); + + it('throws when given an invalid ExampleInputWithOverrides object', () => { + const invalidInputWithOverrides = { + attackDiscoveries: null, + overrides: 'invalid', // should be an object + }; + + expect(() => ExampleInputWithOverrides.parse(invalidInputWithOverrides)).toThrow(); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts new file mode 100644 index 0000000000000..8183695fd7d2f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/example_input/index.ts @@ -0,0 +1,52 @@ +/* + * 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 { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import { z } from '@kbn/zod'; + +const Document = z.object({ + pageContent: z.string(), + metadata: z.record(z.string(), z.any()), +}); + +type Document = z.infer; + +/** + * Parses the input from an example in a LangSmith dataset + */ +export const ExampleInput = z.object({ + attackDiscoveries: z.array(AttackDiscovery).nullable().optional(), + attackDiscoveryPrompt: z.string().optional(), + anonymizedAlerts: z.array(Document).optional(), + combinedGenerations: z.string().optional(), + combinedRefinements: z.string().optional(), + errors: z.array(z.string()).optional(), + generationAttempts: z.number().optional(), + generations: z.array(z.string()).optional(), + hallucinationFailures: z.number().optional(), + maxGenerationAttempts: z.number().optional(), + maxHallucinationFailures: z.number().optional(), + maxRepeatedGenerations: z.number().optional(), + refinements: z.array(z.string()).optional(), + refinePrompt: z.string().optional(), + replacements: Replacements.optional(), + unrefinedResults: z.array(AttackDiscovery).nullable().optional(), +}); + +export type ExampleInput = z.infer; + +/** + * The optional overrides for an example input + */ +export const ExampleInputWithOverrides = z.intersection( + ExampleInput, + z.object({ + overrides: ExampleInput.optional(), + }) +); + +export type ExampleInputWithOverrides = z.infer; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts new file mode 100644 index 0000000000000..8ea30103c0768 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { getDefaultPromptTemplate } from '.'; + +describe('getDefaultPromptTemplate', () => { + it('returns the expected prompt template', () => { + const expectedTemplate = `Evaluate based on how well the following submission follows the specified rubric. Grade only based on the rubric and "expected response": + +[BEGIN rubric] +1. Is the submission non-empty and not null? +2. Is the submission well-formed JSON? +3. Evaluate the value of the "detailsMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "detailsMarkdown" in the submission capture the essence of the "expected response", regardless of the order in which they appear, and highlight the same incident(s)? +4. Evaluate the value of the "entitySummaryMarkdown" field of all the "attackDiscoveries" in the submission json. Does the value of "entitySummaryMarkdown" in the submission mention at least 50% the same entities as in the "expected response"? +5. Evaluate the value of the "summaryMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "summaryMarkdown" in the submission at least partially similar to that of the "expected response", regardless of the order in which they appear, and summarize the same incident(s)? +6. Evaluate the value of the "title" field of all the "attackDiscoveries" in the submission json. Are the "title" values in the submission at least partially similar to the tile(s) of the "expected response", regardless of the order in which they appear, and mention the same incident(s)? +7. Evaluate the value of the "alertIds" field of all the "attackDiscoveries" in the submission json. Do they match at least 100% of the "alertIds" in the submission? +[END rubric] + +[BEGIN DATA] +{input} +[BEGIN submission] +{output} +[END submission] +[BEGIN expected response] +{reference} +[END expected response] +[END DATA] + +{criteria} Base your answer based on all the grading rubric items. If at least 5 of the 7 rubric items are correct, consider the submission correct. Write out your explanation for each criterion in the rubric, first in detail, then as a separate summary on a new line. + +Then finally respond with a single character, 'Y' or 'N', on a new line without any preceding or following characters. It's important that only a single character appears on the last line.`; + + const result = getDefaultPromptTemplate(); + + expect(result).toBe(expectedTemplate); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts new file mode 100644 index 0000000000000..08e10f00e7f77 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_default_prompt_template/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getDefaultPromptTemplate = + () => `Evaluate based on how well the following submission follows the specified rubric. Grade only based on the rubric and "expected response": + +[BEGIN rubric] +1. Is the submission non-empty and not null? +2. Is the submission well-formed JSON? +3. Evaluate the value of the "detailsMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "detailsMarkdown" in the submission capture the essence of the "expected response", regardless of the order in which they appear, and highlight the same incident(s)? +4. Evaluate the value of the "entitySummaryMarkdown" field of all the "attackDiscoveries" in the submission json. Does the value of "entitySummaryMarkdown" in the submission mention at least 50% the same entities as in the "expected response"? +5. Evaluate the value of the "summaryMarkdown" field of all the "attackDiscoveries" in the submission json. Do the values of "summaryMarkdown" in the submission at least partially similar to that of the "expected response", regardless of the order in which they appear, and summarize the same incident(s)? +6. Evaluate the value of the "title" field of all the "attackDiscoveries" in the submission json. Are the "title" values in the submission at least partially similar to the tile(s) of the "expected response", regardless of the order in which they appear, and mention the same incident(s)? +7. Evaluate the value of the "alertIds" field of all the "attackDiscoveries" in the submission json. Do they match at least 100% of the "alertIds" in the submission? +[END rubric] + +[BEGIN DATA] +{input} +[BEGIN submission] +{output} +[END submission] +[BEGIN expected response] +{reference} +[END expected response] +[END DATA] + +{criteria} Base your answer based on all the grading rubric items. If at least 5 of the 7 rubric items are correct, consider the submission correct. Write out your explanation for each criterion in the rubric, first in detail, then as a separate summary on a new line. + +Then finally respond with a single character, 'Y' or 'N', on a new line without any preceding or following characters. It's important that only a single character appears on the last line.`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts new file mode 100644 index 0000000000000..c261f151b99ab --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.test.ts @@ -0,0 +1,125 @@ +/* + * 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 { omit } from 'lodash/fp'; + +import { getExampleAttackDiscoveriesWithReplacements } from '.'; +import { exampleWithReplacements } from '../../../__mocks__/mock_examples'; + +describe('getExampleAttackDiscoveriesWithReplacements', () => { + it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { + const result = getExampleAttackDiscoveriesWithReplacements(exampleWithReplacements); + + expect(result).toEqual([ + { + title: 'Critical Malware and Phishing Alerts on host SRVMAC08', + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + timestamp: '2024-10-10T22:59:52.749Z', + detailsMarkdown: + '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name SRVMAC08 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name james }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', + summaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', + mitreAttackTactics: ['Credential Access', 'Input Capture'], + entitySummaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}.', + }, + ]); + }); + + it('returns an empty entitySummaryMarkdown when the entitySummaryMarkdown is missing', () => { + const missingEntitySummaryMarkdown = omit( + 'entitySummaryMarkdown', + exampleWithReplacements.outputs?.attackDiscoveries?.[0] + ); + + const exampleWithMissingEntitySummaryMarkdown = { + ...exampleWithReplacements, + outputs: { + ...exampleWithReplacements.outputs, + attackDiscoveries: [missingEntitySummaryMarkdown], + }, + }; + + const result = getExampleAttackDiscoveriesWithReplacements( + exampleWithMissingEntitySummaryMarkdown + ); + + expect(result).toEqual([ + { + title: 'Critical Malware and Phishing Alerts on host SRVMAC08', + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + timestamp: '2024-10-10T22:59:52.749Z', + detailsMarkdown: + '- On `2023-06-19T00:28:38.061Z` a critical malware detection alert was triggered on host {{ host.name SRVMAC08 }} running {{ host.os.name macOS }} version {{ host.os.version 13.4 }}.\n- The malware was identified as {{ file.name unix1 }} with SHA256 hash {{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}.\n- The process {{ process.name My Go Application.app }} was executed with command line {{ process.command_line /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app }}.\n- The process was not trusted as its code signature failed to satisfy specified code requirements.\n- The user involved was {{ user.name james }}.\n- Another critical alert was triggered for potential credentials phishing via {{ process.name osascript }} on the same host.\n- The phishing attempt involved displaying a dialog to capture the user\'s password.\n- The process {{ process.name osascript }} was executed with command line {{ process.command_line osascript -e display dialog "MacOS wants to access System Preferences\\n\\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬ }}.\n- The MITRE ATT&CK tactics involved include Credential Access and Input Capture.', + summaryMarkdown: + 'Critical malware and phishing alerts detected on {{ host.name SRVMAC08 }} involving user {{ user.name james }}. Malware identified as {{ file.name unix1 }} and phishing attempt via {{ process.name osascript }}.', + mitreAttackTactics: ['Credential Access', 'Input Capture'], + entitySummaryMarkdown: '', + }, + ]); + }); + + it('throws when an example is undefined', () => { + expect(() => getExampleAttackDiscoveriesWithReplacements(undefined)).toThrowError(); + }); + + it('throws when the example is missing attackDiscoveries', () => { + const missingAttackDiscoveries = { + ...exampleWithReplacements, + outputs: { + replacements: { ...exampleWithReplacements.outputs?.replacements }, + }, + }; + + expect(() => + getExampleAttackDiscoveriesWithReplacements(missingAttackDiscoveries) + ).toThrowError(); + }); + + it('throws when attackDiscoveries is null', () => { + const nullAttackDiscoveries = { + ...exampleWithReplacements, + outputs: { + attackDiscoveries: null, + replacements: { ...exampleWithReplacements.outputs?.replacements }, + }, + }; + + expect(() => getExampleAttackDiscoveriesWithReplacements(nullAttackDiscoveries)).toThrowError(); + }); + + it('returns the original attack discoveries when replacements are missing', () => { + const missingReplacements = { + ...exampleWithReplacements, + outputs: { + attackDiscoveries: [...exampleWithReplacements.outputs?.attackDiscoveries], + }, + }; + + const result = getExampleAttackDiscoveriesWithReplacements(missingReplacements); + + expect(result).toEqual(exampleWithReplacements.outputs?.attackDiscoveries); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts new file mode 100644 index 0000000000000..8fc5de2a08ed1 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_example_attack_discoveries_with_replacements/index.ts @@ -0,0 +1,29 @@ +/* + * 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 { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common'; +import type { Example } from 'langsmith/schemas'; + +import { getDiscoveriesWithOriginalValues } from '../../get_discoveries_with_original_values'; + +export const getExampleAttackDiscoveriesWithReplacements = ( + example: Example | undefined +): AttackDiscoveries => { + const exampleAttackDiscoveries = example?.outputs?.attackDiscoveries; + const exampleReplacements = example?.outputs?.replacements ?? {}; + + // NOTE: calls to `parse` throw an error if the Example input is invalid + const validatedAttackDiscoveries = AttackDiscoveries.parse(exampleAttackDiscoveries); + const validatedReplacements = Replacements.parse(exampleReplacements); + + const withReplacements = getDiscoveriesWithOriginalValues({ + attackDiscoveries: validatedAttackDiscoveries, + replacements: validatedReplacements, + }); + + return withReplacements; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts new file mode 100644 index 0000000000000..bd22e5d952b07 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.test.ts @@ -0,0 +1,117 @@ +/* + * 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 { omit } from 'lodash/fp'; + +import { getRunAttackDiscoveriesWithReplacements } from '.'; +import { runWithReplacements } from '../../../__mocks__/mock_runs'; + +describe('getRunAttackDiscoveriesWithReplacements', () => { + it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { + const result = getRunAttackDiscoveriesWithReplacements(runWithReplacements); + + expect(result).toEqual([ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: + 'The host `{{ host.name SRVMAC08 }}` and user `{{ user.name james }}` were involved in the attack.', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ]); + }); + + it("returns an empty entitySummaryMarkdown when it's missing from the attack discovery", () => { + const missingEntitySummaryMarkdown = omit( + 'entitySummaryMarkdown', + runWithReplacements.outputs?.attackDiscoveries?.[0] + ); + + const runWithMissingEntitySummaryMarkdown = { + ...runWithReplacements, + outputs: { + ...runWithReplacements.outputs, + attackDiscoveries: [missingEntitySummaryMarkdown], + }, + }; + + const result = getRunAttackDiscoveriesWithReplacements(runWithMissingEntitySummaryMarkdown); + + expect(result).toEqual([ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: '', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ]); + }); + + it('throws when the run is missing attackDiscoveries', () => { + const missingAttackDiscoveries = { + ...runWithReplacements, + outputs: { + replacements: { ...runWithReplacements.outputs?.replacements }, + }, + }; + + expect(() => getRunAttackDiscoveriesWithReplacements(missingAttackDiscoveries)).toThrowError(); + }); + + it('throws when attackDiscoveries is null', () => { + const nullAttackDiscoveries = { + ...runWithReplacements, + outputs: { + attackDiscoveries: null, + replacements: { ...runWithReplacements.outputs?.replacements }, + }, + }; + + expect(() => getRunAttackDiscoveriesWithReplacements(nullAttackDiscoveries)).toThrowError(); + }); + + it('returns the original attack discoveries when replacements are missing', () => { + const missingReplacements = { + ...runWithReplacements, + outputs: { + attackDiscoveries: [...runWithReplacements.outputs?.attackDiscoveries], + }, + }; + + const result = getRunAttackDiscoveriesWithReplacements(missingReplacements); + + expect(result).toEqual(runWithReplacements.outputs?.attackDiscoveries); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts new file mode 100644 index 0000000000000..01193320f712b --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/get_run_attack_discoveries_with_replacements/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common'; +import type { Run } from 'langsmith/schemas'; + +import { getDiscoveriesWithOriginalValues } from '../../get_discoveries_with_original_values'; + +export const getRunAttackDiscoveriesWithReplacements = (run: Run): AttackDiscoveries => { + const runAttackDiscoveries = run.outputs?.attackDiscoveries; + const runReplacements = run.outputs?.replacements ?? {}; + + // NOTE: calls to `parse` throw an error if the Run Input is invalid + const validatedAttackDiscoveries = AttackDiscoveries.parse(runAttackDiscoveries); + const validatedReplacements = Replacements.parse(runReplacements); + + const withReplacements = getDiscoveriesWithOriginalValues({ + attackDiscoveries: validatedAttackDiscoveries, + replacements: validatedReplacements, + }); + + return withReplacements; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts new file mode 100644 index 0000000000000..829e27df73f14 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { PromptTemplate } from '@langchain/core/prompts'; +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import { loadEvaluator } from 'langchain/evaluation'; + +import { type GetCustomEvaluatorOptions, getCustomEvaluator } from '.'; +import { getDefaultPromptTemplate } from './get_default_prompt_template'; +import { getExampleAttackDiscoveriesWithReplacements } from './get_example_attack_discoveries_with_replacements'; +import { getRunAttackDiscoveriesWithReplacements } from './get_run_attack_discoveries_with_replacements'; +import { exampleWithReplacements } from '../../__mocks__/mock_examples'; +import { runWithReplacements } from '../../__mocks__/mock_runs'; + +const mockLlm = jest.fn() as unknown as ActionsClientLlm; + +jest.mock('langchain/evaluation', () => ({ + ...jest.requireActual('langchain/evaluation'), + loadEvaluator: jest.fn().mockResolvedValue({ + evaluateStrings: jest.fn().mockResolvedValue({ + key: 'correctness', + score: 0.9, + }), + }), +})); + +const options: GetCustomEvaluatorOptions = { + criteria: 'correctness', + key: 'attack_discovery_correctness', + llm: mockLlm, + template: getDefaultPromptTemplate(), +}; + +describe('getCustomEvaluator', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns an evaluator function', () => { + const evaluator = getCustomEvaluator(options); + + expect(typeof evaluator).toBe('function'); + }); + + it('calls loadEvaluator with the expected arguments', async () => { + const evaluator = getCustomEvaluator(options); + + await evaluator(runWithReplacements, exampleWithReplacements); + + expect(loadEvaluator).toHaveBeenCalledWith('labeled_criteria', { + criteria: options.criteria, + chainOptions: { + prompt: PromptTemplate.fromTemplate(options.template), + }, + llm: mockLlm, + }); + }); + + it('calls evaluateStrings with the expected arguments', async () => { + const mockEvaluateStrings = jest.fn().mockResolvedValue({ + key: 'correctness', + score: 0.9, + }); + + (loadEvaluator as jest.Mock).mockResolvedValue({ + evaluateStrings: mockEvaluateStrings, + }); + + const evaluator = getCustomEvaluator(options); + + await evaluator(runWithReplacements, exampleWithReplacements); + + const prediction = getRunAttackDiscoveriesWithReplacements(runWithReplacements); + const reference = getExampleAttackDiscoveriesWithReplacements(exampleWithReplacements); + + expect(mockEvaluateStrings).toHaveBeenCalledWith({ + input: '', + prediction: JSON.stringify(prediction, null, 2), + reference: JSON.stringify(reference, null, 2), + }); + }); + + it('returns the expected result', async () => { + const evaluator = getCustomEvaluator(options); + + const result = await evaluator(runWithReplacements, exampleWithReplacements); + + expect(result).toEqual({ key: 'attack_discovery_correctness', score: 0.9 }); + }); + + it('throws given an undefined example', async () => { + const evaluator = getCustomEvaluator(options); + + await expect(async () => evaluator(runWithReplacements, undefined)).rejects.toThrow(); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts new file mode 100644 index 0000000000000..bcabe410c1b72 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts @@ -0,0 +1,69 @@ +/* + * 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 { ActionsClientLlm } from '@kbn/langchain/server'; +import { PromptTemplate } from '@langchain/core/prompts'; +import type { EvaluationResult } from 'langsmith/evaluation'; +import type { Run, Example } from 'langsmith/schemas'; +import { CriteriaLike, loadEvaluator } from 'langchain/evaluation'; + +import { getExampleAttackDiscoveriesWithReplacements } from './get_example_attack_discoveries_with_replacements'; +import { getRunAttackDiscoveriesWithReplacements } from './get_run_attack_discoveries_with_replacements'; + +export interface GetCustomEvaluatorOptions { + /** + * Examples: + * - "conciseness" + * - "relevance" + * - "correctness" + * - "detail" + */ + criteria: CriteriaLike; + /** + * The evaluation score will use this key + */ + key: string; + /** + * LLm to use for evaluation + */ + llm: ActionsClientLlm; + /** + * A prompt template that uses the {input}, {submission}, and {reference} variables + */ + template: string; +} + +export type CustomEvaluator = ( + rootRun: Run, + example: Example | undefined +) => Promise; + +export const getCustomEvaluator = + ({ criteria, key, llm, template }: GetCustomEvaluatorOptions): CustomEvaluator => + async (rootRun, example) => { + const chain = await loadEvaluator('labeled_criteria', { + criteria, + chainOptions: { + prompt: PromptTemplate.fromTemplate(template), + }, + llm, + }); + + const exampleAttackDiscoveriesWithReplacements = + getExampleAttackDiscoveriesWithReplacements(example); + + const runAttackDiscoveriesWithReplacements = getRunAttackDiscoveriesWithReplacements(rootRun); + + // NOTE: res contains a score, as well as the reasoning for the score + const res = await chain.evaluateStrings({ + input: '', // empty for now, but this could be the alerts, i.e. JSON.stringify(rootRun.outputs?.anonymizedAlerts, null, 2), + prediction: JSON.stringify(runAttackDiscoveriesWithReplacements, null, 2), + reference: JSON.stringify(exampleAttackDiscoveriesWithReplacements, null, 2), + }); + + return { key, score: res.score }; + }; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts new file mode 100644 index 0000000000000..423248aa5c3d6 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { AttackDiscovery } from '@kbn/elastic-assistant-common'; +import { omit } from 'lodash/fp'; + +import { getDiscoveriesWithOriginalValues } from '.'; +import { runWithReplacements } from '../../__mocks__/mock_runs'; + +describe('getDiscoveriesWithOriginalValues', () => { + it('returns attack discoveries with replacements applied to the detailsMarkdown, entitySummaryMarkdown, summaryMarkdown, and title', () => { + const result = getDiscoveriesWithOriginalValues({ + attackDiscoveries: runWithReplacements.outputs?.attackDiscoveries, + replacements: runWithReplacements.outputs?.replacements, + }); + + expect(result).toEqual([ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: + 'The host `{{ host.name SRVMAC08 }}` and user `{{ user.name james }}` were involved in the attack.', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ]); + }); + + it("returns an empty entitySummaryMarkdown when it's missing from the attack discovery", () => { + const missingEntitySummaryMarkdown = omit( + 'entitySummaryMarkdown', + runWithReplacements.outputs?.attackDiscoveries?.[0] + ) as unknown as AttackDiscovery; + + const result = getDiscoveriesWithOriginalValues({ + attackDiscoveries: [missingEntitySummaryMarkdown], + replacements: runWithReplacements.outputs?.replacements, + }); + expect(result).toEqual([ + { + alertIds: [ + '4af5689eb58c2420efc0f7fad53c5bf9b8b6797e516d6ea87d6044ce25d54e16', + 'c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b', + '021b27d6bee0650a843be1d511119a3b5c7c8fdaeff922471ce0248ad27bd26c', + '6cc8d5f0e1c2b6c75219b001858f1be64194a97334be7a1e3572f8cfe6bae608', + 'f39a4013ed9609584a8a22dca902e896aa5b24d2da03e0eaab5556608fa682ac', + '909968e926e08a974c7df1613d98ebf1e2422afcb58e4e994beb47b063e85080', + '2c25a4dc31cd1ec254c2b19ea663fd0b09a16e239caa1218b4598801fb330da6', + '3bf907becb3a4f8e39a3b673e0d50fc954a7febef30c12891744c603760e4998', + ], + detailsMarkdown: + '- The attack began with the execution of a malicious file named `unix1` on the host `{{ host.name SRVMAC08 }}` by the user `{{ user.name james }}`.\n- The file `unix1` was detected at `{{ file.path /Users/james/unix1 }}` with a SHA256 hash of `{{ file.hash.sha256 0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231 }}`.\n- The process `{{ process.name My Go Application.app }}` was executed multiple times with different arguments, indicating potential persistence mechanisms.\n- The process `{{ process.name chmod }}` was used to change permissions of the file `unix1` to 777, making it executable.\n- A phishing attempt was detected via `osascript` on the same host, attempting to capture user credentials.\n- The attack involved multiple critical alerts, all indicating high-risk malware activity.', + entitySummaryMarkdown: '', + mitreAttackTactics: ['Initial Access', 'Execution', 'Persistence', 'Credential Access'], + summaryMarkdown: + 'A series of critical malware alerts were detected on the host `{{ host.name SRVMAC08 }}` involving the user `{{ user.name james }}`. The attack included the execution of a malicious file `unix1`, permission changes, and a phishing attempt via `osascript`.', + title: 'Critical Malware Attack on macOS Host', + timestamp: '2024-10-11T17:55:59.702Z', + }, + ]); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts new file mode 100644 index 0000000000000..1ef88e2208d1f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_discoveries_with_original_values/index.ts @@ -0,0 +1,39 @@ +/* + * 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 AttackDiscovery, + Replacements, + replaceAnonymizedValuesWithOriginalValues, +} from '@kbn/elastic-assistant-common'; + +export const getDiscoveriesWithOriginalValues = ({ + attackDiscoveries, + replacements, +}: { + attackDiscoveries: AttackDiscovery[]; + replacements: Replacements; +}): AttackDiscovery[] => + attackDiscoveries.map((attackDiscovery) => ({ + ...attackDiscovery, + detailsMarkdown: replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.detailsMarkdown, + replacements, + }), + entitySummaryMarkdown: replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.entitySummaryMarkdown ?? '', + replacements, + }), + summaryMarkdown: replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.summaryMarkdown, + replacements, + }), + title: replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.title, + replacements, + }), + })); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts new file mode 100644 index 0000000000000..132a819d44ec8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.test.ts @@ -0,0 +1,161 @@ +/* + * 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 { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { loggerMock } from '@kbn/logging-mocks'; + +import { getEvaluatorLlm } from '.'; + +jest.mock('@kbn/langchain/server', () => ({ + ...jest.requireActual('@kbn/langchain/server'), + + ActionsClientLlm: jest.fn(), +})); + +const connectorTimeout = 1000; + +const evaluatorConnectorId = 'evaluator-connector-id'; +const evaluatorConnector = { + id: 'evaluatorConnectorId', + actionTypeId: '.gen-ai', + name: 'GPT-4o', + isPreconfigured: true, + isSystemAction: false, + isDeprecated: false, +} as Connector; + +const experimentConnector: Connector = { + name: 'Gemini 1.5 Pro 002', + actionTypeId: '.gemini', + config: { + apiUrl: 'https://example.com', + defaultModel: 'gemini-1.5-pro-002', + gcpRegion: 'test-region', + gcpProjectID: 'test-project-id', + }, + secrets: { + credentialsJson: '{}', + }, + id: 'gemini-1-5-pro-002', + isPreconfigured: true, + isSystemAction: false, + isDeprecated: false, +} as Connector; + +const logger = loggerMock.create(); + +describe('getEvaluatorLlm', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('getting the evaluation connector', () => { + it("calls actionsClient.get with the evaluator connector ID when it's provided", async () => { + const actionsClient = { + get: jest.fn(), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey: undefined, + logger, + }); + + expect(actionsClient.get).toHaveBeenCalledWith({ + id: evaluatorConnectorId, + throwIfSystemAction: false, + }); + }); + + it("calls actionsClient.get with the experiment connector ID when the evaluator connector ID isn't provided", async () => { + const actionsClient = { + get: jest.fn().mockResolvedValue(null), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId: undefined, + experimentConnector, + langSmithApiKey: undefined, + logger, + }); + + expect(actionsClient.get).toHaveBeenCalledWith({ + id: experimentConnector.id, + throwIfSystemAction: false, + }); + }); + + it('falls back to the experiment connector when the evaluator connector is not found', async () => { + const actionsClient = { + get: jest.fn().mockResolvedValue(null), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey: undefined, + logger, + }); + + expect(ActionsClientLlm).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: experimentConnector.id, + }) + ); + }); + }); + + it('logs the expected connector names and types', async () => { + const actionsClient = { + get: jest.fn().mockResolvedValue(evaluatorConnector), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey: undefined, + logger, + }); + + expect(logger.info).toHaveBeenCalledWith( + `The ${evaluatorConnector.name} (openai) connector will judge output from the ${experimentConnector.name} (gemini) connector` + ); + }); + + it('creates a new ActionsClientLlm instance with the expected traceOptions', async () => { + const actionsClient = { + get: jest.fn().mockResolvedValue(evaluatorConnector), + } as unknown as ActionsClient; + + await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey: 'test-api-key', + logger, + }); + + expect(ActionsClientLlm).toHaveBeenCalledWith( + expect.objectContaining({ + traceOptions: { + projectName: 'evaluators', + tracers: expect.any(Array), + }, + }) + ); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts new file mode 100644 index 0000000000000..236def9670d07 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_evaluator_llm/index.ts @@ -0,0 +1,65 @@ +/* + * 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 { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import { Logger } from '@kbn/core/server'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; + +import { getLlmType } from '../../../../../routes/utils'; + +export const getEvaluatorLlm = async ({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector, + langSmithApiKey, + logger, +}: { + actionsClient: PublicMethodsOf; + connectorTimeout: number; + evaluatorConnectorId: string | undefined; + experimentConnector: Connector; + langSmithApiKey: string | undefined; + logger: Logger; +}): Promise => { + const evaluatorConnector = + (await actionsClient.get({ + id: evaluatorConnectorId ?? experimentConnector.id, // fallback to the experiment connector if the evaluator connector is not found: + throwIfSystemAction: false, + })) ?? experimentConnector; + + const evaluatorLlmType = getLlmType(evaluatorConnector.actionTypeId); + const experimentLlmType = getLlmType(experimentConnector.actionTypeId); + + logger.info( + `The ${evaluatorConnector.name} (${evaluatorLlmType}) connector will judge output from the ${experimentConnector.name} (${experimentLlmType}) connector` + ); + + const traceOptions = { + projectName: 'evaluators', + tracers: [ + ...getLangSmithTracer({ + apiKey: langSmithApiKey, + projectName: 'evaluators', + logger, + }), + ], + }; + + return new ActionsClientLlm({ + actionsClient, + connectorId: evaluatorConnector.id, + llmType: evaluatorLlmType, + logger, + temperature: 0, // zero temperature for evaluation + timeout: connectorTimeout, + traceOptions, + }); +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts new file mode 100644 index 0000000000000..47f36bc6fb0e7 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { omit } from 'lodash/fp'; +import type { Example } from 'langsmith/schemas'; + +import { getGraphInputOverrides } from '.'; +import { exampleWithReplacements } from '../../__mocks__/mock_examples'; + +const exampleWithAlerts: Example = { + ...exampleWithReplacements, + outputs: { + ...exampleWithReplacements.outputs, + anonymizedAlerts: [ + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + ], + }, +}; + +const exampleWithNoReplacements: Example = { + ...exampleWithReplacements, + outputs: { + ...omit('replacements', exampleWithReplacements.outputs), + }, +}; + +describe('getGraphInputOverrides', () => { + describe('root-level outputs overrides', () => { + it('returns the anonymizedAlerts from the root level of the outputs when present', () => { + const overrides = getGraphInputOverrides(exampleWithAlerts.outputs); + + expect(overrides.anonymizedAlerts).toEqual(exampleWithAlerts.outputs?.anonymizedAlerts); + }); + + it('does NOT populate the anonymizedAlerts key when it does NOT exist in the outputs', () => { + const overrides = getGraphInputOverrides(exampleWithReplacements.outputs); + + expect(overrides).not.toHaveProperty('anonymizedAlerts'); + }); + + it('returns replacements from the root level of the outputs when present', () => { + const overrides = getGraphInputOverrides(exampleWithReplacements.outputs); + + expect(overrides.replacements).toEqual(exampleWithReplacements.outputs?.replacements); + }); + + it('does NOT populate the replacements key when it does NOT exist in the outputs', () => { + const overrides = getGraphInputOverrides(exampleWithNoReplacements.outputs); + + expect(overrides).not.toHaveProperty('replacements'); + }); + + it('removes unknown properties', () => { + const withUnknownProperties = { + ...exampleWithReplacements, + outputs: { + ...exampleWithReplacements.outputs, + unknownProperty: 'unknown', + }, + }; + + const overrides = getGraphInputOverrides(withUnknownProperties.outputs); + + expect(overrides).not.toHaveProperty('unknownProperty'); + }); + }); + + describe('overrides', () => { + it('returns all overrides at the root level', () => { + const exampleWithOverrides = { + ...exampleWithAlerts, + outputs: { + ...exampleWithAlerts.outputs, + overrides: { + attackDiscoveries: [], + attackDiscoveryPrompt: 'prompt', + anonymizedAlerts: [], + combinedGenerations: 'combinedGenerations', + combinedRefinements: 'combinedRefinements', + errors: ['error'], + generationAttempts: 1, + generations: ['generation'], + hallucinationFailures: 2, + maxGenerationAttempts: 3, + maxHallucinationFailures: 4, + maxRepeatedGenerations: 5, + refinements: ['refinement'], + refinePrompt: 'refinePrompt', + replacements: {}, + unrefinedResults: [], + }, + }, + }; + + const overrides = getGraphInputOverrides(exampleWithOverrides.outputs); + + expect(overrides).toEqual({ + ...exampleWithOverrides.outputs?.overrides, + }); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts new file mode 100644 index 0000000000000..232218f4386f8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_graph_input_overrides/index.ts @@ -0,0 +1,29 @@ +/* + * 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 { pick } from 'lodash/fp'; + +import { ExampleInputWithOverrides } from '../../example_input'; +import { GraphState } from '../../../graphs/default_attack_discovery_graph/types'; + +/** + * Parses input from an LangSmith dataset example to get the graph input overrides + */ +export const getGraphInputOverrides = (outputs: unknown): Partial => { + const validatedInput = ExampleInputWithOverrides.safeParse(outputs).data ?? {}; // safeParse removes unknown properties + + const { overrides } = validatedInput; + + // return all overrides at the root level: + return { + // pick extracts just the anonymizedAlerts and replacements from the root level of the input, + // and only adds the anonymizedAlerts key if it exists in the input + ...pick('anonymizedAlerts', validatedInput), + ...pick('replacements', validatedInput), + ...overrides, // bring all other overrides to the root level + }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts new file mode 100644 index 0000000000000..40b0f080fe54a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/index.ts @@ -0,0 +1,122 @@ +/* + * 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 { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { Logger } from '@kbn/core/server'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import { asyncForEach } from '@kbn/std'; +import { PublicMethodsOf } from '@kbn/utility-types'; + +import { DEFAULT_EVAL_ANONYMIZATION_FIELDS } from './constants'; +import { AttackDiscoveryGraphMetadata } from '../../langchain/graphs'; +import { DefaultAttackDiscoveryGraph } from '../graphs/default_attack_discovery_graph'; +import { getLlmType } from '../../../routes/utils'; +import { runEvaluations } from './run_evaluations'; + +export const evaluateAttackDiscovery = async ({ + actionsClient, + attackDiscoveryGraphs, + alertsIndexPattern, + anonymizationFields = DEFAULT_EVAL_ANONYMIZATION_FIELDS, // determines which fields are included in the alerts + connectors, + connectorTimeout, + datasetName, + esClient, + evaluationId, + evaluatorConnectorId, + langSmithApiKey, + langSmithProject, + logger, + runName, + size, +}: { + actionsClient: PublicMethodsOf; + attackDiscoveryGraphs: AttackDiscoveryGraphMetadata[]; + alertsIndexPattern: string; + anonymizationFields?: AnonymizationFieldResponse[]; + connectors: Connector[]; + connectorTimeout: number; + datasetName: string; + esClient: ElasticsearchClient; + evaluationId: string; + evaluatorConnectorId: string | undefined; + langSmithApiKey: string | undefined; + langSmithProject: string | undefined; + logger: Logger; + runName: string; + size: number; +}): Promise => { + await asyncForEach(attackDiscoveryGraphs, async ({ getDefaultAttackDiscoveryGraph }) => { + // create a graph for every connector: + const graphs: Array<{ + connector: Connector; + graph: DefaultAttackDiscoveryGraph; + llmType: string | undefined; + name: string; + traceOptions: { + projectName: string | undefined; + tracers: LangChainTracer[]; + }; + }> = connectors.map((connector) => { + const llmType = getLlmType(connector.actionTypeId); + + const traceOptions = { + projectName: langSmithProject, + tracers: [ + ...getLangSmithTracer({ + apiKey: langSmithApiKey, + projectName: langSmithProject, + logger, + }), + ], + }; + + const llm = new ActionsClientLlm({ + actionsClient, + connectorId: connector.id, + llmType, + logger, + temperature: 0, // zero temperature for attack discovery, because we want structured JSON output + timeout: connectorTimeout, + traceOptions, + }); + + const graph = getDefaultAttackDiscoveryGraph({ + alertsIndexPattern, + anonymizationFields, + esClient, + llm, + logger, + size, + }); + + return { + connector, + graph, + llmType, + name: `${runName} - ${connector.name} - ${evaluationId} - Attack discovery`, + traceOptions, + }; + }); + + // run the evaluations for each graph: + await runEvaluations({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + datasetName, + graphs, + langSmithApiKey, + logger, + }); + }); +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts new file mode 100644 index 0000000000000..19eb99d57c84c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/run_evaluations/index.ts @@ -0,0 +1,113 @@ +/* + * 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 { ActionsClient } from '@kbn/actions-plugin/server'; +import type { Connector } from '@kbn/actions-plugin/server/application/connector/types'; +import { Logger } from '@kbn/core/server'; +import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; +import { asyncForEach } from '@kbn/std'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { Client } from 'langsmith'; +import { evaluate } from 'langsmith/evaluation'; + +import { getEvaluatorLlm } from '../helpers/get_evaluator_llm'; +import { getCustomEvaluator } from '../helpers/get_custom_evaluator'; +import { getDefaultPromptTemplate } from '../helpers/get_custom_evaluator/get_default_prompt_template'; +import { getGraphInputOverrides } from '../helpers/get_graph_input_overrides'; +import { DefaultAttackDiscoveryGraph } from '../../graphs/default_attack_discovery_graph'; +import { GraphState } from '../../graphs/default_attack_discovery_graph/types'; + +/** + * Runs an evaluation for each graph so they show up separately (resulting in + * each dataset run grouped by connector) + */ +export const runEvaluations = async ({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + datasetName, + graphs, + langSmithApiKey, + logger, +}: { + actionsClient: PublicMethodsOf; + connectorTimeout: number; + evaluatorConnectorId: string | undefined; + datasetName: string; + graphs: Array<{ + connector: Connector; + graph: DefaultAttackDiscoveryGraph; + llmType: string | undefined; + name: string; + traceOptions: { + projectName: string | undefined; + tracers: LangChainTracer[]; + }; + }>; + langSmithApiKey: string | undefined; + logger: Logger; +}): Promise => + asyncForEach(graphs, async ({ connector, graph, llmType, name, traceOptions }) => { + const subject = `connector "${connector.name}" (${llmType}), running experiment "${name}"`; + + try { + logger.info( + () => + `Evaluating ${subject} with dataset "${datasetName}" and evaluator "${evaluatorConnectorId}"` + ); + + const predict = async (input: unknown): Promise => { + logger.debug(() => `Raw example Input for ${subject}":\n ${input}`); + + // The example `Input` may have overrides for the initial state of the graph: + const overrides = getGraphInputOverrides(input); + + return graph.invoke( + { + ...overrides, + }, + { + callbacks: [...(traceOptions.tracers ?? [])], + runName: name, + tags: ['evaluation', llmType ?? ''], + } + ); + }; + + const llm = await getEvaluatorLlm({ + actionsClient, + connectorTimeout, + evaluatorConnectorId, + experimentConnector: connector, + langSmithApiKey, + logger, + }); + + const customEvaluator = getCustomEvaluator({ + criteria: 'correctness', + key: 'attack_discovery_correctness', + llm, + template: getDefaultPromptTemplate(), + }); + + const evalOutput = await evaluate(predict, { + client: new Client({ apiKey: langSmithApiKey }), + data: datasetName ?? '', + evaluators: [customEvaluator], + experimentPrefix: name, + maxConcurrency: 5, // prevents rate limiting + }); + + logger.info(() => `Evaluation complete for ${subject}`); + + logger.debug( + () => `Evaluation output for ${subject}:\n ${JSON.stringify(evalOutput, null, 2)}` + ); + } catch (e) { + logger.error(`Error evaluating ${subject}: ${e}`); + } + }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts new file mode 100644 index 0000000000000..fb5df8f26d0c2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/constants.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +// LangGraph metadata +export const ATTACK_DISCOVERY_GRAPH_RUN_NAME = 'Attack discovery'; +export const ATTACK_DISCOVERY_TAG = 'attack-discovery'; + +// Limits +export const DEFAULT_MAX_GENERATION_ATTEMPTS = 10; +export const DEFAULT_MAX_HALLUCINATION_FAILURES = 5; +export const DEFAULT_MAX_REPEATED_GENERATIONS = 3; + +export const NodeType = { + GENERATE_NODE: 'generate', + REFINE_NODE: 'refine', + RETRIEVE_ANONYMIZED_ALERTS_NODE: 'retrieve_anonymized_alerts', +} as const; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts new file mode 100644 index 0000000000000..225c4a2b8935c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { getGenerateOrEndDecision } from '.'; + +describe('getGenerateOrEndDecision', () => { + it('returns "end" when hasZeroAlerts is true', () => { + const result = getGenerateOrEndDecision(true); + + expect(result).toEqual('end'); + }); + + it('returns "generate" when hasZeroAlerts is false', () => { + const result = getGenerateOrEndDecision(false); + + expect(result).toEqual('generate'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts new file mode 100644 index 0000000000000..b134b2f3a6118 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/helpers/get_generate_or_end_decision/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getGenerateOrEndDecision = (hasZeroAlerts: boolean): 'end' | 'generate' => + hasZeroAlerts ? 'end' : 'generate'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts new file mode 100644 index 0000000000000..06dd1529179fa --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggerMock } from '@kbn/logging-mocks'; + +import { getGenerateOrEndEdge } from '.'; +import type { GraphState } from '../../types'; + +const logger = loggerMock.create(); + +const graphState: GraphState = { + attackDiscoveries: null, + attackDiscoveryPrompt: 'prompt', + anonymizedAlerts: [ + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + ], + combinedGenerations: 'generations', + combinedRefinements: 'refinements', + errors: [], + generationAttempts: 0, + generations: [], + hallucinationFailures: 0, + maxGenerationAttempts: 10, + maxHallucinationFailures: 5, + maxRepeatedGenerations: 10, + refinements: [], + refinePrompt: 'refinePrompt', + replacements: {}, + unrefinedResults: null, +}; + +describe('getGenerateOrEndEdge', () => { + beforeEach(() => jest.clearAllMocks()); + + it("returns 'end' when there are zero alerts", () => { + const state: GraphState = { + ...graphState, + anonymizedAlerts: [], // <-- zero alerts + }; + + const edge = getGenerateOrEndEdge(logger); + const result = edge(state); + + expect(result).toEqual('end'); + }); + + it("returns 'generate' when there are alerts", () => { + const edge = getGenerateOrEndEdge(logger); + const result = edge(graphState); + + expect(result).toEqual('generate'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts new file mode 100644 index 0000000000000..5bfc4912298eb --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_end/index.ts @@ -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 type { Logger } from '@kbn/core/server'; + +import { getGenerateOrEndDecision } from './helpers/get_generate_or_end_decision'; +import { getHasZeroAlerts } from '../helpers/get_has_zero_alerts'; +import type { GraphState } from '../../types'; + +export const getGenerateOrEndEdge = (logger?: Logger) => { + const edge = (state: GraphState): 'end' | 'generate' => { + logger?.debug(() => '---GENERATE OR END---'); + const { anonymizedAlerts } = state; + + const hasZeroAlerts = getHasZeroAlerts(anonymizedAlerts); + + const decision = getGenerateOrEndDecision(hasZeroAlerts); + + logger?.debug( + () => `generatOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( + { + anonymizedAlerts: anonymizedAlerts.length, + hasZeroAlerts, + }, + null, + 2 + )} +\n---GENERATE OR END: ${decision}---` + ); + return decision; + }; + + return edge; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts new file mode 100644 index 0000000000000..42c63b18459ed --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { getGenerateOrRefineOrEndDecision } from '.'; + +describe('getGenerateOrRefineOrEndDecision', () => { + it("returns 'end' if getShouldEnd returns true", () => { + const result = getGenerateOrRefineOrEndDecision({ + hasUnrefinedResults: false, + hasZeroAlerts: true, + maxHallucinationFailuresReached: true, + maxRetriesReached: true, + }); + + expect(result).toEqual('end'); + }); + + it("returns 'refine' if hasUnrefinedResults is true and getShouldEnd returns false", () => { + const result = getGenerateOrRefineOrEndDecision({ + hasUnrefinedResults: true, + hasZeroAlerts: false, + maxHallucinationFailuresReached: false, + maxRetriesReached: false, + }); + + expect(result).toEqual('refine'); + }); + + it("returns 'generate' if hasUnrefinedResults is false and getShouldEnd returns false", () => { + const result = getGenerateOrRefineOrEndDecision({ + hasUnrefinedResults: false, + hasZeroAlerts: false, + maxHallucinationFailuresReached: false, + maxRetriesReached: false, + }); + + expect(result).toEqual('generate'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts new file mode 100644 index 0000000000000..b409f63f71a69 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_generate_or_refine_or_end_decision/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { getShouldEnd } from '../get_should_end'; + +export const getGenerateOrRefineOrEndDecision = ({ + hasUnrefinedResults, + hasZeroAlerts, + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + hasUnrefinedResults: boolean; + hasZeroAlerts: boolean; + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): 'end' | 'generate' | 'refine' => { + if (getShouldEnd({ hasZeroAlerts, maxHallucinationFailuresReached, maxRetriesReached })) { + return 'end'; + } else if (hasUnrefinedResults) { + return 'refine'; + } else { + return 'generate'; + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts new file mode 100644 index 0000000000000..82480a6ad6889 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { getShouldEnd } from '.'; + +describe('getShouldEnd', () => { + it('returns true if hasZeroAlerts is true', () => { + const result = getShouldEnd({ + hasZeroAlerts: true, // <-- true + maxHallucinationFailuresReached: false, + maxRetriesReached: false, + }); + + expect(result).toBe(true); + }); + + it('returns true if maxHallucinationFailuresReached is true', () => { + const result = getShouldEnd({ + hasZeroAlerts: false, + maxHallucinationFailuresReached: true, // <-- true + maxRetriesReached: false, + }); + + expect(result).toBe(true); + }); + + it('returns true if maxRetriesReached is true', () => { + const result = getShouldEnd({ + hasZeroAlerts: false, + maxHallucinationFailuresReached: false, + maxRetriesReached: true, // <-- true + }); + + expect(result).toBe(true); + }); + + it('returns false if all conditions are false', () => { + const result = getShouldEnd({ + hasZeroAlerts: false, + maxHallucinationFailuresReached: false, + maxRetriesReached: false, + }); + + expect(result).toBe(false); + }); + + it('returns true if all conditions are true', () => { + const result = getShouldEnd({ + hasZeroAlerts: true, + maxHallucinationFailuresReached: true, + maxRetriesReached: true, + }); + + expect(result).toBe(true); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts new file mode 100644 index 0000000000000..9724ba25886fa --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/helpers/get_should_end/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getShouldEnd = ({ + hasZeroAlerts, + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + hasZeroAlerts: boolean; + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): boolean => hasZeroAlerts || maxRetriesReached || maxHallucinationFailuresReached; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts new file mode 100644 index 0000000000000..585a1bc2dcac3 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.test.ts @@ -0,0 +1,118 @@ +/* + * 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 { loggerMock } from '@kbn/logging-mocks'; + +import { getGenerateOrRefineOrEndEdge } from '.'; +import type { GraphState } from '../../types'; + +const logger = loggerMock.create(); + +const graphState: GraphState = { + attackDiscoveries: null, + attackDiscoveryPrompt: 'prompt', + anonymizedAlerts: [ + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,e809ffc5e0c2e731c1f146e0f74250078136a87574534bf8e9ee55445894f7fc\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + { + metadata: {}, + pageContent: + '@timestamp,2024-10-10T21:01:24.148Z\n' + + '_id,c675d7eb6ee181d788b474117bae8d3ed4bdc2168605c330a93dd342534fb02b\n' + + 'host.name,e1cb3cf0-30f3-4f99-a9c8-518b955c6f90\n' + + 'user.name,039c15c5-3964-43e7-a891-42fe2ceeb9ff', + }, + ], + combinedGenerations: '', + combinedRefinements: '', + errors: [], + generationAttempts: 0, + generations: [], + hallucinationFailures: 0, + maxGenerationAttempts: 10, + maxHallucinationFailures: 5, + maxRepeatedGenerations: 3, + refinements: [], + refinePrompt: 'refinePrompt', + replacements: {}, + unrefinedResults: null, +}; + +describe('getGenerateOrRefineOrEndEdge', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns "end" when there are zero alerts', () => { + const withZeroAlerts: GraphState = { + ...graphState, + anonymizedAlerts: [], // <-- zero alerts + }; + + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(withZeroAlerts); + + expect(result).toEqual('end'); + }); + + it('returns "end" when max hallucination failures are reached', () => { + const withMaxHallucinationFailures: GraphState = { + ...graphState, + hallucinationFailures: 5, + }; + + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(withMaxHallucinationFailures); + + expect(result).toEqual('end'); + }); + + it('returns "end" when max retries are reached', () => { + const withMaxRetries: GraphState = { + ...graphState, + generationAttempts: 10, + }; + + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(withMaxRetries); + + expect(result).toEqual('end'); + }); + + it('returns refine when there are unrefined results', () => { + const withUnrefinedResults: GraphState = { + ...graphState, + unrefinedResults: [ + { + alertIds: [], + id: 'test-id', + detailsMarkdown: 'test-details', + entitySummaryMarkdown: 'test-summary', + summaryMarkdown: 'test-summary', + title: 'test-title', + timestamp: '2024-10-10T21:01:24.148Z', + }, + ], + }; + + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(withUnrefinedResults); + + expect(result).toEqual('refine'); + }); + + it('return generate when there are no unrefined results', () => { + const edge = getGenerateOrRefineOrEndEdge(logger); + const result = edge(graphState); + + expect(result).toEqual('generate'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts new file mode 100644 index 0000000000000..3368a04ec9204 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/generate_or_refine_or_end/index.ts @@ -0,0 +1,66 @@ +/* + * 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 { Logger } from '@kbn/core/server'; + +import { getGenerateOrRefineOrEndDecision } from './helpers/get_generate_or_refine_or_end_decision'; +import { getHasResults } from '../helpers/get_has_results'; +import { getHasZeroAlerts } from '../helpers/get_has_zero_alerts'; +import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; +import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; +import type { GraphState } from '../../types'; + +export const getGenerateOrRefineOrEndEdge = (logger?: Logger) => { + const edge = (state: GraphState): 'end' | 'generate' | 'refine' => { + logger?.debug(() => '---GENERATE OR REFINE OR END---'); + const { + anonymizedAlerts, + generationAttempts, + hallucinationFailures, + maxGenerationAttempts, + maxHallucinationFailures, + unrefinedResults, + } = state; + + const hasZeroAlerts = getHasZeroAlerts(anonymizedAlerts); + const hasUnrefinedResults = getHasResults(unrefinedResults); + const maxRetriesReached = getMaxRetriesReached({ generationAttempts, maxGenerationAttempts }); + const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ + hallucinationFailures, + maxHallucinationFailures, + }); + + const decision = getGenerateOrRefineOrEndDecision({ + hasUnrefinedResults, + hasZeroAlerts, + maxHallucinationFailuresReached, + maxRetriesReached, + }); + + logger?.debug( + () => + `generatOrRefineOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( + { + anonymizedAlerts: anonymizedAlerts.length, + generationAttempts, + hallucinationFailures, + hasUnrefinedResults, + hasZeroAlerts, + maxHallucinationFailuresReached, + maxRetriesReached, + unrefinedResults: unrefinedResults?.length ?? 0, + }, + null, + 2 + )} + \n---GENERATE OR REFINE OR END: ${decision}---` + ); + return decision; + }; + + return edge; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts new file mode 100644 index 0000000000000..413f01b74dece --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_results/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { AttackDiscovery } from '@kbn/elastic-assistant-common'; + +export const getHasResults = (attackDiscoveries: AttackDiscovery[] | null): boolean => + attackDiscoveries !== null; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts new file mode 100644 index 0000000000000..d768b363f101e --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/helpers/get_has_zero_alerts/index.ts @@ -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 { Document } from '@langchain/core/documents'; +import { isEmpty } from 'lodash/fp'; + +export const getHasZeroAlerts = (anonymizedAlerts: Document[]): boolean => + isEmpty(anonymizedAlerts); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts new file mode 100644 index 0000000000000..7168aa08aeef2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_refine_or_end_decision/index.ts @@ -0,0 +1,25 @@ +/* + * 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 { getShouldEnd } from '../get_should_end'; + +export const getRefineOrEndDecision = ({ + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + hasFinalResults: boolean; + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): 'refine' | 'end' => + getShouldEnd({ + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, + }) + ? 'end' + : 'refine'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts new file mode 100644 index 0000000000000..697f93dd3a02f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/helpers/get_should_end/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getShouldEnd = ({ + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + hasFinalResults: boolean; + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): boolean => hasFinalResults || maxRetriesReached || maxHallucinationFailuresReached; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts new file mode 100644 index 0000000000000..85140dceafdcb --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/refine_or_end/index.ts @@ -0,0 +1,61 @@ +/* + * 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 { Logger } from '@kbn/core/server'; + +import { getRefineOrEndDecision } from './helpers/get_refine_or_end_decision'; +import { getHasResults } from '../helpers/get_has_results'; +import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; +import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; +import type { GraphState } from '../../types'; + +export const getRefineOrEndEdge = (logger?: Logger) => { + const edge = (state: GraphState): 'end' | 'refine' => { + logger?.debug(() => '---REFINE OR END---'); + const { + attackDiscoveries, + generationAttempts, + hallucinationFailures, + maxGenerationAttempts, + maxHallucinationFailures, + } = state; + + const hasFinalResults = getHasResults(attackDiscoveries); + const maxRetriesReached = getMaxRetriesReached({ generationAttempts, maxGenerationAttempts }); + const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ + hallucinationFailures, + maxHallucinationFailures, + }); + + const decision = getRefineOrEndDecision({ + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, + }); + + logger?.debug( + () => + `refineOrEndEdge evaluated the following (derived) state:\n${JSON.stringify( + { + attackDiscoveries: attackDiscoveries?.length ?? 0, + generationAttempts, + hallucinationFailures, + hasFinalResults, + maxHallucinationFailuresReached, + maxRetriesReached, + }, + null, + 2 + )} + \n---REFINE OR END: ${decision}---` + ); + + return decision; + }; + + return edge; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts new file mode 100644 index 0000000000000..050ca17484185 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/get_retrieve_or_generate/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { Document } from '@langchain/core/documents'; + +export const getRetrieveOrGenerate = ( + anonymizedAlerts: Document[] +): 'retrieve_anonymized_alerts' | 'generate' => + anonymizedAlerts.length === 0 ? 'retrieve_anonymized_alerts' : 'generate'; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts new file mode 100644 index 0000000000000..ad0512497d07d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/edges/retrieve_anonymized_alerts_or_generate/index.ts @@ -0,0 +1,36 @@ +/* + * 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 { Logger } from '@kbn/core/server'; + +import { getRetrieveOrGenerate } from './get_retrieve_or_generate'; +import type { GraphState } from '../../types'; + +export const getRetrieveAnonymizedAlertsOrGenerateEdge = (logger?: Logger) => { + const edge = (state: GraphState): 'retrieve_anonymized_alerts' | 'generate' => { + logger?.debug(() => '---RETRIEVE ANONYMIZED ALERTS OR GENERATE---'); + const { anonymizedAlerts } = state; + + const decision = getRetrieveOrGenerate(anonymizedAlerts); + + logger?.debug( + () => + `retrieveAnonymizedAlertsOrGenerateEdge evaluated the following (derived) state:\n${JSON.stringify( + { + anonymizedAlerts: anonymizedAlerts.length, + }, + null, + 2 + )} + \n---RETRIEVE ANONYMIZED ALERTS OR GENERATE: ${decision}---` + ); + + return decision; + }; + + return edge; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts new file mode 100644 index 0000000000000..07985381afa73 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_hallucination_failures_reached/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getMaxHallucinationFailuresReached = ({ + hallucinationFailures, + maxHallucinationFailures, +}: { + hallucinationFailures: number; + maxHallucinationFailures: number; +}): boolean => hallucinationFailures >= maxHallucinationFailures; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts new file mode 100644 index 0000000000000..c1e36917b45cf --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/helpers/get_max_retries_reached/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getMaxRetriesReached = ({ + generationAttempts, + maxGenerationAttempts, +}: { + generationAttempts: number; + maxGenerationAttempts: number; +}): boolean => generationAttempts >= maxGenerationAttempts; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts new file mode 100644 index 0000000000000..b2c90636ef523 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts @@ -0,0 +1,122 @@ +/* + * 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 { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { Replacements } from '@kbn/elastic-assistant-common'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { ActionsClientLlm } from '@kbn/langchain/server'; +import type { CompiledStateGraph } from '@langchain/langgraph'; +import { END, START, StateGraph } from '@langchain/langgraph'; + +import { NodeType } from './constants'; +import { getGenerateOrEndEdge } from './edges/generate_or_end'; +import { getGenerateOrRefineOrEndEdge } from './edges/generate_or_refine_or_end'; +import { getRefineOrEndEdge } from './edges/refine_or_end'; +import { getRetrieveAnonymizedAlertsOrGenerateEdge } from './edges/retrieve_anonymized_alerts_or_generate'; +import { getDefaultGraphState } from './state'; +import { getGenerateNode } from './nodes/generate'; +import { getRefineNode } from './nodes/refine'; +import { getRetrieveAnonymizedAlertsNode } from './nodes/retriever'; +import type { GraphState } from './types'; + +export interface GetDefaultAttackDiscoveryGraphParams { + alertsIndexPattern?: string; + anonymizationFields: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + llm: ActionsClientLlm; + logger?: Logger; + onNewReplacements?: (replacements: Replacements) => void; + replacements?: Replacements; + size: number; +} + +export type DefaultAttackDiscoveryGraph = ReturnType; + +/** + * This function returns a compiled state graph that represents the default + * Attack discovery graph. + * + * Refer to the following diagram for this graph: + * x-pack/plugins/elastic_assistant/docs/img/default_attack_discovery_graph.png + */ +export const getDefaultAttackDiscoveryGraph = ({ + alertsIndexPattern, + anonymizationFields, + esClient, + llm, + logger, + onNewReplacements, + replacements, + size, +}: GetDefaultAttackDiscoveryGraphParams): CompiledStateGraph< + GraphState, + Partial, + 'generate' | 'refine' | 'retrieve_anonymized_alerts' | '__start__' +> => { + try { + const graphState = getDefaultGraphState(); + + // get nodes: + const retrieveAnonymizedAlertsNode = getRetrieveAnonymizedAlertsNode({ + alertsIndexPattern, + anonymizationFields, + esClient, + logger, + onNewReplacements, + replacements, + size, + }); + + const generateNode = getGenerateNode({ + llm, + logger, + }); + + const refineNode = getRefineNode({ + llm, + logger, + }); + + // get edges: + const generateOrEndEdge = getGenerateOrEndEdge(logger); + + const generatOrRefineOrEndEdge = getGenerateOrRefineOrEndEdge(logger); + + const refineOrEndEdge = getRefineOrEndEdge(logger); + + const retrieveAnonymizedAlertsOrGenerateEdge = + getRetrieveAnonymizedAlertsOrGenerateEdge(logger); + + // create the graph: + const graph = new StateGraph({ channels: graphState }) + .addNode(NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, retrieveAnonymizedAlertsNode) + .addNode(NodeType.GENERATE_NODE, generateNode) + .addNode(NodeType.REFINE_NODE, refineNode) + .addConditionalEdges(START, retrieveAnonymizedAlertsOrGenerateEdge, { + generate: NodeType.GENERATE_NODE, + retrieve_anonymized_alerts: NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, + }) + .addConditionalEdges(NodeType.RETRIEVE_ANONYMIZED_ALERTS_NODE, generateOrEndEdge, { + end: END, + generate: NodeType.GENERATE_NODE, + }) + .addConditionalEdges(NodeType.GENERATE_NODE, generatOrRefineOrEndEdge, { + end: END, + generate: NodeType.GENERATE_NODE, + refine: NodeType.REFINE_NODE, + }) + .addConditionalEdges(NodeType.REFINE_NODE, refineOrEndEdge, { + end: END, + refine: NodeType.REFINE_NODE, + }); + + // compile the graph: + return graph.compile(); + } catch (e) { + throw new Error(`Unable to compile AttackDiscoveryGraph\n${e}`); + } +}; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/mock/mock_anonymization_fields.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields.ts similarity index 100% rename from x-pack/plugins/security_solution/server/assistant/tools/mock/mock_anonymization_fields.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields.ts diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts new file mode 100644 index 0000000000000..ed5549acc586a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_empty_open_and_acknowledged_alerts_qery_results.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockEmptyOpenAndAcknowledgedAlertsQueryResults = { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 0, + relation: 'eq', + }, + max_score: null, + hits: [], + }, +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts new file mode 100644 index 0000000000000..3f22f787f54f8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_open_and_acknowledged_alerts_query_results.ts @@ -0,0 +1,1396 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockOpenAndAcknowledgedAlertsQueryResults = { + took: 13, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 31, + relation: 'eq', + }, + max_score: null, + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['/Users/james/unix1'], + 'process.hash.md5': ['85caafe3d324e3287b85348fa2fae492'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': [ + '/Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!!', + ], + 'process.parent.name': ['unix1'], + 'user.name': ['james'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231', + ], + 'process.code_signature.signing_id': ['nans-55554944e5f232edcf023cf68e8e5dac81584f78'], + 'process.pid': [1227], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': ['/Users/james/unix1'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': [''], + 'process.parent.executable': ['/Users/james/unix1'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['unix1'], + 'process.args': [ + '/Users/james/unix1', + '/Users/james/library/Keychains/login.keychain-db', + 'TempTemp1234!!', + ], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [3], + 'process.name': ['unix1'], + 'process.parent.args': [ + '/Users/james/unix1', + '/Users/james/library/Keychains/login.keychain-db', + 'TempTemp1234!!', + ], + '@timestamp': ['2024-05-07T12:48:45.032Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': [ + '/Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!!', + ], + 'host.risk.calculated_level': ['High'], + _id: ['b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560'], + 'process.hash.sha1': ['4ca549355736e4af6434efc4ec9a044ceb2ae3c3'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:39.368Z'], + }, + sort: [99, 1715086125032], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['/Users/james/unix1'], + 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.parent.name': ['My Go Application.app'], + 'user.name': ['james'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', + ], + 'process.code_signature.signing_id': ['a.out'], + 'process.pid': [1220], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': [''], + 'process.parent.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['unix1'], + 'process.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['My Go Application.app'], + 'process.parent.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + '@timestamp': ['2024-05-07T12:48:45.030Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'host.risk.calculated_level': ['High'], + _id: ['0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367'], + 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:38.061Z'], + }, + sort: [99, 1715086125030], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['/Users/james/unix1'], + 'process.hash.md5': ['85caafe3d324e3287b85348fa2fae492'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.parent.name': ['My Go Application.app'], + 'user.name': ['james'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231', + ], + 'process.code_signature.signing_id': ['nans-55554944e5f232edcf023cf68e8e5dac81584f78'], + 'process.pid': [1220], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': ['/Users/james/unix1'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': [''], + 'process.parent.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['unix1'], + 'process.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['unix1'], + 'process.parent.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + '@timestamp': ['2024-05-07T12:48:45.029Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'host.risk.calculated_level': ['High'], + _id: ['600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a'], + 'process.hash.sha1': ['4ca549355736e4af6434efc4ec9a044ceb2ae3c3'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:37.881Z'], + }, + sort: [99, 1715086125029], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['/Users/james/unix1'], + 'process.hash.md5': ['3f19892ab44eb9bc7bc03f438944301e'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.parent.name': ['My Go Application.app'], + 'user.name': ['james'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + 'f80234ff6fed2c62d23f37443f2412fbe806711b6add2ac126e03e282082c8f5', + ], + 'process.code_signature.signing_id': ['com.apple.chmod'], + 'process.pid': [1219], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Software Signing'], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['0b18d6880dc9670ab2b955914598c96fc3d0097dc40ea61157b8c79e75edf231'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': ['/bin/chmod'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.parent.code_signature.subject_name': [''], + 'process.parent.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['unix1'], + 'process.args': ['chmod', '777', '/Users/james/unix1'], + 'process.code_signature.status': ['No error.'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['chmod'], + 'process.parent.args': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + '@timestamp': ['2024-05-07T12:48:45.028Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['chmod 777 /Users/james/unix1'], + 'host.risk.calculated_level': ['High'], + _id: ['e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c'], + 'process.hash.sha1': ['217490d4f51717aa3b301abec96be08602370d2d'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:37.869Z'], + }, + sort: [99, 1715086125028], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['643dddff1a57cbf70594854b44eb1a1d'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [73.02488], + 'rule.reference': [ + 'https://github.com/EmpireProject/EmPyre/blob/master/lib/modules/collection/osx/prompt.py', + 'https://ss64.com/osx/osascript.html', + ], + 'process.parent.name': ['My Go Application.app'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + 'bab17feba710b469e5d96820f0cb7ed511d983e5817f374ec3cb46462ac5b794', + ], + 'process.pid': [1206], + 'process.code_signature.exists': [true], + 'process.code_signature.subject_name': ['Software Signing'], + 'host.os.version': ['13.4'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.72442], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': [ + 'Malicious Behavior Detection Alert: Potential Credentials Phishing via OSASCRIPT', + ], + 'host.name': ['SRVMAC08'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'group.name': ['staff'], + 'kibana.alert.workflow_status': ['open'], + 'rule.name': ['Potential Credentials Phishing via OSASCRIPT'], + 'threat.tactic.id': ['TA0006'], + 'threat.tactic.name': ['Credential Access'], + 'threat.technique.id': ['T1056'], + 'process.parent.args_count': [0], + 'threat.technique.subtechnique.reference': [ + 'https://attack.mitre.org/techniques/T1056/002/', + ], + 'process.name': ['osascript'], + 'threat.technique.subtechnique.name': ['GUI Input Capture'], + 'process.parent.code_signature.trusted': [false], + _id: ['2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f'], + 'threat.technique.name': ['Input Capture'], + 'group.id': ['20'], + 'threat.tactic.reference': ['https://attack.mitre.org/tactics/TA0006/'], + 'user.name': ['james'], + 'threat.framework': ['MITRE ATT&CK'], + 'process.code_signature.signing_id': ['com.apple.osascript'], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': [ + 'code failed to satisfy specified code requirement(s)', + ], + 'event.module': ['endpoint'], + 'process.executable': ['/usr/bin/osascript'], + 'process.parent.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.args': [ + 'osascript', + '-e', + 'display dialog "MacOS wants to access System Preferences\n\t\t\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬', + ], + 'process.code_signature.status': ['No error.'], + message: [ + 'Malicious Behavior Detection Alert: Potential Credentials Phishing via OSASCRIPT', + ], + '@timestamp': ['2024-05-07T12:48:45.027Z'], + 'threat.technique.subtechnique.id': ['T1056.002'], + 'threat.technique.reference': ['https://attack.mitre.org/techniques/T1056/'], + 'process.command_line': [ + 'osascript -e display dialog "MacOS wants to access System Preferences\n\t\t\nPlease enter your password." with title "System Preferences" with icon file "System:Library:CoreServices:CoreTypes.bundle:Contents:Resources:ToolbarAdvanced.icns" default answer "" giving up after 30 with hidden answer ¬', + ], + 'host.risk.calculated_level': ['High'], + 'process.hash.sha1': ['0568baae15c752208ae56d8f9c737976d6de2e3a'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:09.909Z'], + }, + sort: [99, 1715086125027], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '2a9f7602de8656d30dda0ddcf79e78037ac2929780e13d5b2047b3bedc40bb69', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['/sbin/launchd'], + 'process.parent.name': ['launchd'], + 'user.name': ['root'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', + ], + 'process.code_signature.signing_id': ['a.out'], + 'process.pid': [1200], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['No error.'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.491455], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/6D63F08A-011C-4511-8556-EAEF9AFD6340/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': ['Software Signing'], + 'process.parent.executable': ['/sbin/launchd'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['My Go Application.app'], + 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['My Go Application.app'], + 'process.parent.args': ['/sbin/launchd'], + '@timestamp': ['2024-05-07T12:48:45.023Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', + ], + 'host.risk.calculated_level': ['High'], + _id: ['2a9f7602de8656d30dda0ddcf79e78037ac2929780e13d5b2047b3bedc40bb69'], + 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:28:06.888Z'], + }, + sort: [99, 1715086125023], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '4615c3a90e8057ae5cc9b358bbbf4298e346277a2f068dda052b0b43ef6d5bbd', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/3C4D44B9-4838-4613-BACC-BD00A9CE4025/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['/sbin/launchd'], + 'process.parent.name': ['launchd'], + 'user.name': ['root'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', + ], + 'process.code_signature.signing_id': ['a.out'], + 'process.pid': [1169], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['No error.'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.491455], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/3C4D44B9-4838-4613-BACC-BD00A9CE4025/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': ['Software Signing'], + 'process.parent.executable': ['/sbin/launchd'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['My Go Application.app'], + 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['My Go Application.app'], + 'process.parent.args': ['/sbin/launchd'], + '@timestamp': ['2024-05-07T12:48:45.022Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', + ], + 'host.risk.calculated_level': ['High'], + _id: ['4615c3a90e8057ae5cc9b358bbbf4298e346277a2f068dda052b0b43ef6d5bbd'], + 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:27:47.362Z'], + }, + sort: [99, 1715086125022], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '449322a72d3f19efbdf983935a1bdd21ebd6b9c761ce31e8b252003017d7e5db', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/37D933EC-334D-410A-A741-0F730D6AE3FD/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'process.hash.md5': ['e62bdd3eaf2be436fca2e67b7eede603'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['/sbin/launchd'], + 'process.parent.name': ['launchd'], + 'user.name': ['root'], + 'user.risk.calculated_level': ['Moderate'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097', + ], + 'process.code_signature.signing_id': ['a.out'], + 'process.pid': [1123], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['No error.'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': [''], + 'host.os.version': ['13.4'], + 'file.hash.sha256': ['2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [66.491455], + 'host.os.name': ['macOS'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVMAC08'], + 'process.executable': [ + '/private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/37D933EC-334D-410A-A741-0F730D6AE3FD/d/Setup.app/Contents/MacOS/My Go Application.app', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.parent.code_signature.subject_name': ['Software Signing'], + 'process.parent.executable': ['/sbin/launchd'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['My Go Application.app'], + 'process.args': ['xpcproxy', 'application.Appify by Machine Box.My Go Application.20.23'], + 'process.code_signature.status': ['code failed to satisfy specified code requirement(s)'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['My Go Application.app'], + 'process.parent.args': ['/sbin/launchd'], + '@timestamp': ['2024-05-07T12:48:45.020Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + 'xpcproxy application.Appify by Machine Box.My Go Application.20.23', + ], + 'host.risk.calculated_level': ['High'], + _id: ['449322a72d3f19efbdf983935a1bdd21ebd6b9c761ce31e8b252003017d7e5db'], + 'process.hash.sha1': ['58a3bddbc7c45193ecbefa22ad0496b60a29dff2'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-06-19T00:25:24.716Z'], + }, + sort: [99, 1715086125020], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'kibana.alert.workflow_status': ['open'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Memory Threat Detection Alert: Shellcode Injection'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:45.017Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + _id: ['f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:22.051Z'], + }, + sort: [99, 1715086125017], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['C:\\Windows\\mpsvc.dll'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection', 'library'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['8dd620d9aeb35960bb766458c8890ede987c33d239cf730f93fe49d90ae759dd'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['mpsvc.dll'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:45.008Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + _id: ['aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:18.093Z'], + }, + sort: [99, 1715086125008], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'dd9e4ea23961ccfdb7a9c760ee6bedd19a013beac3b0d38227e7ae77ba4ce515', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['C:\\Windows\\mpsvc.dll'], + 'process.hash.md5': ['561cffbaba71a6e8cc1cdceda990ead4'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': ['C:\\Windows\\Explorer.EXE'], + 'process.parent.name': ['explorer.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + ], + 'process.pid': [1008], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['trusted'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['8dd620d9aeb35960bb766458c8890ede987c33d239cf730f93fe49d90ae759dd'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['Microsoft Windows'], + 'process.parent.executable': ['C:\\Windows\\explorer.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['mpsvc.dll'], + 'process.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.code_signature.status': ['errorExpired'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], + 'process.parent.args': ['C:\\Windows\\Explorer.EXE'], + '@timestamp': ['2024-05-07T12:48:45.007Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'host.risk.calculated_level': ['High'], + _id: ['dd9e4ea23961ccfdb7a9c760ee6bedd19a013beac3b0d38227e7ae77ba4ce515'], + 'process.hash.sha1': ['5162f14d75e96edb914d1756349d6e11583db0b0'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:17.887Z'], + }, + sort: [99, 1715086125007], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'f30d55e503b1d848b34ee57741b203d8052360dd873ea34802f3fa7a9ef34d0a', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.hash.md5': ['561cffbaba71a6e8cc1cdceda990ead4'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': ['C:\\Windows\\Explorer.EXE'], + 'process.parent.name': ['explorer.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + ], + 'process.pid': [1008], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['trusted'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [false], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['Microsoft Windows'], + 'process.parent.executable': ['C:\\Windows\\explorer.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], + 'process.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.code_signature.status': ['errorExpired'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], + 'process.parent.args': ['C:\\Windows\\Explorer.EXE'], + '@timestamp': ['2024-05-07T12:48:45.006Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'host.risk.calculated_level': ['High'], + _id: ['f30d55e503b1d848b34ee57741b203d8052360dd873ea34802f3fa7a9ef34d0a'], + 'process.hash.sha1': ['5162f14d75e96edb914d1756349d6e11583db0b0'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:17.544Z'], + }, + sort: [99, 1715086125006], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '6f8cd5e8021dbb64598f2b7ec56bee21fd00d1e62d4e08905f86bf234873ee66', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.hash.md5': ['f070b5cf25febb9a88a168efd87c6112'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [''], + 'process.parent.name': ['userinit.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '567be4d1e15f4ff96d92e7d28e191076f5813f50be96bf4c3916e4ecf53f66cd', + ], + 'process.pid': [6228], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['trusted'], + 'process.pe.original_file_name': ['EXPLORER.EXE'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Windows'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\explorer.exe'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['Microsoft Windows'], + 'process.parent.executable': ['C:\\Windows\\System32\\userinit.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe'], + 'process.args': ['C:\\Windows\\Explorer.EXE'], + 'process.code_signature.status': ['trusted'], + message: ['Malware Detection Alert'], + 'process.name': ['explorer.exe'], + '@timestamp': ['2024-05-07T12:48:45.004Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': ['C:\\Windows\\Explorer.EXE'], + 'host.risk.calculated_level': ['High'], + _id: ['6f8cd5e8021dbb64598f2b7ec56bee21fd00d1e62d4e08905f86bf234873ee66'], + 'process.hash.sha1': ['94518c310478e494082418ed295466f5aea26eea'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:37:18.152Z'], + }, + sort: [99, 1715086125004], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'ce110da958fe0cf0c07599a21c68d90a64c93b7607aa27970a614c7f49598316', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e', + ], + 'process.hash.md5': ['f070b5cf25febb9a88a168efd87c6112'], + 'event.category': ['malware', 'intrusion_detection', 'file'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [''], + 'process.parent.name': ['userinit.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '567be4d1e15f4ff96d92e7d28e191076f5813f50be96bf4c3916e4ecf53f66cd', + ], + 'process.pid': [6228], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['trusted'], + 'process.pe.original_file_name': ['EXPLORER.EXE'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Windows'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\explorer.exe'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['Microsoft Windows'], + 'process.parent.executable': ['C:\\Windows\\System32\\userinit.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e'], + 'process.args': ['C:\\Windows\\Explorer.EXE'], + 'process.code_signature.status': ['trusted'], + message: ['Malware Detection Alert'], + 'process.name': ['explorer.exe'], + '@timestamp': ['2024-05-07T12:48:45.001Z'], + 'process.parent.code_signature.trusted': [true], + 'process.command_line': ['C:\\Windows\\Explorer.EXE'], + 'host.risk.calculated_level': ['High'], + _id: ['ce110da958fe0cf0c07599a21c68d90a64c93b7607aa27970a614c7f49598316'], + 'process.hash.sha1': ['94518c310478e494082418ed295466f5aea26eea'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:36:43.813Z'], + }, + sort: [99, 1715086125001], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '0866787b0027b4d908767ac16e35a1da00970c83632ba85be65f2ad371132b4f', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection', 'process', 'file'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Ransomware Detection Alert'], + 'host.name': ['SRVWIN02'], + 'Ransomware.files.data': [ + '2D002D002D003D003D003D0020005700', + '2D002D002D003D003D003D0020005700', + '2D002D002D003D003D003D0020005700', + ], + 'process.code_signature.trusted': [true], + 'Ransomware.files.metrics': ['CANARY_ACTIVITY'], + 'kibana.alert.workflow_status': ['open'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'Ransomware.files.score': [0, 0, 0], + 'process.parent.code_signature.trusted': [false], + _id: ['0866787b0027b4d908767ac16e35a1da00970c83632ba85be65f2ad371132b4f'], + 'Ransomware.version': ['1.6.0'], + 'user.name': ['Administrator'], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'Ransomware.files.operation': ['creation', 'creation', 'creation'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.Ext.token.integrity_level_name': ['high'], + 'Ransomware.files.path': [ + 'c:\\hd3vuk19y-readme.txt', + 'c:\\$winreagent\\hd3vuk19y-readme.txt', + 'c:\\aaantiransomelastic-do-not-touch-dab6d40c-a6a1-442c-adc4-9d57a47e58d7\\hd3vuk19y-readme.txt', + ], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'Ransomware.files.entropy': [3.629971457026797, 3.629971457026797, 3.629971457026797], + 'Ransomware.feature': ['canary'], + 'Ransomware.files.extension': ['txt', 'txt', 'txt'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Ransomware Detection Alert'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:45.000Z'], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:22.964Z'], + }, + sort: [99, 1715086125000], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'b0fdf96721e361e1137d49a67e26d92f96b146392d7f44322bddc3d660abaef1', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'kibana.alert.workflow_status': ['open'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Memory Threat Detection Alert: Shellcode Injection'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:44.996Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + _id: ['b0fdf96721e361e1137d49a67e26d92f96b146392d7f44322bddc3d660abaef1'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:22.174Z'], + }, + sort: [99, 1715086124996], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '7b4f49f21cf141e67856d3207fb4ea069c8035b41f0ea501970694cf8bd43cbe', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Memory Threat Detection Alert: Shellcode Injection'], + 'host.name': ['SRVWIN02'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'kibana.alert.workflow_status': ['open'], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Memory Threat Detection Alert: Shellcode Injection'], + 'process.parent.args_count': [1], + 'process.name': ['MsMpEng.exe'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:44.986Z'], + 'process.parent.code_signature.trusted': [false], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + _id: ['7b4f49f21cf141e67856d3207fb4ea069c8035b41f0ea501970694cf8bd43cbe'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:22.066Z'], + }, + sort: [99, 1715086124986], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'ea81d79104cbd442236b5bcdb7a3331de897aa4ce1523e622068038d048d0a9e', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['8cc83221870dd07144e63df594c391d9'], + 'event.category': ['malware', 'intrusion_detection', 'process'], + 'host.risk.calculated_score_norm': [75.62723], + 'process.parent.command_line': [ + '"C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe" ', + ], + 'process.parent.name': [ + 'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '33bc14d231a4afaa18f06513766d5f69d8b88f1e697cd127d24fb4b72ad44c7a', + ], + 'process.Ext.memory_region.malware_signature.primary.matches': [ + 'WVmF9nQli1UIg2YEAIk+iwoLSgQ=', + 'dQxy0zPAQF9eW4vlXcMzwOv1VYvsgw==', + 'DIsEsIN4BAV1HP9wCP9wDP91DP8=', + '+4tF/FCLCP9RCF6Lx19bi+Vdw1U=', + 'vAAAADPSi030i/GLRfAPpMEBwe4f', + 'VIvO99GLwiNN3PfQM030I8czReiJ', + 'DIlGDIXAdSozwOtsi0YIhcB0Yms=', + ], + 'process.pid': [8708], + 'process.code_signature.exists': [true], + 'process.code_signature.subject_name': ['Microsoft Corporation'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': [ + 'Memory Threat Detection Alert: Windows.Ransomware.Sodinokibi', + ], + 'host.name': ['SRVWIN02'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'kibana.alert.workflow_status': ['open'], + 'rule.name': ['Windows.Ransomware.Sodinokibi'], + 'process.parent.args_count': [1], + 'process.Ext.memory_region.bytes_compressed_present': [false], + 'process.name': ['MsMpEng.exe'], + 'process.parent.code_signature.trusted': [false], + _id: ['ea81d79104cbd442236b5bcdb7a3331de897aa4ce1523e622068038d048d0a9e'], + 'user.name': ['Administrator'], + 'process.parent.code_signature.exists': [true], + 'process.parent.code_signature.status': ['errorExpired'], + 'process.pe.original_file_name': ['MsMpEng.exe'], + 'event.module': ['endpoint'], + 'process.Ext.memory_region.malware_signature.all_names': [ + 'Windows.Ransomware.Sodinokibi', + ], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\MsMpEng.exe'], + 'process.Ext.memory_region.malware_signature.primary.signature.name': [ + 'Windows.Ransomware.Sodinokibi', + ], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.code_signature.subject_name': ['PB03 TRANSPORT LTD.'], + 'process.parent.executable': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + 'process.args': ['C:\\Windows\\MsMpEng.exe'], + 'process.code_signature.status': ['trusted'], + message: ['Memory Threat Detection Alert: Windows.Ransomware.Sodinokibi'], + 'process.parent.args': [ + 'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe', + ], + '@timestamp': ['2024-05-07T12:48:44.975Z'], + 'process.command_line': ['"C:\\Windows\\MsMpEng.exe"'], + 'host.risk.calculated_level': ['High'], + 'process.hash.sha1': ['3d409b39b8502fcd23335a878f2cbdaf6d721995'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-20T23:38:25.169Z'], + }, + sort: [99, 1715086124975], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'file.path': ['C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll'], + 'process.hash.md5': ['4bfef0b578515c16b9582e32b78d2594'], + 'event.category': ['malware', 'intrusion_detection', 'library'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['C:\\Programdata\\Q3C7N1V8.exe'], + 'process.parent.name': ['Q3C7N1V8.exe'], + 'user.name': ['Administrator'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '70d21cbdc527559c4931421e66aa819b86d5af5535445ace467e74518164c46a', + ], + 'process.pid': [7824], + 'process.code_signature.exists': [true], + 'process.parent.code_signature.exists': [false], + 'process.pe.original_file_name': ['RUNDLL32.EXE'], + 'event.module': ['endpoint'], + 'process.code_signature.subject_name': ['Microsoft Windows'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'file.hash.sha256': ['12e6642cf6413bdf5388bee663080fa299591b2ba023d069286f3be9647547c8'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Malware Detection Alert'], + 'host.name': ['SRVWIN01'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\SysWOW64\\rundll32.exe'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.executable': ['C:\\ProgramData\\Q3C7N1V8.exe'], + 'kibana.alert.workflow_status': ['open'], + 'file.name': ['cdnver.dll'], + 'process.args': [ + 'C:\\Windows\\System32\\rundll32.exe', + 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll,#1', + ], + 'process.code_signature.status': ['trusted'], + message: ['Malware Detection Alert'], + 'process.parent.args_count': [1], + 'process.name': ['rundll32.exe'], + 'process.parent.args': ['C:\\Programdata\\Q3C7N1V8.exe'], + '@timestamp': ['2024-05-07T12:47:32.838Z'], + 'process.command_line': [ + '"C:\\Windows\\System32\\rundll32.exe" "C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll",#1', + ], + 'host.risk.calculated_level': ['High'], + _id: ['cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b'], + 'process.hash.sha1': ['9b16507aaf10a0aafa0df2ba83e8eb2708d83a02'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-16T01:51:26.472Z'], + }, + sort: [99, 1715086052838], + }, + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: '6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3', + _score: null, + fields: { + 'kibana.alert.severity': ['critical'], + 'process.hash.md5': ['4bfef0b578515c16b9582e32b78d2594'], + 'event.category': ['malware', 'intrusion_detection'], + 'host.risk.calculated_score_norm': [73.02488], + 'process.parent.command_line': ['C:\\Programdata\\Q3C7N1V8.exe'], + 'process.parent.name': ['Q3C7N1V8.exe'], + 'user.risk.calculated_level': ['High'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.hash.sha256': [ + '70d21cbdc527559c4931421e66aa819b86d5af5535445ace467e74518164c46a', + ], + 'process.pid': [7824], + 'process.code_signature.exists': [true], + 'process.code_signature.subject_name': ['Microsoft Windows'], + 'host.os.version': ['21H2 (10.0.20348.1366)'], + 'kibana.alert.risk_score': [99], + 'user.risk.calculated_score_norm': [82.16188], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': [ + 'Malicious Behavior Detection Alert: RunDLL32 with Unusual Arguments', + ], + 'host.name': ['SRVWIN01'], + 'event.outcome': ['success'], + 'process.code_signature.trusted': [true], + 'kibana.alert.workflow_status': ['open'], + 'rule.name': ['RunDLL32 with Unusual Arguments'], + 'threat.tactic.id': ['TA0005'], + 'threat.tactic.name': ['Defense Evasion'], + 'threat.technique.id': ['T1218'], + 'process.parent.args_count': [1], + 'threat.technique.subtechnique.reference': [ + 'https://attack.mitre.org/techniques/T1218/011/', + ], + 'process.name': ['rundll32.exe'], + 'threat.technique.subtechnique.name': ['Rundll32'], + _id: ['6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3'], + 'threat.technique.name': ['System Binary Proxy Execution'], + 'threat.tactic.reference': ['https://attack.mitre.org/tactics/TA0005/'], + 'user.name': ['Administrator'], + 'threat.framework': ['MITRE ATT&CK'], + 'process.working_directory': ['C:\\Users\\Administrator\\Documents\\'], + 'process.pe.original_file_name': ['RUNDLL32.EXE'], + 'event.module': ['endpoint'], + 'user.domain': ['OMM-WIN-DETECT'], + 'process.executable': ['C:\\Windows\\SysWOW64\\rundll32.exe'], + 'process.Ext.token.integrity_level_name': ['high'], + 'process.parent.executable': ['C:\\ProgramData\\Q3C7N1V8.exe'], + 'process.args': [ + 'C:\\Windows\\System32\\rundll32.exe', + 'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll,#1', + ], + 'process.code_signature.status': ['trusted'], + message: ['Malicious Behavior Detection Alert: RunDLL32 with Unusual Arguments'], + 'process.parent.args': ['C:\\Programdata\\Q3C7N1V8.exe'], + '@timestamp': ['2024-05-07T12:47:32.836Z'], + 'threat.technique.subtechnique.id': ['T1218.011'], + 'threat.technique.reference': ['https://attack.mitre.org/techniques/T1218/'], + 'process.command_line': [ + '"C:\\Windows\\System32\\rundll32.exe" "C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll",#1', + ], + 'host.risk.calculated_level': ['High'], + 'process.hash.sha1': ['9b16507aaf10a0aafa0df2ba83e8eb2708d83a02'], + 'event.dataset': ['endpoint.alerts'], + 'kibana.alert.original_time': ['2023-01-16T01:51:26.348Z'], + }, + sort: [99, 1715086052836], + }, + ], + }, +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts new file mode 100644 index 0000000000000..a40dde44f8d67 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/discard_previous_generations/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GraphState } from '../../../../types'; + +export const discardPreviousGenerations = ({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected, + state, +}: { + generationAttempts: number; + hallucinationFailures: number; + isHallucinationDetected: boolean; + state: GraphState; +}): GraphState => { + return { + ...state, + combinedGenerations: '', // <-- reset the combined generations + generationAttempts: generationAttempts + 1, + generations: [], // <-- reset the generations + hallucinationFailures: isHallucinationDetected + ? hallucinationFailures + 1 + : hallucinationFailures, + }; +}; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts similarity index 70% rename from x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts index bc290bf172382..287f5e6b2130a 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.test.ts @@ -4,15 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { getAttackDiscoveryPrompt } from './get_attack_discovery_prompt'; -describe('getAttackDiscoveryPrompt', () => { - it('should generate the correct attack discovery prompt', () => { +import { getAlertsContextPrompt } from '.'; +import { getDefaultAttackDiscoveryPrompt } from '../../../helpers/get_default_attack_discovery_prompt'; + +describe('getAlertsContextPrompt', () => { + it('generates the correct prompt', () => { const anonymizedAlerts = ['Alert 1', 'Alert 2', 'Alert 3']; - const expected = `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output. + const expected = `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. You MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds). -Use context from the following open and acknowledged alerts to provide insights: +Use context from the following alerts to provide insights: """ Alert 1 @@ -23,7 +25,10 @@ Alert 3 """ `; - const prompt = getAttackDiscoveryPrompt({ anonymizedAlerts }); + const prompt = getAlertsContextPrompt({ + anonymizedAlerts, + attackDiscoveryPrompt: getDefaultAttackDiscoveryPrompt(), + }); expect(prompt).toEqual(expected); }); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts new file mode 100644 index 0000000000000..d92d935053577 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_alerts_context_prompt/index.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +// NOTE: we ask the LLM to `provide insights`. We do NOT use the feature name, `AttackDiscovery`, in the prompt. +export const getAlertsContextPrompt = ({ + anonymizedAlerts, + attackDiscoveryPrompt, +}: { + anonymizedAlerts: string[]; + attackDiscoveryPrompt: string; +}) => `${attackDiscoveryPrompt} + +Use context from the following alerts to provide insights: + +""" +${anonymizedAlerts.join('\n\n')} +""" +`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts new file mode 100644 index 0000000000000..fb7cf6bd59f98 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_anonymized_alerts_from_state/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { GraphState } from '../../../../types'; + +export const getAnonymizedAlertsFromState = (state: GraphState): string[] => + state.anonymizedAlerts.map((doc) => doc.pageContent); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts new file mode 100644 index 0000000000000..face2a6afc6bc --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/helpers/get_use_unrefined_results/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AttackDiscovery } from '@kbn/elastic-assistant-common'; + +import { getMaxRetriesReached } from '../../../../helpers/get_max_retries_reached'; + +export const getUseUnrefinedResults = ({ + generationAttempts, + maxGenerationAttempts, + unrefinedResults, +}: { + generationAttempts: number; + maxGenerationAttempts: number; + unrefinedResults: AttackDiscovery[] | null; +}): boolean => { + const nextAttemptWouldExcedLimit = getMaxRetriesReached({ + generationAttempts: generationAttempts + 1, // + 1, because we just used an attempt + maxGenerationAttempts, + }); + + return nextAttemptWouldExcedLimit && unrefinedResults != null && unrefinedResults.length > 0; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts new file mode 100644 index 0000000000000..1fcd81622f0fe --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/index.ts @@ -0,0 +1,154 @@ +/* + * 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 { ActionsClientLlm } from '@kbn/langchain/server'; +import type { Logger } from '@kbn/core/server'; + +import { discardPreviousGenerations } from './helpers/discard_previous_generations'; +import { extractJson } from '../helpers/extract_json'; +import { getAnonymizedAlertsFromState } from './helpers/get_anonymized_alerts_from_state'; +import { getChainWithFormatInstructions } from '../helpers/get_chain_with_format_instructions'; +import { getCombined } from '../helpers/get_combined'; +import { getCombinedAttackDiscoveryPrompt } from '../helpers/get_combined_attack_discovery_prompt'; +import { generationsAreRepeating } from '../helpers/generations_are_repeating'; +import { getUseUnrefinedResults } from './helpers/get_use_unrefined_results'; +import { parseCombinedOrThrow } from '../helpers/parse_combined_or_throw'; +import { responseIsHallucinated } from '../helpers/response_is_hallucinated'; +import type { GraphState } from '../../types'; + +export const getGenerateNode = ({ + llm, + logger, +}: { + llm: ActionsClientLlm; + logger?: Logger; +}): ((state: GraphState) => Promise) => { + const generate = async (state: GraphState): Promise => { + logger?.debug(() => `---GENERATE---`); + + const anonymizedAlerts: string[] = getAnonymizedAlertsFromState(state); + + const { + attackDiscoveryPrompt, + combinedGenerations, + generationAttempts, + generations, + hallucinationFailures, + maxGenerationAttempts, + maxRepeatedGenerations, + } = state; + + let combinedResponse = ''; // mutable, because it must be accessed in the catch block + let partialResponse = ''; // mutable, because it must be accessed in the catch block + + try { + const query = getCombinedAttackDiscoveryPrompt({ + anonymizedAlerts, + attackDiscoveryPrompt, + combinedMaybePartialResults: combinedGenerations, + }); + + const { chain, formatInstructions, llmType } = getChainWithFormatInstructions(llm); + + logger?.debug( + () => `generate node is invoking the chain (${llmType}), attempt ${generationAttempts}` + ); + + const rawResponse = (await chain.invoke({ + format_instructions: formatInstructions, + query, + })) as unknown as string; + + // LOCAL MUTATION: + partialResponse = extractJson(rawResponse); // remove the surrounding ```json``` + + // if the response is hallucinated, discard previous generations and start over: + if (responseIsHallucinated(partialResponse)) { + logger?.debug( + () => + `generate node detected a hallucination (${llmType}), on attempt ${generationAttempts}; discarding the accumulated generations and starting over` + ); + + return discardPreviousGenerations({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected: true, + state, + }); + } + + // if the generations are repeating, discard previous generations and start over: + if ( + generationsAreRepeating({ + currentGeneration: partialResponse, + previousGenerations: generations, + sampleLastNGenerations: maxRepeatedGenerations, + }) + ) { + logger?.debug( + () => + `generate node detected (${llmType}), detected ${maxRepeatedGenerations} repeated generations on attempt ${generationAttempts}; discarding the accumulated results and starting over` + ); + + // discard the accumulated results and start over: + return discardPreviousGenerations({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected: false, + state, + }); + } + + // LOCAL MUTATION: + combinedResponse = getCombined({ combinedGenerations, partialResponse }); // combine the new response with the previous ones + + const unrefinedResults = parseCombinedOrThrow({ + combinedResponse, + generationAttempts, + llmType, + logger, + nodeName: 'generate', + }); + + // use the unrefined results if we already reached the max number of retries: + const useUnrefinedResults = getUseUnrefinedResults({ + generationAttempts, + maxGenerationAttempts, + unrefinedResults, + }); + + if (useUnrefinedResults) { + logger?.debug( + () => + `generate node is using unrefined results response (${llm._llmType()}) from attempt ${generationAttempts}, because all attempts have been used` + ); + } + + return { + ...state, + attackDiscoveries: useUnrefinedResults ? unrefinedResults : null, // optionally skip the refinement step by returning the final answer + combinedGenerations: combinedResponse, + generationAttempts: generationAttempts + 1, + generations: [...generations, partialResponse], + unrefinedResults, + }; + } catch (error) { + const parsingError = `generate node is unable to parse (${llm._llmType()}) response from attempt ${generationAttempts}; (this may be an incomplete response from the model): ${error}`; + logger?.debug(() => parsingError); // logged at debug level because the error is expected when the model returns an incomplete response + + return { + ...state, + combinedGenerations: combinedResponse, + errors: [...state.errors, parsingError], + generationAttempts: generationAttempts + 1, + generations: [...generations, partialResponse], + }; + } + }; + + return generate; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts new file mode 100644 index 0000000000000..05210799f151c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/generate/schema/index.ts @@ -0,0 +1,84 @@ +/* + * 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. + */ + +/* + * 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 { z } from '@kbn/zod'; + +export const SYNTAX = '{{ field.name fieldValue1 fieldValue2 fieldValueN }}'; +const GOOD_SYNTAX_EXAMPLES = + 'Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }}'; + +const BAD_SYNTAX_EXAMPLES = + 'Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}'; + +const RECONNAISSANCE = 'Reconnaissance'; +const INITIAL_ACCESS = 'Initial Access'; +const EXECUTION = 'Execution'; +const PERSISTENCE = 'Persistence'; +const PRIVILEGE_ESCALATION = 'Privilege Escalation'; +const DISCOVERY = 'Discovery'; +const LATERAL_MOVEMENT = 'Lateral Movement'; +const COMMAND_AND_CONTROL = 'Command and Control'; +const EXFILTRATION = 'Exfiltration'; + +const MITRE_ATTACK_TACTICS = [ + RECONNAISSANCE, + INITIAL_ACCESS, + EXECUTION, + PERSISTENCE, + PRIVILEGE_ESCALATION, + DISCOVERY, + LATERAL_MOVEMENT, + COMMAND_AND_CONTROL, + EXFILTRATION, +] as const; + +export const AttackDiscoveriesGenerationSchema = z.object({ + insights: z + .array( + z.object({ + alertIds: z.string().array().describe(`The alert IDs that the insight is based on.`), + detailsMarkdown: z + .string() + .describe( + `A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` + ), + entitySummaryMarkdown: z + .string() + .optional() + .describe( + `A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same ${SYNTAX} syntax` + ), + mitreAttackTactics: z + .string() + .array() + .optional() + .describe( + `An array of MITRE ATT&CK tactic for the insight, using one of the following values: ${MITRE_ATTACK_TACTICS.join( + ',' + )}` + ), + summaryMarkdown: z + .string() + .describe(`A markdown summary of insight, using the same ${SYNTAX} syntax`), + title: z + .string() + .describe( + 'A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.' + ), + }) + ) + .describe( + `Insights with markdown that always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` + ), +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts new file mode 100644 index 0000000000000..fd824709f5fcf --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/add_trailing_backticks_if_necessary/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const addTrailingBackticksIfNecessary = (text: string): string => { + const leadingJSONpattern = /^\w*```json(.*?)/s; + const trailingBackticksPattern = /(.*?)```\w*$/s; + + const hasLeadingJSONWrapper = leadingJSONpattern.test(text); + const hasTrailingBackticks = trailingBackticksPattern.test(text); + + if (hasLeadingJSONWrapper && !hasTrailingBackticks) { + return `${text}\n\`\`\``; + } + + return text; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts new file mode 100644 index 0000000000000..5e13ec9f0dafe --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { extractJson } from '.'; + +describe('extractJson', () => { + it('returns the JSON text surrounded by ```json and ``` with no whitespace or additional text', () => { + const input = '```json{"key": "value"}```'; + + const expected = '{"key": "value"}'; + + expect(extractJson(input)).toBe(expected); + }); + + it('returns the JSON block when surrounded by additional text and whitespace', () => { + const input = + 'You asked for some JSON, here it is:\n```json\n{"key": "value"}\n```\nI hope that works for you.'; + + const expected = '{"key": "value"}'; + + expect(extractJson(input)).toBe(expected); + }); + + it('returns the original text if no JSON block is found', () => { + const input = "There's no JSON here, just some text."; + + expect(extractJson(input)).toBe(input); + }); + + it('trims leading and trailing whitespace from the extracted JSON', () => { + const input = 'Text before\n```json\n {"key": "value"} \n```\nText after'; + + const expected = '{"key": "value"}'; + + expect(extractJson(input)).toBe(expected); + }); + + it('handles incomplete JSON blocks with no trailing ```', () => { + const input = 'Text before\n```json\n{"key": "value"'; // <-- no closing ```, because incomplete generation + + expect(extractJson(input)).toBe('{"key": "value"'); + }); + + it('handles multiline json (real world edge case)', () => { + const input = + '```json\n{\n "insights": [\n {\n "alertIds": [\n "a609473a23b3a66a40f2bba06795c28a0c12863c6931f39e472d069f5600cbae",\n "04a9ded2b4f10ea407711f0010d426ad328eea43ae53e1e0bf166c058947dff6",\n "8d53b9838181299b3c0b1544ea469216d72ad2234a1cce44017dd248a08d78d1",\n "51d0080ffcc1982dbae7c31a9a021f7b51422000dec1f0e0bb58bd61d934c893",\n "d93302956bee58d538f6f7a6cbf944e549e8466dacfb554a302dce46a069eef0",\n "75c89f679397f089716034cde20f5547a2e6bdd1606b1e002e0976ab339c4cd9",\n "5d8e9427c0ecc4daa5809bfe250b9a382c53e81e8f39eec87499d28efdda9300",\n "f18ac1874f510fd3fabb0ae48d0714f4952b294496ef1d993e3eb03f839e2d83",\n "e37cb31213c4c4e80beaf9f75e7966f88cdd86a228c6cb1a28e46356410fa78f",\n "cf70077b8888e8fbe434808fddbaf65d97fff244bb185a595cf0ad487e9c5850",\n "01bea609f0880b10b7b3c6cf6e8245ef0f134386fdcbf2a167e72487e0bda616",\n "289621edc88fd8b4775c541e46bcfdea40538291266179c59a5ca5afbee74cfc",\n "ba121c2045058b62a92e6a3abadd3c78a005b89129630e2271b2f45d5fd995b2",\n "fceb940b252be079df3629550d852bd2793f79071c917227268fa1b805abc8d1",\n "7044589c27bab148cdb97d9e2eeb88bd924fca82a6a05a53ec94dcadf8e56303",\n "1b68be35429f52280456aab17dd94191fe5c47fed9768f00d9f9e9044a08bbb5",\n "52478d4a119bbc44bec67f384f83dfa20b33cf9963177e619cd47a32bababe12",\n "fecbbb8924493b466e8f5744e0875a9ee91f326213b691b576b13da3fb875ebf",\n "c46bbdeb7b59f52c976e7e4f30e3d5c65f417d716cb140096b5edba52b1449a1",\n "f12caebcbda087fc8b49cdced64a8997dd1428f4cf91ebb251434a55126399b3",\n "c7478edbd13af443cfafc57d50e5206c5ae8c0f9c9cabc073fdc2d3946559617",\n "3585ae62651929ef405f9783410d7a94f4254d299205e22f22966178f189bb11",\n "f50f531912af1d31a66a0e37d4c0d9c571c2cca6bef2c7f8453eb3ab67c4d1a0",\n "95a9403f0bb97d03fc3c2eb06386503831766f541b736468088092c5e0e25830",\n "c1292c67f3ccd2cb2651c601f0816122cfa459276fa5fc89b40c62d1a793963e",\n "8911886e1b2964176f70eaee2aa6693ce101ee9c8ec5434acdc7ff18616ec31c",\n "bfbfb02c03c6f69fc2352c48d8fd7c7e4b557c611e16956fbb63e337a513e699",\n "064cbdc1932029fcb34f6ba685211b971afde3b8aa4325054bedaf4c9e4587ed",\n "9fd5d0ca9b9fff6e37f1114ad874103badb2b0570ef143cd4a26a553effdff00",\n "9e2687f26f04b5a8def3266f89fbe7217da2d4355c3b035268df1802f1342c81",\n "64557c4006c52119c01f6e3e582ce1b8207b2e8f64aaaa630ca1fd156c01ea1e",\n "df98d2568c986d101af055f78c7e2a39299627531c28012b5025d10e2ec1b208",\n "10683db11fb21cae36577f83722c686c2fc691d2be6fc4396f2733564f3210d1",\n "f46e7b3266200e3e23b15b5acea7bb934e2c17d23058e10daeed51f036f4932b",\n "3c77d55f912b80b66cc1e4e1df02a22ddee07c50338a409374fb2567d2fb4ca3",\n "8ec169c0fdf558c0d9d9ad8dedad0898b15bb718421b4cab8f5cce4ebcb78254",\n "4119a1705f993588f8d1d576e567ec17f102aeafe535e53bb56ec833418ccd08",\n "b53d06bfd23ab843dba67e2fde0da6364475b0bfb9c40cb8a6641cc4ecadec01",\n "1dcd85c8279fd7152dadecfc547cce06261d23ef4589fe4fdcc92b1ceeb76c0f",\n "d4ed490b1d39925ee612058655030bdb7cecda3e5893e1c40dbbac852b72fbc6",\n "2ecc96c4d51f5338684c08e7c67357e504abfec6fc4f21753a3c941189db68e1",\n "0c9fb123686bc739d117ee4f607ffbcef39f1f72e7eab6d01b70bbb40480b3d6",\n "162be5e04f54a5cd475d2437fe769ee044324b0a32ce83a735f61719b8b5fd63",\n "21eae60b4b29f7f01cc7006372374e1c5d6912858c33397cdbe4470df97fba79",\n "0409539590b6d9b80f7071d3d5658434f982ba7957aa6a5037f8b7a73b70100d",\n "5e8e654df34a9053f8b90e4ade25520dbee5994ebf7da531e1e7255d029ab031",\n "3ef381b2d29d71bc3ac8580d333344948a2664855a89ff037299a8b4aa663293",\n "0aef1fe2506842f9c53549049b47a8166bcc3d6efe2d8fcf1e57f3a634ed137c",\n "c2d12dacd0cd6ef4a7386c8d0146d3eb91a7e1e9f2d8d47bffaab07a92577993",\n "45e6663c65172e225e2531df3dce58096ed6e9a7d0fd7819e5b6f094a41731a0",\n "f2af064d46f1db1d96c7c9508a462993851e42f29566f2101ea3a1c51e5e451c",\n "b75c046d06f86eea41826999211ab5e6c9cb5fe067ade561fb5dc5f0b52d4584",\n "1fb9fbb26b78c2e9c56abf8e39e4cb278a5a382d53115dcb1624fdefca762865",\n "d78c4d12f6d50278be6320df1fe10beeef8723558cdb12d9d6c7d1aa8180498b",\n "c8fa7d3a31906893c47df234318e94bc4371b55ac54edc60b2c09afd8a9291c6",\n "5236dc9c55f19d8aed50078cc6ecd1de85041afa65003276fc311c14d5a74d0a",\n "efb9d548ff94246a22cfa8e06b70689d8f3edf69c8ad45c3811e0d340b4b10ff",\n "842c8d78d995f49b569934cf5e8316ba1d93a1d73e757210d5f0eb7e1ed52049",\n "b95dcfba35d31ab263bfab939280c71893bdb39e3a744c2f3cc38612ebcbb42a",\n "d6387171a203c64fd1c09514a028cf813d2ffccf968831c92cdf22287992e004",\n "b8d098f358ce5e8fa2900ac18435078652353a32a19ef2fd038bf82eee3a0731"\n ],\n "detailsMarkdown": "### Attack Progression\\n- **Initial Access**: The attack began with a spearphishing attachment delivered via Microsoft Office documents. The documents contained malicious macros that executed upon opening.\\n- **Execution**: The malicious macros executed various commands, including the use of `certutil` to decode and execute payloads, and `regsvr32` to register malicious DLLs.\\n- **Persistence**: The attackers established persistence by modifying registry run keys and creating scheduled tasks.\\n- **Credential Access**: The attackers attempted to capture credentials using `osascript` on macOS systems.\\n- **Defense Evasion**: The attackers used code signing with invalid or expired certificates to evade detection.\\n- **Command and Control**: The attackers established command and control channels using various techniques, including the use of `mshta` and `powershell` scripts.\\n- **Exfiltration**: The attackers exfiltrated data using tools like `curl` to transfer data to remote servers.\\n- **Impact**: The attackers deployed ransomware, including `Sodinokibi` and `Bumblebee`, to encrypt files and demand ransom payments.\\n\\n### Affected Hosts and Users\\n- **Hosts**: Multiple hosts across different operating systems (Windows, macOS, Linux) were affected.\\n- **Users**: The attacks targeted various users, including administrators and regular users.\\n\\n### Known Threat Groups\\n- The attack patterns and techniques used in this campaign are consistent with those employed by known threat groups such as `Emotet`, `Qbot`, and `Sodinokibi`.\\n\\n### Recommendations\\n- **Immediate Actions**: Isolate affected systems, reset passwords, and review network traffic for signs of command and control communications.\\n- **Long-term Actions**: Implement multi-factor authentication, conduct regular security awareness training, and deploy advanced endpoint protection solutions.",\n "entitySummaryMarkdown": "{{ host.name 9ed6a9db-da4d-4877-a2b4-f7a22cc55e9a }} {{ user.name c45d8d76-bff6-4c4b-aa5a-62eb15d68adb }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence",\n "Credential Access",\n "Defense Evasion",\n "Command and Control",\n "Exfiltration",\n "Impact"\n ],\n "summaryMarkdown": "A sophisticated multi-stage attack was detected, involving spearphishing, credential access, and ransomware deployment. The attack targeted multiple hosts and users across different operating systems.",\n "title": "Multi-Stage Cyber Attack Detected"\n }\n ]\n}\n```'; + + const expected = + '{\n "insights": [\n {\n "alertIds": [\n "a609473a23b3a66a40f2bba06795c28a0c12863c6931f39e472d069f5600cbae",\n "04a9ded2b4f10ea407711f0010d426ad328eea43ae53e1e0bf166c058947dff6",\n "8d53b9838181299b3c0b1544ea469216d72ad2234a1cce44017dd248a08d78d1",\n "51d0080ffcc1982dbae7c31a9a021f7b51422000dec1f0e0bb58bd61d934c893",\n "d93302956bee58d538f6f7a6cbf944e549e8466dacfb554a302dce46a069eef0",\n "75c89f679397f089716034cde20f5547a2e6bdd1606b1e002e0976ab339c4cd9",\n "5d8e9427c0ecc4daa5809bfe250b9a382c53e81e8f39eec87499d28efdda9300",\n "f18ac1874f510fd3fabb0ae48d0714f4952b294496ef1d993e3eb03f839e2d83",\n "e37cb31213c4c4e80beaf9f75e7966f88cdd86a228c6cb1a28e46356410fa78f",\n "cf70077b8888e8fbe434808fddbaf65d97fff244bb185a595cf0ad487e9c5850",\n "01bea609f0880b10b7b3c6cf6e8245ef0f134386fdcbf2a167e72487e0bda616",\n "289621edc88fd8b4775c541e46bcfdea40538291266179c59a5ca5afbee74cfc",\n "ba121c2045058b62a92e6a3abadd3c78a005b89129630e2271b2f45d5fd995b2",\n "fceb940b252be079df3629550d852bd2793f79071c917227268fa1b805abc8d1",\n "7044589c27bab148cdb97d9e2eeb88bd924fca82a6a05a53ec94dcadf8e56303",\n "1b68be35429f52280456aab17dd94191fe5c47fed9768f00d9f9e9044a08bbb5",\n "52478d4a119bbc44bec67f384f83dfa20b33cf9963177e619cd47a32bababe12",\n "fecbbb8924493b466e8f5744e0875a9ee91f326213b691b576b13da3fb875ebf",\n "c46bbdeb7b59f52c976e7e4f30e3d5c65f417d716cb140096b5edba52b1449a1",\n "f12caebcbda087fc8b49cdced64a8997dd1428f4cf91ebb251434a55126399b3",\n "c7478edbd13af443cfafc57d50e5206c5ae8c0f9c9cabc073fdc2d3946559617",\n "3585ae62651929ef405f9783410d7a94f4254d299205e22f22966178f189bb11",\n "f50f531912af1d31a66a0e37d4c0d9c571c2cca6bef2c7f8453eb3ab67c4d1a0",\n "95a9403f0bb97d03fc3c2eb06386503831766f541b736468088092c5e0e25830",\n "c1292c67f3ccd2cb2651c601f0816122cfa459276fa5fc89b40c62d1a793963e",\n "8911886e1b2964176f70eaee2aa6693ce101ee9c8ec5434acdc7ff18616ec31c",\n "bfbfb02c03c6f69fc2352c48d8fd7c7e4b557c611e16956fbb63e337a513e699",\n "064cbdc1932029fcb34f6ba685211b971afde3b8aa4325054bedaf4c9e4587ed",\n "9fd5d0ca9b9fff6e37f1114ad874103badb2b0570ef143cd4a26a553effdff00",\n "9e2687f26f04b5a8def3266f89fbe7217da2d4355c3b035268df1802f1342c81",\n "64557c4006c52119c01f6e3e582ce1b8207b2e8f64aaaa630ca1fd156c01ea1e",\n "df98d2568c986d101af055f78c7e2a39299627531c28012b5025d10e2ec1b208",\n "10683db11fb21cae36577f83722c686c2fc691d2be6fc4396f2733564f3210d1",\n "f46e7b3266200e3e23b15b5acea7bb934e2c17d23058e10daeed51f036f4932b",\n "3c77d55f912b80b66cc1e4e1df02a22ddee07c50338a409374fb2567d2fb4ca3",\n "8ec169c0fdf558c0d9d9ad8dedad0898b15bb718421b4cab8f5cce4ebcb78254",\n "4119a1705f993588f8d1d576e567ec17f102aeafe535e53bb56ec833418ccd08",\n "b53d06bfd23ab843dba67e2fde0da6364475b0bfb9c40cb8a6641cc4ecadec01",\n "1dcd85c8279fd7152dadecfc547cce06261d23ef4589fe4fdcc92b1ceeb76c0f",\n "d4ed490b1d39925ee612058655030bdb7cecda3e5893e1c40dbbac852b72fbc6",\n "2ecc96c4d51f5338684c08e7c67357e504abfec6fc4f21753a3c941189db68e1",\n "0c9fb123686bc739d117ee4f607ffbcef39f1f72e7eab6d01b70bbb40480b3d6",\n "162be5e04f54a5cd475d2437fe769ee044324b0a32ce83a735f61719b8b5fd63",\n "21eae60b4b29f7f01cc7006372374e1c5d6912858c33397cdbe4470df97fba79",\n "0409539590b6d9b80f7071d3d5658434f982ba7957aa6a5037f8b7a73b70100d",\n "5e8e654df34a9053f8b90e4ade25520dbee5994ebf7da531e1e7255d029ab031",\n "3ef381b2d29d71bc3ac8580d333344948a2664855a89ff037299a8b4aa663293",\n "0aef1fe2506842f9c53549049b47a8166bcc3d6efe2d8fcf1e57f3a634ed137c",\n "c2d12dacd0cd6ef4a7386c8d0146d3eb91a7e1e9f2d8d47bffaab07a92577993",\n "45e6663c65172e225e2531df3dce58096ed6e9a7d0fd7819e5b6f094a41731a0",\n "f2af064d46f1db1d96c7c9508a462993851e42f29566f2101ea3a1c51e5e451c",\n "b75c046d06f86eea41826999211ab5e6c9cb5fe067ade561fb5dc5f0b52d4584",\n "1fb9fbb26b78c2e9c56abf8e39e4cb278a5a382d53115dcb1624fdefca762865",\n "d78c4d12f6d50278be6320df1fe10beeef8723558cdb12d9d6c7d1aa8180498b",\n "c8fa7d3a31906893c47df234318e94bc4371b55ac54edc60b2c09afd8a9291c6",\n "5236dc9c55f19d8aed50078cc6ecd1de85041afa65003276fc311c14d5a74d0a",\n "efb9d548ff94246a22cfa8e06b70689d8f3edf69c8ad45c3811e0d340b4b10ff",\n "842c8d78d995f49b569934cf5e8316ba1d93a1d73e757210d5f0eb7e1ed52049",\n "b95dcfba35d31ab263bfab939280c71893bdb39e3a744c2f3cc38612ebcbb42a",\n "d6387171a203c64fd1c09514a028cf813d2ffccf968831c92cdf22287992e004",\n "b8d098f358ce5e8fa2900ac18435078652353a32a19ef2fd038bf82eee3a0731"\n ],\n "detailsMarkdown": "### Attack Progression\\n- **Initial Access**: The attack began with a spearphishing attachment delivered via Microsoft Office documents. The documents contained malicious macros that executed upon opening.\\n- **Execution**: The malicious macros executed various commands, including the use of `certutil` to decode and execute payloads, and `regsvr32` to register malicious DLLs.\\n- **Persistence**: The attackers established persistence by modifying registry run keys and creating scheduled tasks.\\n- **Credential Access**: The attackers attempted to capture credentials using `osascript` on macOS systems.\\n- **Defense Evasion**: The attackers used code signing with invalid or expired certificates to evade detection.\\n- **Command and Control**: The attackers established command and control channels using various techniques, including the use of `mshta` and `powershell` scripts.\\n- **Exfiltration**: The attackers exfiltrated data using tools like `curl` to transfer data to remote servers.\\n- **Impact**: The attackers deployed ransomware, including `Sodinokibi` and `Bumblebee`, to encrypt files and demand ransom payments.\\n\\n### Affected Hosts and Users\\n- **Hosts**: Multiple hosts across different operating systems (Windows, macOS, Linux) were affected.\\n- **Users**: The attacks targeted various users, including administrators and regular users.\\n\\n### Known Threat Groups\\n- The attack patterns and techniques used in this campaign are consistent with those employed by known threat groups such as `Emotet`, `Qbot`, and `Sodinokibi`.\\n\\n### Recommendations\\n- **Immediate Actions**: Isolate affected systems, reset passwords, and review network traffic for signs of command and control communications.\\n- **Long-term Actions**: Implement multi-factor authentication, conduct regular security awareness training, and deploy advanced endpoint protection solutions.",\n "entitySummaryMarkdown": "{{ host.name 9ed6a9db-da4d-4877-a2b4-f7a22cc55e9a }} {{ user.name c45d8d76-bff6-4c4b-aa5a-62eb15d68adb }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence",\n "Credential Access",\n "Defense Evasion",\n "Command and Control",\n "Exfiltration",\n "Impact"\n ],\n "summaryMarkdown": "A sophisticated multi-stage attack was detected, involving spearphishing, credential access, and ransomware deployment. The attack targeted multiple hosts and users across different operating systems.",\n "title": "Multi-Stage Cyber Attack Detected"\n }\n ]\n}'; + + expect(extractJson(input)).toBe(expected); + }); + + it('handles "Here is my analysis of the security events in JSON format" (real world edge case)', () => { + const input = + 'Here is my analysis of the security events in JSON format:\n\n```json\n{\n "insights": [\n {\n "alertIds": [\n "d776c8406fd81427b1f166550ac1b949017da7a13dc734594e4b05f24622b26e",\n "504c012054cfe91986311b4e6bc8523914434fab590e5c07c0328fab6566753c",\n "b706b8c19e68cc4f54b69f0a93e32b10f4102b610213b7826fb1d303b90a0536",\n "7763ebe716c47f64987362a9fb120d73873c77d26ad915f2c3d57c5dd3b7eed0",\n "25c61e0423a9bfd7f268ca6e9b67d4f507207c0cb1e1b4701aa5248cb3866f1f",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:17.566Z }}, a malicious file with SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} was detected on {{ host.name SRVNIX05 }}\\n- The file was initially downloaded as a zip archive and extracted to /home/ubuntu/\\n- The malware, identified as Linux.Trojan.BPFDoor, was then copied to /dev/shm/kdmtmpflush and executed\\n- This trojan allows remote attackers to gain backdoor access to the compromised Linux system\\n- The malware was executed with root privileges, indicating a serious compromise\\n- Network connections and other malicious activities from this backdoor should be investigated",\n "entitySummaryMarkdown": "{{ host.name SRVNIX05 }} compromised by Linux.Trojan.BPFDoor malware executed as {{ user.name root }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Linux.Trojan.BPFDoor malware detected and executed on {{ host.name SRVNIX05 }} with root privileges, allowing remote backdoor access",\n "title": "Linux Trojan BPFDoor Backdoor Detected"\n },\n {\n "alertIds": [\n "5946b409f49b0983de53e575db0874ef11b0544766f816dc702941a69a9b0dd1",\n "aa0ba23872c48a8ee761591c5bb0a9ed8258c51b27111cc72dbe8624a0b7da96",\n "b60a5c344b579cab9406becdec14a11d56f4eccc2bf6caaf6eb72ddf1707124c",\n "4920ca19a22968e4ab0cf299974234699d9cce15545c401a2b8fd09d71f6e106",\n "26302b2afbe58c8dcfde950c7164262c626af0b85f0808f3d8632b1d6a406d16",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "41564c953dd101b942537110d175d2b269959c24dbf5b7c482e32851ab6f5dc1",\n "12e102970920f5f938b21effb09394c00540075fc4057ec79e221046a8b6ba0f"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:33.570Z }}, suspicious activity was detected on {{ host.name SRVMAC08 }}\\n- A malicious application \\"My Go Application.app\\" was executed, likely masquerading as a legitimate program\\n- The malware attempted to access the user\'s keychain to steal credentials\\n- It executed a file named {{ file.name unix1 }} which tried to access {{ file.path /Users/james/library/Keychains/login.keychain-db }}\\n- The malware also attempted to display a fake system preferences dialog to phish the user\'s password\\n- This attack targeted {{ user.name james }}, who has high user criticality",\n "entitySummaryMarkdown": "{{ host.name SRVMAC08 }} infected with malware targeting {{ user.name james }}\'s credentials",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Credential Access" \n ],\n "summaryMarkdown": "Malware on {{ host.name SRVMAC08 }} attempted to steal keychain credentials and phish password from {{ user.name james }}",\n "title": "macOS Credential Theft Attempt"\n },\n {\n "alertIds": [\n "a492cd3202717d0c86f9b44623b12ac4d19855722e0fadb2f84a547afb45871a",\n "7fdf3a399b0a6df74784f478c2712a0e47ff997f73701593b3a5a56fa452056f",\n "bf33e5f004b6f6f41e362f929b3fa16b5ea9ecbb0f6389acd17dfcfb67ff3ae9",\n "b6559664247c438f9cd15022feb87855253c3cef882cc52d2e064f2693977f1c",\n "636a5a24b810bf2dbc5e2417858ac218b1fadb598fa55676745f88c0509f3e48",\n "fc0f6f9939277cc4f526148c15813f5d48094e557fdcf0ba9e773b2a16ec8c2e",\n "0029a93e8f72dce05a22ca0cc5a5cd1ca8a29b93b3c8864f7623f10b98d79084",\n "67f41b973f82fc141d75fbbd1d6caba11066c19b2a1c720fcec9e681e1cfa60c",\n "79774ae772225e94b6183f5ea394572ebe24452be99100bab145173c57c73d3b"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:54.836Z }}, malicious activity was detected on {{ host.name SRVWIN01 }}\\n- An Excel file was used to drop and execute malware\\n- The malware used certutil.exe to decode a malicious payload\\n- A suspicious executable {{ file.name Q3C7N1V8.exe }} was created in C:\\\\ProgramData\\\\\\n- The malware established persistence by modifying registry run keys\\n- It then executed a DLL {{ file.name cdnver.dll }} using rundll32.exe\\n- This attack chain indicates a sophisticated malware infection, likely part of an ongoing attack campaign",\n "entitySummaryMarkdown": "{{ host.name SRVWIN01 }} infected via malicious Excel file executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access", \n "Execution",\n "Persistence",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN01 }} via malicious Excel file, establishing persistence and executing additional payloads",\n "title": "Excel-based Malware Infection Chain"\n },\n {\n "alertIds": [\n "801ec41afa5f05a7cafefe4eaff87be1f9eb7ecbfcfc501bd83a12f19e742be0",\n "eafd7577e1d88b2c4fc3d0e3eb54b2a315f79996f075ba3c57d6f2ae7181c53b",\n "eb8fee0ceacc8caec4757e95ec132a42bae4ba7841126ce9616873e01e806ddf",\n "69dcd5e48424cc8a04a965f5bec7539c8221ac556a7b93c531cdc7e02b58c191",\n "6c81da91ad4ec313c5a4aa970e1fdf7c3ee6dbfa8536c734bd12c72f1abe3a09",\n "584d904ea196623eb794df40565797656e24d05a707638447b5e53c05d520510",\n "46d05beb516dae1ad2f168084cdeb5bfd35ac1b1194bd65aa1c837fb3b77c21d",\n "c79fe367d985d9a5d9ee723ce94977b88fe1bbb3ec8e2ffbb7b3ee134d6b49ef",\n "3ef6baa7c7c99cad5b7832e6a778a7d1ea2d88729a3e50fbf2b821d0e57f2740",\n "1fbe36af64b587d7604812f6a248754cfe8c1d80b0551046c1fc95640d0ba538",\n "4451f6a45edc2d90f85717925071457e88dd41d0ee3d3c377f5721a254651513",\n "7ec9f53a2c4571325476ad2f4de3d2ecb49609b35a4a30a33d8d57e815d09f52",\n "ca57fd3a83e06419ce8299eefd3c783bd3d33b46ce47ffd27e2abdcb2b3e0955"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:14.847Z }}, a malicious OneNote file was opened on {{ host.name SRVWIN04 }}\\n- The OneNote file executed an embedded HTA file using mshta.exe\\n- The HTA file then downloaded additional malware using curl.exe\\n- A suspicious DLL {{ file.path C:\\\\ProgramData\\\\121.png }} was loaded using rundll32.exe\\n- The malware injected shellcode into legitimate Windows processes like AtBroker.exe\\n- Memory scans detected signatures matching the Qbot banking trojan\\n- The malware established persistence by modifying registry run keys\\n- It also performed domain trust enumeration, indicating potential lateral movement preparation\\n- This sophisticated attack chain suggests a targeted intrusion by an advanced threat actor",\n "entitySummaryMarkdown": "{{ host.name SRVWIN04 }} compromised via malicious OneNote file opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution", \n "Persistence",\n "Defense Evasion",\n "Discovery"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN04 }} via OneNote file, downloading Qbot trojan and preparing for potential lateral movement",\n "title": "OneNote-based Qbot Infection Chain"\n },\n {\n "alertIds": [\n "7150ee5a9571c6028573bf7d9c2ed0da15c3387ee3c8f668741799496f7b4ae9",\n "6053ca3481a9307d3a8626fe055357541bb53d97f5deb1b7b346ec86441c335b",\n "d9c3908a4ac46b90270e6aab8217ab6385a114574931026f1df8cfc930260ff6",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070",\n "f045dc2a57582944b6e198e685e98bf02f86b5eb23ddbbdbb015c8568867122c",\n "171fe0490d48e9cac6f5b46aec7bfa67f3ecb96af308027018ca881bae1ce5d7",\n "0e22ea9514fd663a3841a212b19736fd1579c301d80f4838f25adeec24de4cf6",\n "9d8fdb59213e5a950d93253f9f986c730c877a70493c4f47ad0de52ef50c42f1"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:58.609Z }}, a malicious executable was run on {{ host.name SRVWIN02 }}\\n- The malware injected shellcode into the legitimate MsMpEng.exe (Windows Defender) process\\n- Memory scans detected signatures matching the Sodinokibi (REvil) ransomware\\n- The malware created ransom notes and began encrypting files\\n- It also attempted to enable network discovery, likely to spread to other systems\\n- This indicates an active ransomware infection that could quickly spread across the network",\n "entitySummaryMarkdown": "{{ host.name SRVWIN02 }} infected with Sodinokibi ransomware executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion",\n "Impact"\n ],\n "summaryMarkdown": "Sodinokibi (REvil) ransomware detected on {{ host.name SRVWIN02 }}, actively encrypting files and attempting to spread",\n "title": "Active Sodinokibi Ransomware Infection"\n },\n {\n "alertIds": [\n "6f8e71d59956c6dbed5c88986cdafd4386684e3879085b2742e1f2d38b282066",\n "c13b78fbfef05ddc81c73b436ccb5288d8cd52a46175638b1b3b0d311f8b53e8",\n "b0f3d3f5bfc0b1d1f3c7e219ee44dc225fa26cafd40697073a636b44cf6054ad"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:22.077Z }}, suspicious activity was detected on {{ host.name SRVWIN06 }}\\n- The msiexec.exe process spawned an unusual PowerShell child process\\n- The PowerShell process executed a script from a suspicious temporary directory\\n- Memory scans of the PowerShell process detected signatures matching the Bumblebee malware loader\\n- Bumblebee is known to be used by multiple ransomware groups as an initial access vector\\n- This indicates a likely ongoing attack attempting to deploy additional malware or ransomware",\n "entitySummaryMarkdown": "{{ host.name SRVWIN06 }} infected with Bumblebee malware loader via {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Bumblebee malware loader detected on {{ host.name SRVWIN06 }}, likely attempting to deploy additional payloads",\n "title": "Bumblebee Malware Loader Detected"\n },\n {\n "alertIds": [\n "f629babc51c3628517d8a7e1f0662124ee41e4328b1dbcf72dc3fc6f2e410d33",\n "627d00600f803366edb83700b546a4bf486e2990ac7140d842e898eb6e298e83",\n "6181847506974ed4458f03b60919c4a306197b5cb040ab324d2d1f6d0ca5bde1",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "df26b2d23068b77fdc001ea44f46505a259f02ceccc9fa0b2401c5e35190e710",\n "9c038ff779bd0ff514a1ff2b55caa359189d8bcebc48c6ac14a789946e87eaed"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:27.839Z }}, a malicious Word document was opened on {{ host.name SRVWIN07 }}\\n- The document spawned wscript.exe to execute a malicious VBS script\\n- The VBS script then launched a PowerShell process with suspicious arguments\\n- PowerShell was used to create a scheduled task for persistence\\n- This attack chain indicates a likely attempt to establish a foothold for further malicious activities",\n "entitySummaryMarkdown": "{{ host.name SRVWIN07 }} compromised via malicious Word document opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Malicious Word document on {{ host.name SRVWIN07 }} led to execution of VBS and PowerShell scripts, establishing persistence via scheduled task",\n "title": "Malicious Document Leads to Persistence"\n }\n ]\n}'; + + const expected = + '{\n "insights": [\n {\n "alertIds": [\n "d776c8406fd81427b1f166550ac1b949017da7a13dc734594e4b05f24622b26e",\n "504c012054cfe91986311b4e6bc8523914434fab590e5c07c0328fab6566753c",\n "b706b8c19e68cc4f54b69f0a93e32b10f4102b610213b7826fb1d303b90a0536",\n "7763ebe716c47f64987362a9fb120d73873c77d26ad915f2c3d57c5dd3b7eed0",\n "25c61e0423a9bfd7f268ca6e9b67d4f507207c0cb1e1b4701aa5248cb3866f1f",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:17.566Z }}, a malicious file with SHA256 hash {{ file.hash.sha256 74ef6cc38f5a1a80148752b63c117e6846984debd2af806c65887195a8eccc56 }} was detected on {{ host.name SRVNIX05 }}\\n- The file was initially downloaded as a zip archive and extracted to /home/ubuntu/\\n- The malware, identified as Linux.Trojan.BPFDoor, was then copied to /dev/shm/kdmtmpflush and executed\\n- This trojan allows remote attackers to gain backdoor access to the compromised Linux system\\n- The malware was executed with root privileges, indicating a serious compromise\\n- Network connections and other malicious activities from this backdoor should be investigated",\n "entitySummaryMarkdown": "{{ host.name SRVNIX05 }} compromised by Linux.Trojan.BPFDoor malware executed as {{ user.name root }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Linux.Trojan.BPFDoor malware detected and executed on {{ host.name SRVNIX05 }} with root privileges, allowing remote backdoor access",\n "title": "Linux Trojan BPFDoor Backdoor Detected"\n },\n {\n "alertIds": [\n "5946b409f49b0983de53e575db0874ef11b0544766f816dc702941a69a9b0dd1",\n "aa0ba23872c48a8ee761591c5bb0a9ed8258c51b27111cc72dbe8624a0b7da96",\n "b60a5c344b579cab9406becdec14a11d56f4eccc2bf6caaf6eb72ddf1707124c",\n "4920ca19a22968e4ab0cf299974234699d9cce15545c401a2b8fd09d71f6e106",\n "26302b2afbe58c8dcfde950c7164262c626af0b85f0808f3d8632b1d6a406d16",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "41564c953dd101b942537110d175d2b269959c24dbf5b7c482e32851ab6f5dc1",\n "12e102970920f5f938b21effb09394c00540075fc4057ec79e221046a8b6ba0f"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:33.570Z }}, suspicious activity was detected on {{ host.name SRVMAC08 }}\\n- A malicious application \\"My Go Application.app\\" was executed, likely masquerading as a legitimate program\\n- The malware attempted to access the user\'s keychain to steal credentials\\n- It executed a file named {{ file.name unix1 }} which tried to access {{ file.path /Users/james/library/Keychains/login.keychain-db }}\\n- The malware also attempted to display a fake system preferences dialog to phish the user\'s password\\n- This attack targeted {{ user.name james }}, who has high user criticality",\n "entitySummaryMarkdown": "{{ host.name SRVMAC08 }} infected with malware targeting {{ user.name james }}\'s credentials",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Credential Access" \n ],\n "summaryMarkdown": "Malware on {{ host.name SRVMAC08 }} attempted to steal keychain credentials and phish password from {{ user.name james }}",\n "title": "macOS Credential Theft Attempt"\n },\n {\n "alertIds": [\n "a492cd3202717d0c86f9b44623b12ac4d19855722e0fadb2f84a547afb45871a",\n "7fdf3a399b0a6df74784f478c2712a0e47ff997f73701593b3a5a56fa452056f",\n "bf33e5f004b6f6f41e362f929b3fa16b5ea9ecbb0f6389acd17dfcfb67ff3ae9",\n "b6559664247c438f9cd15022feb87855253c3cef882cc52d2e064f2693977f1c",\n "636a5a24b810bf2dbc5e2417858ac218b1fadb598fa55676745f88c0509f3e48",\n "fc0f6f9939277cc4f526148c15813f5d48094e557fdcf0ba9e773b2a16ec8c2e",\n "0029a93e8f72dce05a22ca0cc5a5cd1ca8a29b93b3c8864f7623f10b98d79084",\n "67f41b973f82fc141d75fbbd1d6caba11066c19b2a1c720fcec9e681e1cfa60c",\n "79774ae772225e94b6183f5ea394572ebe24452be99100bab145173c57c73d3b"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:54.836Z }}, malicious activity was detected on {{ host.name SRVWIN01 }}\\n- An Excel file was used to drop and execute malware\\n- The malware used certutil.exe to decode a malicious payload\\n- A suspicious executable {{ file.name Q3C7N1V8.exe }} was created in C:\\\\ProgramData\\\\\\n- The malware established persistence by modifying registry run keys\\n- It then executed a DLL {{ file.name cdnver.dll }} using rundll32.exe\\n- This attack chain indicates a sophisticated malware infection, likely part of an ongoing attack campaign",\n "entitySummaryMarkdown": "{{ host.name SRVWIN01 }} infected via malicious Excel file executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access", \n "Execution",\n "Persistence",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN01 }} via malicious Excel file, establishing persistence and executing additional payloads",\n "title": "Excel-based Malware Infection Chain"\n },\n {\n "alertIds": [\n "801ec41afa5f05a7cafefe4eaff87be1f9eb7ecbfcfc501bd83a12f19e742be0",\n "eafd7577e1d88b2c4fc3d0e3eb54b2a315f79996f075ba3c57d6f2ae7181c53b",\n "eb8fee0ceacc8caec4757e95ec132a42bae4ba7841126ce9616873e01e806ddf",\n "69dcd5e48424cc8a04a965f5bec7539c8221ac556a7b93c531cdc7e02b58c191",\n "6c81da91ad4ec313c5a4aa970e1fdf7c3ee6dbfa8536c734bd12c72f1abe3a09",\n "584d904ea196623eb794df40565797656e24d05a707638447b5e53c05d520510",\n "46d05beb516dae1ad2f168084cdeb5bfd35ac1b1194bd65aa1c837fb3b77c21d",\n "c79fe367d985d9a5d9ee723ce94977b88fe1bbb3ec8e2ffbb7b3ee134d6b49ef",\n "3ef6baa7c7c99cad5b7832e6a778a7d1ea2d88729a3e50fbf2b821d0e57f2740",\n "1fbe36af64b587d7604812f6a248754cfe8c1d80b0551046c1fc95640d0ba538",\n "4451f6a45edc2d90f85717925071457e88dd41d0ee3d3c377f5721a254651513",\n "7ec9f53a2c4571325476ad2f4de3d2ecb49609b35a4a30a33d8d57e815d09f52",\n "ca57fd3a83e06419ce8299eefd3c783bd3d33b46ce47ffd27e2abdcb2b3e0955"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:14.847Z }}, a malicious OneNote file was opened on {{ host.name SRVWIN04 }}\\n- The OneNote file executed an embedded HTA file using mshta.exe\\n- The HTA file then downloaded additional malware using curl.exe\\n- A suspicious DLL {{ file.path C:\\\\ProgramData\\\\121.png }} was loaded using rundll32.exe\\n- The malware injected shellcode into legitimate Windows processes like AtBroker.exe\\n- Memory scans detected signatures matching the Qbot banking trojan\\n- The malware established persistence by modifying registry run keys\\n- It also performed domain trust enumeration, indicating potential lateral movement preparation\\n- This sophisticated attack chain suggests a targeted intrusion by an advanced threat actor",\n "entitySummaryMarkdown": "{{ host.name SRVWIN04 }} compromised via malicious OneNote file opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution", \n "Persistence",\n "Defense Evasion",\n "Discovery"\n ],\n "summaryMarkdown": "Sophisticated malware infection on {{ host.name SRVWIN04 }} via OneNote file, downloading Qbot trojan and preparing for potential lateral movement",\n "title": "OneNote-based Qbot Infection Chain"\n },\n {\n "alertIds": [\n "7150ee5a9571c6028573bf7d9c2ed0da15c3387ee3c8f668741799496f7b4ae9",\n "6053ca3481a9307d3a8626fe055357541bb53d97f5deb1b7b346ec86441c335b",\n "d9c3908a4ac46b90270e6aab8217ab6385a114574931026f1df8cfc930260ff6",\n "ea99e1633177f0c82e5126d4c999db2128c3adac6af4c7f4f183abc44486f070",\n "f045dc2a57582944b6e198e685e98bf02f86b5eb23ddbbdbb015c8568867122c",\n "171fe0490d48e9cac6f5b46aec7bfa67f3ecb96af308027018ca881bae1ce5d7",\n "0e22ea9514fd663a3841a212b19736fd1579c301d80f4838f25adeec24de4cf6",\n "9d8fdb59213e5a950d93253f9f986c730c877a70493c4f47ad0de52ef50c42f1"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:49:58.609Z }}, a malicious executable was run on {{ host.name SRVWIN02 }}\\n- The malware injected shellcode into the legitimate MsMpEng.exe (Windows Defender) process\\n- Memory scans detected signatures matching the Sodinokibi (REvil) ransomware\\n- The malware created ransom notes and began encrypting files\\n- It also attempted to enable network discovery, likely to spread to other systems\\n- This indicates an active ransomware infection that could quickly spread across the network",\n "entitySummaryMarkdown": "{{ host.name SRVWIN02 }} infected with Sodinokibi ransomware executed by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion",\n "Impact"\n ],\n "summaryMarkdown": "Sodinokibi (REvil) ransomware detected on {{ host.name SRVWIN02 }}, actively encrypting files and attempting to spread",\n "title": "Active Sodinokibi Ransomware Infection"\n },\n {\n "alertIds": [\n "6f8e71d59956c6dbed5c88986cdafd4386684e3879085b2742e1f2d38b282066",\n "c13b78fbfef05ddc81c73b436ccb5288d8cd52a46175638b1b3b0d311f8b53e8",\n "b0f3d3f5bfc0b1d1f3c7e219ee44dc225fa26cafd40697073a636b44cf6054ad"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:22.077Z }}, suspicious activity was detected on {{ host.name SRVWIN06 }}\\n- The msiexec.exe process spawned an unusual PowerShell child process\\n- The PowerShell process executed a script from a suspicious temporary directory\\n- Memory scans of the PowerShell process detected signatures matching the Bumblebee malware loader\\n- Bumblebee is known to be used by multiple ransomware groups as an initial access vector\\n- This indicates a likely ongoing attack attempting to deploy additional malware or ransomware",\n "entitySummaryMarkdown": "{{ host.name SRVWIN06 }} infected with Bumblebee malware loader via {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Execution",\n "Defense Evasion"\n ],\n "summaryMarkdown": "Bumblebee malware loader detected on {{ host.name SRVWIN06 }}, likely attempting to deploy additional payloads",\n "title": "Bumblebee Malware Loader Detected"\n },\n {\n "alertIds": [\n "f629babc51c3628517d8a7e1f0662124ee41e4328b1dbcf72dc3fc6f2e410d33",\n "627d00600f803366edb83700b546a4bf486e2990ac7140d842e898eb6e298e83",\n "6181847506974ed4458f03b60919c4a306197b5cb040ab324d2d1f6d0ca5bde1",\n "3aba59cd449be763e5b50ab954e39936ab3035be36010810e340e277b5670017",\n "df26b2d23068b77fdc001ea44f46505a259f02ceccc9fa0b2401c5e35190e710",\n "9c038ff779bd0ff514a1ff2b55caa359189d8bcebc48c6ac14a789946e87eaed"\n ],\n "detailsMarkdown": "- At {{ kibana.alert.original_time 2024-05-16T18:50:27.839Z }}, a malicious Word document was opened on {{ host.name SRVWIN07 }}\\n- The document spawned wscript.exe to execute a malicious VBS script\\n- The VBS script then launched a PowerShell process with suspicious arguments\\n- PowerShell was used to create a scheduled task for persistence\\n- This attack chain indicates a likely attempt to establish a foothold for further malicious activities",\n "entitySummaryMarkdown": "{{ host.name SRVWIN07 }} compromised via malicious Word document opened by {{ user.name Administrator }}",\n "mitreAttackTactics": [\n "Initial Access",\n "Execution",\n "Persistence"\n ],\n "summaryMarkdown": "Malicious Word document on {{ host.name SRVWIN07 }} led to execution of VBS and PowerShell scripts, establishing persistence via scheduled task",\n "title": "Malicious Document Leads to Persistence"\n }\n ]\n}'; + + expect(extractJson(input)).toBe(expected); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts new file mode 100644 index 0000000000000..79d3f9c0d0599 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/extract_json/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const extractJson = (input: string): string => { + const regex = /```json\s*([\s\S]*?)(?:\s*```|$)/; + const match = input.match(regex); + + if (match && match[1]) { + return match[1].trim(); + } + + return input; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx new file mode 100644 index 0000000000000..7d6db4dd72dfd --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { generationsAreRepeating } from '.'; + +describe('getIsGenerationRepeating', () => { + it('returns true when all previous generations are the same as the current generation', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: ['gen1', 'gen1', 'gen1'], // <-- all the same, length 3 + sampleLastNGenerations: 3, + }); + + expect(result).toBe(true); + }); + + it('returns false when some of the previous generations are NOT the same as the current generation', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: ['gen1', 'gen2', 'gen1'], // <-- some are different, length 3 + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); + + it('returns true when all *sampled* generations are the same as the current generation, and there are older samples past the last N', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: [ + 'gen2', // <-- older sample will be ignored + 'gen1', + 'gen1', + 'gen1', + ], + sampleLastNGenerations: 3, + }); + + expect(result).toBe(true); + }); + + it('returns false when some of the *sampled* generations are NOT the same as the current generation, and there are additional samples past the last N', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: [ + 'gen1', // <-- older sample will be ignored + 'gen1', + 'gen1', + 'gen2', + ], + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); + + it('returns false when sampling fewer generations than sampleLastNGenerations, and all are the same as the current generation', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: ['gen1', 'gen1'], // <-- same, but only 2 generations + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); + + it('returns false when sampling fewer generations than sampleLastNGenerations, and some are different from the current generation', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: ['gen1', 'gen2'], // <-- different, but only 2 generations + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); + + it('returns false when there are no previous generations to sample', () => { + const result = generationsAreRepeating({ + currentGeneration: 'gen1', + previousGenerations: [], + sampleLastNGenerations: 3, + }); + + expect(result).toBe(false); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx new file mode 100644 index 0000000000000..6cc9cd86c9d2f --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/generations_are_repeating/index.tsx @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** Returns true if the last n generations are repeating the same output */ +export const generationsAreRepeating = ({ + currentGeneration, + previousGenerations, + sampleLastNGenerations, +}: { + currentGeneration: string; + previousGenerations: string[]; + sampleLastNGenerations: number; +}): boolean => { + const generationsToSample = previousGenerations.slice(-sampleLastNGenerations); + + if (generationsToSample.length < sampleLastNGenerations) { + return false; // Not enough generations to sample + } + + return generationsToSample.every((generation) => generation === currentGeneration); +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts new file mode 100644 index 0000000000000..7eacaad1d7e39 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_chain_with_format_instructions/index.ts @@ -0,0 +1,34 @@ +/* + * 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 { ActionsClientLlm } from '@kbn/langchain/server'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { Runnable } from '@langchain/core/runnables'; + +import { getOutputParser } from '../get_output_parser'; + +interface GetChainWithFormatInstructions { + chain: Runnable; + formatInstructions: string; + llmType: string; +} + +export const getChainWithFormatInstructions = ( + llm: ActionsClientLlm +): GetChainWithFormatInstructions => { + const outputParser = getOutputParser(); + const formatInstructions = outputParser.getFormatInstructions(); + + const prompt = ChatPromptTemplate.fromTemplate( + `Answer the user's question as best you can:\n{format_instructions}\n{query}` + ); + + const chain = prompt.pipe(llm); + const llmType = llm._llmType(); + + return { chain, formatInstructions, llmType }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts new file mode 100644 index 0000000000000..10b5c323891a1 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getCombined = ({ + combinedGenerations, + partialResponse, +}: { + combinedGenerations: string; + partialResponse: string; +}): string => `${combinedGenerations}${partialResponse}`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts new file mode 100644 index 0000000000000..4c9ac71f8310c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_combined_attack_discovery_prompt/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash/fp'; + +import { getAlertsContextPrompt } from '../../generate/helpers/get_alerts_context_prompt'; +import { getContinuePrompt } from '../get_continue_prompt'; + +/** + * Returns the the initial query, or the initial query combined with a + * continuation prompt and partial results + */ +export const getCombinedAttackDiscoveryPrompt = ({ + anonymizedAlerts, + attackDiscoveryPrompt, + combinedMaybePartialResults, +}: { + anonymizedAlerts: string[]; + attackDiscoveryPrompt: string; + /** combined results that may contain incomplete JSON */ + combinedMaybePartialResults: string; +}): string => { + const alertsContextPrompt = getAlertsContextPrompt({ + anonymizedAlerts, + attackDiscoveryPrompt, + }); + + return isEmpty(combinedMaybePartialResults) + ? alertsContextPrompt // no partial results yet + : `${alertsContextPrompt} + +${getContinuePrompt()} + +""" +${combinedMaybePartialResults} +""" + +`; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts new file mode 100644 index 0000000000000..628ba0531332c --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_continue_prompt/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getContinuePrompt = + (): string => `Continue exactly where you left off in the JSON output below, generating only the additional JSON output when it's required to complete your work. The additional JSON output MUST ALWAYS follow these rules: +1) it MUST conform to the schema above, because it will be checked against the JSON schema +2) it MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds), because it will be parsed as JSON +3) it MUST NOT repeat any the previous output, because that would prevent partial results from being combined +4) it MUST NOT restart from the beginning, because that would prevent partial results from being combined +5) it MUST NOT be prefixed or suffixed with additional text outside of the JSON, because that would prevent it from being combined and parsed as JSON: +`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts new file mode 100644 index 0000000000000..25bace13d40c8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_default_attack_discovery_prompt/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getDefaultAttackDiscoveryPrompt = (): string => + "You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. You MUST escape all JSON special characters (i.e. backslashes, double quotes, newlines, tabs, carriage returns, backspaces, and form feeds)."; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts new file mode 100644 index 0000000000000..569c8cf4e04a5 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getOutputParser } from '.'; + +describe('getOutputParser', () => { + it('returns a structured output parser with the expected format instructions', () => { + const outputParser = getOutputParser(); + + const expected = `You must format your output as a JSON value that adheres to a given \"JSON Schema\" instance. + +\"JSON Schema\" is a declarative language that allows you to annotate and validate JSON documents. + +For example, the example \"JSON Schema\" instance {{\"properties\": {{\"foo\": {{\"description\": \"a list of test words\", \"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}}}, \"required\": [\"foo\"]}}}} +would match an object with one required property, \"foo\". The \"type\" property specifies \"foo\" must be an \"array\", and the \"description\" property semantically describes it as \"a list of test words\". The items within \"foo\" must be strings. +Thus, the object {{\"foo\": [\"bar\", \"baz\"]}} is a well-formatted instance of this example \"JSON Schema\". The object {{\"properties\": {{\"foo\": [\"bar\", \"baz\"]}}}} is not well-formatted. + +Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas! + +Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock: +\`\`\`json +{"type":"object","properties":{"insights":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"alertIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"The alert IDs that the insight is based on.\"},\"detailsMarkdown\":{\"type\":\"string\",\"description\":\"A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"},\"entitySummaryMarkdown\":{\"type\":\"string\",\"description\":\"A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"mitreAttackTactics\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"An array of MITRE ATT&CK tactic for the insight, using one of the following values: Reconnaissance,Initial Access,Execution,Persistence,Privilege Escalation,Discovery,Lateral Movement,Command and Control,Exfiltration\"},\"summaryMarkdown\":{\"type\":\"string\",\"description\":\"A markdown summary of insight, using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"title\":{\"type\":\"string\",\"description\":\"A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.\"}},\"required\":[\"alertIds\",\"detailsMarkdown\",\"summaryMarkdown\",\"title\"],\"additionalProperties\":false},\"description\":\"Insights with markdown that always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"}},\"required\":[\"insights\"],\"additionalProperties":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"} +\`\`\` +`; + + expect(outputParser.getFormatInstructions()).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts new file mode 100644 index 0000000000000..2ca0d72b63eb4 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/get_output_parser/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { StructuredOutputParser } from 'langchain/output_parsers'; + +import { AttackDiscoveriesGenerationSchema } from '../../generate/schema'; + +export const getOutputParser = () => + StructuredOutputParser.fromZodSchema(AttackDiscoveriesGenerationSchema); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts new file mode 100644 index 0000000000000..3f7a0a9d802b3 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/parse_combined_or_throw/index.ts @@ -0,0 +1,53 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import type { AttackDiscovery } from '@kbn/elastic-assistant-common'; + +import { addTrailingBackticksIfNecessary } from '../add_trailing_backticks_if_necessary'; +import { extractJson } from '../extract_json'; +import { AttackDiscoveriesGenerationSchema } from '../../generate/schema'; + +export const parseCombinedOrThrow = ({ + combinedResponse, + generationAttempts, + llmType, + logger, + nodeName, +}: { + /** combined responses that maybe valid JSON */ + combinedResponse: string; + generationAttempts: number; + nodeName: string; + llmType: string; + logger?: Logger; +}): AttackDiscovery[] => { + const timestamp = new Date().toISOString(); + + const extractedJson = extractJson(addTrailingBackticksIfNecessary(combinedResponse)); + + logger?.debug( + () => + `${nodeName} node is parsing extractedJson (${llmType}) from attempt ${generationAttempts}` + ); + + const unvalidatedParsed = JSON.parse(extractedJson); + + logger?.debug( + () => + `${nodeName} node is validating combined response (${llmType}) from attempt ${generationAttempts}` + ); + + const validatedResponse = AttackDiscoveriesGenerationSchema.parse(unvalidatedParsed); + + logger?.debug( + () => + `${nodeName} node successfully validated Attack discoveries response (${llmType}) from attempt ${generationAttempts}` + ); + + return [...validatedResponse.insights.map((insight) => ({ ...insight, timestamp }))]; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts new file mode 100644 index 0000000000000..f938f6436db98 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/helpers/response_is_hallucinated/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const responseIsHallucinated = (result: string): boolean => + result.includes('{{ host.name hostNameValue }}'); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts new file mode 100644 index 0000000000000..e642e598e73f0 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/discard_previous_refinements/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GraphState } from '../../../../types'; + +export const discardPreviousRefinements = ({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected, + state, +}: { + generationAttempts: number; + hallucinationFailures: number; + isHallucinationDetected: boolean; + state: GraphState; +}): GraphState => { + return { + ...state, + combinedRefinements: '', // <-- reset the combined refinements + generationAttempts: generationAttempts + 1, + refinements: [], // <-- reset the refinements + hallucinationFailures: isHallucinationDetected + ? hallucinationFailures + 1 + : hallucinationFailures, + }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts new file mode 100644 index 0000000000000..11ea40a48ae55 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_combined_refine_prompt/index.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AttackDiscovery } from '@kbn/elastic-assistant-common'; +import { isEmpty } from 'lodash/fp'; + +import { getContinuePrompt } from '../../../helpers/get_continue_prompt'; + +/** + * Returns a prompt that combines the initial query, a refine prompt, and partial results + */ +export const getCombinedRefinePrompt = ({ + attackDiscoveryPrompt, + combinedRefinements, + refinePrompt, + unrefinedResults, +}: { + attackDiscoveryPrompt: string; + combinedRefinements: string; + refinePrompt: string; + unrefinedResults: AttackDiscovery[] | null; +}): string => { + const baseQuery = `${attackDiscoveryPrompt} + +${refinePrompt} + +""" +${JSON.stringify(unrefinedResults, null, 2)} +""" + +`; + + return isEmpty(combinedRefinements) + ? baseQuery // no partial results yet + : `${baseQuery} + +${getContinuePrompt()} + +""" +${combinedRefinements} +""" + +`; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts new file mode 100644 index 0000000000000..5743316669785 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_default_refine_prompt/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getDefaultRefinePrompt = + (): string => `You previously generated the following insights, but sometimes they represent the same attack. + +Combine the insights below, when they represent the same attack; leave any insights that are not combined unchanged:`; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts new file mode 100644 index 0000000000000..13d0a2228a3ee --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/helpers/get_use_unrefined_results/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +/** + * Note: the conditions tested here are different than the generate node + */ +export const getUseUnrefinedResults = ({ + maxHallucinationFailuresReached, + maxRetriesReached, +}: { + maxHallucinationFailuresReached: boolean; + maxRetriesReached: boolean; +}): boolean => maxRetriesReached || maxHallucinationFailuresReached; // we may have reached max halucination failures, but we still want to use the unrefined results diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts new file mode 100644 index 0000000000000..0c7987eef92bc --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/refine/index.ts @@ -0,0 +1,166 @@ +/* + * 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 { ActionsClientLlm } from '@kbn/langchain/server'; +import type { Logger } from '@kbn/core/server'; + +import { discardPreviousRefinements } from './helpers/discard_previous_refinements'; +import { extractJson } from '../helpers/extract_json'; +import { getChainWithFormatInstructions } from '../helpers/get_chain_with_format_instructions'; +import { getCombined } from '../helpers/get_combined'; +import { getCombinedRefinePrompt } from './helpers/get_combined_refine_prompt'; +import { generationsAreRepeating } from '../helpers/generations_are_repeating'; +import { getMaxHallucinationFailuresReached } from '../../helpers/get_max_hallucination_failures_reached'; +import { getMaxRetriesReached } from '../../helpers/get_max_retries_reached'; +import { getUseUnrefinedResults } from './helpers/get_use_unrefined_results'; +import { parseCombinedOrThrow } from '../helpers/parse_combined_or_throw'; +import { responseIsHallucinated } from '../helpers/response_is_hallucinated'; +import type { GraphState } from '../../types'; + +export const getRefineNode = ({ + llm, + logger, +}: { + llm: ActionsClientLlm; + logger?: Logger; +}): ((state: GraphState) => Promise) => { + const refine = async (state: GraphState): Promise => { + logger?.debug(() => '---REFINE---'); + + const { + attackDiscoveryPrompt, + combinedRefinements, + generationAttempts, + hallucinationFailures, + maxGenerationAttempts, + maxHallucinationFailures, + maxRepeatedGenerations, + refinements, + refinePrompt, + unrefinedResults, + } = state; + + let combinedResponse = ''; // mutable, because it must be accessed in the catch block + let partialResponse = ''; // mutable, because it must be accessed in the catch block + + try { + const query = getCombinedRefinePrompt({ + attackDiscoveryPrompt, + combinedRefinements, + refinePrompt, + unrefinedResults, + }); + + const { chain, formatInstructions, llmType } = getChainWithFormatInstructions(llm); + + logger?.debug( + () => `refine node is invoking the chain (${llmType}), attempt ${generationAttempts}` + ); + + const rawResponse = (await chain.invoke({ + format_instructions: formatInstructions, + query, + })) as unknown as string; + + // LOCAL MUTATION: + partialResponse = extractJson(rawResponse); // remove the surrounding ```json``` + + // if the response is hallucinated, discard it: + if (responseIsHallucinated(partialResponse)) { + logger?.debug( + () => + `refine node detected a hallucination (${llmType}), on attempt ${generationAttempts}; discarding the accumulated refinements and starting over` + ); + + return discardPreviousRefinements({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected: true, + state, + }); + } + + // if the refinements are repeating, discard previous refinements and start over: + if ( + generationsAreRepeating({ + currentGeneration: partialResponse, + previousGenerations: refinements, + sampleLastNGenerations: maxRepeatedGenerations, + }) + ) { + logger?.debug( + () => + `refine node detected (${llmType}), detected ${maxRepeatedGenerations} repeated generations on attempt ${generationAttempts}; discarding the accumulated results and starting over` + ); + + // discard the accumulated results and start over: + return discardPreviousRefinements({ + generationAttempts, + hallucinationFailures, + isHallucinationDetected: false, + state, + }); + } + + // LOCAL MUTATION: + combinedResponse = getCombined({ combinedGenerations: combinedRefinements, partialResponse }); // combine the new response with the previous ones + + const attackDiscoveries = parseCombinedOrThrow({ + combinedResponse, + generationAttempts, + llmType, + logger, + nodeName: 'refine', + }); + + return { + ...state, + attackDiscoveries, // the final, refined answer + generationAttempts: generationAttempts + 1, + combinedRefinements: combinedResponse, + refinements: [...refinements, partialResponse], + }; + } catch (error) { + const parsingError = `refine node is unable to parse (${llm._llmType()}) response from attempt ${generationAttempts}; (this may be an incomplete response from the model): ${error}`; + logger?.debug(() => parsingError); // logged at debug level because the error is expected when the model returns an incomplete response + + const maxRetriesReached = getMaxRetriesReached({ + generationAttempts: generationAttempts + 1, + maxGenerationAttempts, + }); + + const maxHallucinationFailuresReached = getMaxHallucinationFailuresReached({ + hallucinationFailures, + maxHallucinationFailures, + }); + + // we will use the unrefined results if we have reached the maximum number of retries or hallucination failures: + const useUnrefinedResults = getUseUnrefinedResults({ + maxHallucinationFailuresReached, + maxRetriesReached, + }); + + if (useUnrefinedResults) { + logger?.debug( + () => + `refine node is using unrefined results response (${llm._llmType()}) from attempt ${generationAttempts}, because all attempts have been used` + ); + } + + return { + ...state, + attackDiscoveries: useUnrefinedResults ? unrefinedResults : null, + combinedRefinements: combinedResponse, + errors: [...state.errors, parsingError], + generationAttempts: generationAttempts + 1, + refinements: [...refinements, partialResponse], + }; + } + }; + + return refine; +}; diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts new file mode 100644 index 0000000000000..3a8b7ed3a6b94 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts @@ -0,0 +1,74 @@ +/* + * 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 { ElasticsearchClient } from '@kbn/core/server'; +import { Replacements } from '@kbn/elastic-assistant-common'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { CallbackManagerForRetrieverRun } from '@langchain/core/callbacks/manager'; +import type { Document } from '@langchain/core/documents'; +import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers'; + +import { getAnonymizedAlerts } from '../helpers/get_anonymized_alerts'; + +export type CustomRetrieverInput = BaseRetrieverInput; + +export class AnonymizedAlertsRetriever extends BaseRetriever { + lc_namespace = ['langchain', 'retrievers']; + + #alertsIndexPattern?: string; + #anonymizationFields?: AnonymizationFieldResponse[]; + #esClient: ElasticsearchClient; + #onNewReplacements?: (newReplacements: Replacements) => void; + #replacements?: Replacements; + #size?: number; + + constructor({ + alertsIndexPattern, + anonymizationFields, + fields, + esClient, + onNewReplacements, + replacements, + size, + }: { + alertsIndexPattern?: string; + anonymizationFields?: AnonymizationFieldResponse[]; + fields?: CustomRetrieverInput; + esClient: ElasticsearchClient; + onNewReplacements?: (newReplacements: Replacements) => void; + replacements?: Replacements; + size?: number; + }) { + super(fields); + + this.#alertsIndexPattern = alertsIndexPattern; + this.#anonymizationFields = anonymizationFields; + this.#esClient = esClient; + this.#onNewReplacements = onNewReplacements; + this.#replacements = replacements; + this.#size = size; + } + + async _getRelevantDocuments( + query: string, + runManager?: CallbackManagerForRetrieverRun + ): Promise { + const anonymizedAlerts = await getAnonymizedAlerts({ + alertsIndexPattern: this.#alertsIndexPattern, + anonymizationFields: this.#anonymizationFields, + esClient: this.#esClient, + onNewReplacements: this.#onNewReplacements, + replacements: this.#replacements, + size: this.#size, + }); + + return anonymizedAlerts.map((alert) => ({ + pageContent: alert, + metadata: {}, + })); + } +} diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts similarity index 90% rename from x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts index 6b7526870eb9f..b616c392ddd21 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts @@ -6,19 +6,19 @@ */ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { getOpenAndAcknowledgedAlertsQuery } from '@kbn/elastic-assistant-common'; -import { getAnonymizedAlerts } from './get_anonymized_alerts'; -import { mockOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_open_and_acknowledged_alerts_query_results'; -import { getOpenAndAcknowledgedAlertsQuery } from '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query'; -import { MIN_SIZE } from '../open_and_acknowledged_alerts/helpers'; +const MIN_SIZE = 10; -jest.mock('../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query', () => { - const original = jest.requireActual( - '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query' - ); +import { getAnonymizedAlerts } from '.'; +import { mockOpenAndAcknowledgedAlertsQueryResults } from '../../../../mock/mock_open_and_acknowledged_alerts_query_results'; + +jest.mock('@kbn/elastic-assistant-common', () => { + const original = jest.requireActual('@kbn/elastic-assistant-common'); return { - getOpenAndAcknowledgedAlertsQuery: jest.fn(() => original), + ...original, + getOpenAndAcknowledgedAlertsQuery: jest.fn(), }; }); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts similarity index 77% rename from x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts index 5989caf439518..bc2a7f5bf9e71 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_anonymized_alerts.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts @@ -7,12 +7,16 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { ElasticsearchClient } from '@kbn/core/server'; -import type { Replacements } from '@kbn/elastic-assistant-common'; -import { getAnonymizedValue, transformRawData } from '@kbn/elastic-assistant-common'; -import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { + Replacements, + getAnonymizedValue, + getOpenAndAcknowledgedAlertsQuery, + getRawDataOrDefault, + sizeIsOutOfRange, + transformRawData, +} from '@kbn/elastic-assistant-common'; -import { getOpenAndAcknowledgedAlertsQuery } from '../open_and_acknowledged_alerts/get_open_and_acknowledged_alerts_query'; -import { getRawDataOrDefault, sizeIsOutOfRange } from '../open_and_acknowledged_alerts/helpers'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; export const getAnonymizedAlerts = async ({ alertsIndexPattern, diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts new file mode 100644 index 0000000000000..951ae3bca8854 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts @@ -0,0 +1,70 @@ +/* + * 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 { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { Replacements } from '@kbn/elastic-assistant-common'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; + +import { AnonymizedAlertsRetriever } from './anonymized_alerts_retriever'; +import type { GraphState } from '../../types'; + +export const getRetrieveAnonymizedAlertsNode = ({ + alertsIndexPattern, + anonymizationFields, + esClient, + logger, + onNewReplacements, + replacements, + size, +}: { + alertsIndexPattern?: string; + anonymizationFields?: AnonymizationFieldResponse[]; + esClient: ElasticsearchClient; + logger?: Logger; + onNewReplacements?: (replacements: Replacements) => void; + replacements?: Replacements; + size?: number; +}): ((state: GraphState) => Promise) => { + let localReplacements = { ...(replacements ?? {}) }; + const localOnNewReplacements = (newReplacements: Replacements) => { + localReplacements = { ...localReplacements, ...newReplacements }; + + onNewReplacements?.(localReplacements); // invoke the callback with the latest replacements + }; + + const retriever = new AnonymizedAlertsRetriever({ + alertsIndexPattern, + anonymizationFields, + esClient, + onNewReplacements: localOnNewReplacements, + replacements, + size, + }); + + const retrieveAnonymizedAlerts = async (state: GraphState): Promise => { + logger?.debug(() => '---RETRIEVE ANONYMIZED ALERTS---'); + const documents = await retriever + .withConfig({ runName: 'runAnonymizedAlertsRetriever' }) + .invoke(''); + + return { + ...state, + anonymizedAlerts: documents, + replacements: localReplacements, + }; + }; + + return retrieveAnonymizedAlerts; +}; + +/** + * Retrieve documents + * + * @param {GraphState} state The current state of the graph. + * @param {RunnableConfig | undefined} config The configuration object for tracing. + * @returns {Promise} The new state object. + */ diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts new file mode 100644 index 0000000000000..4229155cc2e25 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/state/index.ts @@ -0,0 +1,86 @@ +/* + * 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 { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import type { Document } from '@langchain/core/documents'; +import type { StateGraphArgs } from '@langchain/langgraph'; + +import { + DEFAULT_MAX_GENERATION_ATTEMPTS, + DEFAULT_MAX_HALLUCINATION_FAILURES, + DEFAULT_MAX_REPEATED_GENERATIONS, +} from '../constants'; +import { getDefaultAttackDiscoveryPrompt } from '../nodes/helpers/get_default_attack_discovery_prompt'; +import { getDefaultRefinePrompt } from '../nodes/refine/helpers/get_default_refine_prompt'; +import type { GraphState } from '../types'; + +export const getDefaultGraphState = (): StateGraphArgs['channels'] => ({ + attackDiscoveries: { + value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x, + default: () => null, + }, + attackDiscoveryPrompt: { + value: (x: string, y?: string) => y ?? x, + default: () => getDefaultAttackDiscoveryPrompt(), + }, + anonymizedAlerts: { + value: (x: Document[], y?: Document[]) => y ?? x, + default: () => [], + }, + combinedGenerations: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + combinedRefinements: { + value: (x: string, y?: string) => y ?? x, + default: () => '', + }, + errors: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, + generationAttempts: { + value: (x: number, y?: number) => y ?? x, + default: () => 0, + }, + generations: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, + hallucinationFailures: { + value: (x: number, y?: number) => y ?? x, + default: () => 0, + }, + refinePrompt: { + value: (x: string, y?: string) => y ?? x, + default: () => getDefaultRefinePrompt(), + }, + maxGenerationAttempts: { + value: (x: number, y?: number) => y ?? x, + default: () => DEFAULT_MAX_GENERATION_ATTEMPTS, + }, + maxHallucinationFailures: { + value: (x: number, y?: number) => y ?? x, + default: () => DEFAULT_MAX_HALLUCINATION_FAILURES, + }, + maxRepeatedGenerations: { + value: (x: number, y?: number) => y ?? x, + default: () => DEFAULT_MAX_REPEATED_GENERATIONS, + }, + refinements: { + value: (x: string[], y?: string[]) => y ?? x, + default: () => [], + }, + replacements: { + value: (x: Replacements, y?: Replacements) => y ?? x, + default: () => ({}), + }, + unrefinedResults: { + value: (x: AttackDiscovery[] | null, y?: AttackDiscovery[] | null) => y ?? x, + default: () => null, + }, +}); diff --git a/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts new file mode 100644 index 0000000000000..b4473a02b82ae --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import type { Document } from '@langchain/core/documents'; + +export interface GraphState { + attackDiscoveries: AttackDiscovery[] | null; + attackDiscoveryPrompt: string; + anonymizedAlerts: Document[]; + combinedGenerations: string; + combinedRefinements: string; + errors: string[]; + generationAttempts: number; + generations: string[]; + hallucinationFailures: number; + maxGenerationAttempts: number; + maxHallucinationFailures: number; + maxRepeatedGenerations: number; + refinements: string[]; + refinePrompt: string; + replacements: Replacements; + unrefinedResults: AttackDiscovery[] | null; +} diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts similarity index 94% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts index 6e9cc39597bd7..a82ec24c7041e 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.test.ts @@ -10,11 +10,11 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { createAttackDiscovery } from './create_attack_discovery'; import { AttackDiscoveryCreateProps, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { getAttackDiscovery } from './get_attack_discovery'; +import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; import { loggerMock } from '@kbn/logging-mocks'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); -jest.mock('./get_attack_discovery'); +jest.mock('../get_attack_discovery/get_attack_discovery'); const attackDiscoveryCreate: AttackDiscoveryCreateProps = { attackDiscoveries: [], apiConfig: { diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts index 7304ab3488529..fc511dc559d30 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/create_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/create_attack_discovery/create_attack_discovery.ts @@ -9,8 +9,8 @@ import { v4 as uuidv4 } from 'uuid'; import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryCreateProps, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { getAttackDiscovery } from './get_attack_discovery'; -import { CreateAttackDiscoverySchema } from './types'; +import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; +import { CreateAttackDiscoverySchema } from '../types'; export interface CreateAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration.ts similarity index 100% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/field_maps_configuration.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration.ts diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts index e80d1e4589838..945603b517938 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_all_attack_discoveries.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_all_attack_discoveries/find_all_attack_discoveries.ts @@ -8,8 +8,8 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/security-plugin/common'; -import { EsAttackDiscoverySchema } from './types'; -import { transformESSearchToAttackDiscovery } from './transforms'; +import { EsAttackDiscoverySchema } from '../types'; +import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; const MAX_ITEMS = 10000; export interface FindAllAttackDiscoveriesParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts index 10688ce25b25e..53d74e6e92f42 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.test.ts @@ -9,7 +9,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts index 532c35ac89c05..07fde44080026 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/find_attack_discovery_by_connector_id.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id.ts @@ -7,8 +7,8 @@ import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from './types'; -import { transformESSearchToAttackDiscovery } from './transforms'; +import { EsAttackDiscoverySchema } from '../types'; +import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; export interface FindAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts index 4ee89fb7a3bc0..af1a1827cbddd 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.test.ts @@ -8,7 +8,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { getAttackDiscovery } from './get_attack_discovery'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; import { AuthenticatedUser } from '@kbn/core-security-common'; const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts index d0cf6fd19ae05..ae2051d9e480b 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/get_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/get_attack_discovery/get_attack_discovery.ts @@ -7,8 +7,8 @@ import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from './types'; -import { transformESSearchToAttackDiscovery } from './transforms'; +import { EsAttackDiscoverySchema } from '../types'; +import { transformESSearchToAttackDiscovery } from '../transforms/transforms'; export interface GetAttackDiscoveryParams { esClient: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts index ca053743c8035..5aac100f5f52c 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/index.ts @@ -11,12 +11,15 @@ import { AttackDiscoveryResponse, } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { findAllAttackDiscoveries } from './find_all_attack_discoveries'; -import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id'; -import { updateAttackDiscovery } from './update_attack_discovery'; -import { createAttackDiscovery } from './create_attack_discovery'; -import { getAttackDiscovery } from './get_attack_discovery'; -import { AIAssistantDataClient, AIAssistantDataClientParams } from '..'; +import { findAllAttackDiscoveries } from './find_all_attack_discoveries/find_all_attack_discoveries'; +import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id/find_attack_discovery_by_connector_id'; +import { updateAttackDiscovery } from './update_attack_discovery/update_attack_discovery'; +import { createAttackDiscovery } from './create_attack_discovery/create_attack_discovery'; +import { getAttackDiscovery } from './get_attack_discovery/get_attack_discovery'; +import { + AIAssistantDataClient, + AIAssistantDataClientParams, +} from '../../../ai_assistant_data_clients'; type AttackDiscoveryDataClientParams = AIAssistantDataClientParams; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts similarity index 98% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts index d9a37582f48b0..765d40f7a3226 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/transforms.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transforms.ts @@ -7,7 +7,7 @@ import { estypes } from '@elastic/elasticsearch'; import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common'; -import { EsAttackDiscoverySchema } from './types'; +import { EsAttackDiscoverySchema } from '../types'; export const transformESSearchToAttackDiscovery = ( response: estypes.SearchResponse diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts similarity index 93% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts index 4a17c50e06af4..08be262fede5a 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/types.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/types.ts @@ -6,7 +6,7 @@ */ import { AttackDiscoveryStatus, Provider } from '@kbn/elastic-assistant-common'; -import { EsReplacementSchema } from '../conversations/types'; +import { EsReplacementSchema } from '../../../ai_assistant_data_clients/conversations/types'; export interface EsAttackDiscoverySchema { '@timestamp': string; @@ -53,7 +53,7 @@ export interface CreateAttackDiscoverySchema { title: string; timestamp: string; details_markdown: string; - entity_summary_markdown: string; + entity_summary_markdown?: string; mitre_attack_tactics?: string[]; summary_markdown: string; id?: string; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts similarity index 97% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts index 24deda445f320..8d98839c092aa 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.test.ts @@ -7,7 +7,7 @@ import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; -import { getAttackDiscovery } from './get_attack_discovery'; +import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; import { updateAttackDiscovery } from './update_attack_discovery'; import { AttackDiscoveryResponse, @@ -15,7 +15,7 @@ import { AttackDiscoveryUpdateProps, } from '@kbn/elastic-assistant-common'; import { AuthenticatedUser } from '@kbn/core-security-common'; -jest.mock('./get_attack_discovery'); +jest.mock('../get_attack_discovery/get_attack_discovery'); const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); const mockLogger = loggerMock.create(); const user = { diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts similarity index 95% rename from x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts index 73a386bbb4362..c810a71c5f1a3 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/attack_discovery/update_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/attack_discovery/persistence/update_attack_discovery/update_attack_discovery.ts @@ -14,8 +14,8 @@ import { UUID, } from '@kbn/elastic-assistant-common'; import * as uuid from 'uuid'; -import { EsReplacementSchema } from '../conversations/types'; -import { getAttackDiscovery } from './get_attack_discovery'; +import { EsReplacementSchema } from '../../../../ai_assistant_data_clients/conversations/types'; +import { getAttackDiscovery } from '../get_attack_discovery/get_attack_discovery'; export interface UpdateAttackDiscoverySchema { id: UUID; @@ -25,7 +25,7 @@ export interface UpdateAttackDiscoverySchema { title: string; timestamp: string; details_markdown: string; - entity_summary_markdown: string; + entity_summary_markdown?: string; mitre_attack_tactics?: string[]; summary_markdown: string; id?: string; diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts index 706da7197f31a..b9e4f85a800a0 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/index.ts @@ -10,14 +10,41 @@ import { GetDefaultAssistantGraphParams, DefaultAssistantGraph, } from './default_assistant_graph/graph'; +import { + DefaultAttackDiscoveryGraph, + GetDefaultAttackDiscoveryGraphParams, + getDefaultAttackDiscoveryGraph, +} from '../../attack_discovery/graphs/default_attack_discovery_graph'; export type GetAssistantGraph = (params: GetDefaultAssistantGraphParams) => DefaultAssistantGraph; +export type GetAttackDiscoveryGraph = ( + params: GetDefaultAttackDiscoveryGraphParams +) => DefaultAttackDiscoveryGraph; + +export type GraphType = 'assistant' | 'attack-discovery'; + +export interface AssistantGraphMetadata { + getDefaultAssistantGraph: GetAssistantGraph; + graphType: 'assistant'; +} + +export interface AttackDiscoveryGraphMetadata { + getDefaultAttackDiscoveryGraph: GetAttackDiscoveryGraph; + graphType: 'attack-discovery'; +} + +export type GraphMetadata = AssistantGraphMetadata | AttackDiscoveryGraphMetadata; /** * Map of the different Assistant Graphs. Useful for running evaluations. */ -export const ASSISTANT_GRAPH_MAP: Record = { - DefaultAssistantGraph: getDefaultAssistantGraph, - // TODO: Support additional graphs - // AttackDiscoveryGraph: getDefaultAssistantGraph, +export const ASSISTANT_GRAPH_MAP: Record = { + DefaultAssistantGraph: { + getDefaultAssistantGraph, + graphType: 'assistant', + }, + DefaultAttackDiscoveryGraph: { + getDefaultAttackDiscoveryGraph, + graphType: 'attack-discovery', + }, }; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts similarity index 85% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts index 74cf160c43ffe..ce07d66b9606e 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.test.ts @@ -8,15 +8,24 @@ import { getAttackDiscoveryRoute } from './get_attack_discovery'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { serverMock } from '../../__mocks__/server'; -import { requestContextMock } from '../../__mocks__/request_context'; +import { serverMock } from '../../../__mocks__/server'; +import { requestContextMock } from '../../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; -import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; -import { getAttackDiscoveryRequest } from '../../__mocks__/request'; -import { getAttackDiscoveryStats, updateAttackDiscoveryLastViewedAt } from './helpers'; -jest.mock('./helpers'); +import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; +import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; +import { getAttackDiscoveryRequest } from '../../../__mocks__/request'; +import { getAttackDiscoveryStats, updateAttackDiscoveryLastViewedAt } from '../helpers/helpers'; + +jest.mock('../helpers/helpers', () => { + const original = jest.requireActual('../helpers/helpers'); + + return { + ...original, + getAttackDiscoveryStats: jest.fn(), + updateAttackDiscoveryLastViewedAt: jest.fn(), + }; +}); const mockStats = { newConnectorResultsCount: 2, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts similarity index 92% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts index 09b2df98fe090..e3756b10a3fb3 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/get/get_attack_discovery.ts @@ -14,10 +14,10 @@ import { } from '@kbn/elastic-assistant-common'; import { transformError } from '@kbn/securitysolution-es-utils'; -import { updateAttackDiscoveryLastViewedAt, getAttackDiscoveryStats } from './helpers'; -import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../common/constants'; -import { buildResponse } from '../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { updateAttackDiscoveryLastViewedAt, getAttackDiscoveryStats } from '../helpers/helpers'; +import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../../common/constants'; +import { buildResponse } from '../../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../../types'; export const getAttackDiscoveryRoute = (router: IRouter) => { router.versioned diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts deleted file mode 100644 index d5eaf7d159618..0000000000000 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts +++ /dev/null @@ -1,805 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AuthenticatedUser } from '@kbn/core-security-common'; -import moment from 'moment'; -import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock'; - -import { - REQUIRED_FOR_ATTACK_DISCOVERY, - addGenerationInterval, - attackDiscoveryStatus, - getAssistantToolParams, - handleToolError, - updateAttackDiscoveryStatusToCanceled, - updateAttackDiscoveryStatusToRunning, - updateAttackDiscoveries, - getAttackDiscoveryStats, -} from './helpers'; -import { ActionsClientLlm } from '@kbn/langchain/server'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; -import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; -import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { loggerMock } from '@kbn/logging-mocks'; -import { KibanaRequest } from '@kbn/core-http-server'; -import { - AttackDiscoveryPostRequestBody, - ExecuteConnectorRequestBody, -} from '@kbn/elastic-assistant-common'; -import { coreMock } from '@kbn/core/server/mocks'; -import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; -import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; - -import { - getAnonymizationFieldMock, - getUpdateAnonymizationFieldSchemaMock, -} from '../../__mocks__/anonymization_fields_schema.mock'; - -jest.mock('lodash/fp', () => ({ - uniq: jest.fn((arr) => Array.from(new Set(arr))), -})); - -jest.mock('@kbn/securitysolution-es-utils', () => ({ - transformError: jest.fn((err) => err), -})); -jest.mock('@kbn/langchain/server', () => ({ - ActionsClientLlm: jest.fn(), -})); -jest.mock('../evaluate/utils', () => ({ - getLangSmithTracer: jest.fn().mockReturnValue([]), -})); -jest.mock('../utils', () => ({ - getLlmType: jest.fn().mockReturnValue('llm-type'), -})); -const findAttackDiscoveryByConnectorId = jest.fn(); -const updateAttackDiscovery = jest.fn(); -const createAttackDiscovery = jest.fn(); -const getAttackDiscovery = jest.fn(); -const findAllAttackDiscoveries = jest.fn(); -const mockDataClient = { - findAttackDiscoveryByConnectorId, - updateAttackDiscovery, - createAttackDiscovery, - getAttackDiscovery, - findAllAttackDiscoveries, -} as unknown as AttackDiscoveryDataClient; -const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); -const mockLogger = loggerMock.create(); -const mockTelemetry = coreMock.createSetup().analytics; -const mockError = new Error('Test error'); - -const mockAuthenticatedUser = { - username: 'user', - profile_uid: '1234', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; - -const mockApiConfig = { - connectorId: 'connector-id', - actionTypeId: '.bedrock', - model: 'model', - provider: OpenAiProviderType.OpenAi, -}; - -const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0]; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockRequest: KibanaRequest = {} as unknown as KibanaRequest< - unknown, - unknown, - any, // eslint-disable-line @typescript-eslint/no-explicit-any - any // eslint-disable-line @typescript-eslint/no-explicit-any ->; - -describe('helpers', () => { - const date = '2024-03-28T22:27:28.000Z'; - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - beforeEach(() => { - jest.clearAllMocks(); - jest.setSystemTime(new Date(date)); - getAttackDiscovery.mockResolvedValue(mockCurrentAd); - updateAttackDiscovery.mockResolvedValue({}); - }); - describe('getAssistantToolParams', () => { - const alertsIndexPattern = '.alerts-security.alerts-default'; - const esClient = elasticsearchClientMock.createElasticsearchClient(); - const actionsClient = actionsClientMock.create(); - const langChainTimeout = 1000; - const latestReplacements = {}; - const llm = new ActionsClientLlm({ - actionsClient, - connectorId: 'test-connecter-id', - llmType: 'bedrock', - logger: mockLogger, - temperature: 0, - timeout: 580000, - }); - const onNewReplacements = jest.fn(); - const size = 20; - - const mockParams = { - actionsClient, - alertsIndexPattern: 'alerts-*', - anonymizationFields: [{ id: '1', field: 'field1', allowed: true, anonymized: true }], - apiConfig: mockApiConfig, - esClient: mockEsClient, - connectorTimeout: 1000, - langChainTimeout: 2000, - langSmithProject: 'project', - langSmithApiKey: 'api-key', - logger: mockLogger, - latestReplacements: {}, - onNewReplacements: jest.fn(), - request: {} as KibanaRequest< - unknown, - unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody - >, - size: 10, - }; - - it('should return formatted assistant tool params', () => { - const result = getAssistantToolParams(mockParams); - - expect(ActionsClientLlm).toHaveBeenCalledWith( - expect.objectContaining({ - connectorId: 'connector-id', - llmType: 'llm-type', - }) - ); - expect(result.anonymizationFields).toEqual([ - ...mockParams.anonymizationFields, - ...REQUIRED_FOR_ATTACK_DISCOVERY, - ]); - }); - - it('returns the expected AssistantToolParams when anonymizationFields are provided', () => { - const anonymizationFields = [ - getAnonymizationFieldMock(getUpdateAnonymizationFieldSchemaMock()), - ]; - - const result = getAssistantToolParams({ - actionsClient, - alertsIndexPattern, - apiConfig: mockApiConfig, - anonymizationFields, - connectorTimeout: 1000, - latestReplacements, - esClient, - langChainTimeout, - logger: mockLogger, - onNewReplacements, - request: mockRequest, - size, - }); - - expect(result).toEqual({ - alertsIndexPattern, - anonymizationFields: [...anonymizationFields, ...REQUIRED_FOR_ATTACK_DISCOVERY], - isEnabledKnowledgeBase: false, - chain: undefined, - esClient, - langChainTimeout, - llm, - logger: mockLogger, - onNewReplacements, - replacements: latestReplacements, - request: mockRequest, - size, - }); - }); - - it('returns the expected AssistantToolParams when anonymizationFields is undefined', () => { - const anonymizationFields = undefined; - - const result = getAssistantToolParams({ - actionsClient, - alertsIndexPattern, - apiConfig: mockApiConfig, - anonymizationFields, - connectorTimeout: 1000, - latestReplacements, - esClient, - langChainTimeout, - logger: mockLogger, - onNewReplacements, - request: mockRequest, - size, - }); - - expect(result).toEqual({ - alertsIndexPattern, - anonymizationFields: [...REQUIRED_FOR_ATTACK_DISCOVERY], - isEnabledKnowledgeBase: false, - chain: undefined, - esClient, - langChainTimeout, - llm, - logger: mockLogger, - onNewReplacements, - replacements: latestReplacements, - request: mockRequest, - size, - }); - }); - - describe('addGenerationInterval', () => { - const generationInterval = { date: '2024-01-01T00:00:00Z', durationMs: 1000 }; - const existingIntervals = [ - { date: '2024-01-02T00:00:00Z', durationMs: 2000 }, - { date: '2024-01-03T00:00:00Z', durationMs: 3000 }, - ]; - - it('should add new interval and maintain length within MAX_GENERATION_INTERVALS', () => { - const result = addGenerationInterval(existingIntervals, generationInterval); - expect(result.length).toBeLessThanOrEqual(5); - expect(result).toContain(generationInterval); - }); - - it('should remove the oldest interval if exceeding MAX_GENERATION_INTERVALS', () => { - const longExistingIntervals = [...Array(5)].map((_, i) => ({ - date: `2024-01-0${i + 2}T00:00:00Z`, - durationMs: (i + 2) * 1000, - })); - const result = addGenerationInterval(longExistingIntervals, generationInterval); - expect(result.length).toBe(5); - expect(result).not.toContain(longExistingIntervals[4]); - }); - }); - - describe('updateAttackDiscoveryStatusToRunning', () => { - it('should update existing attack discovery to running', async () => { - const existingAd = { id: 'existing-id', backingIndex: 'index' }; - findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); - updateAttackDiscovery.mockResolvedValue(existingAd); - - const result = await updateAttackDiscoveryStatusToRunning( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig - ); - - expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({ - connectorId: mockApiConfig.connectorId, - authenticatedUser: mockAuthenticatedUser, - }); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: expect.objectContaining({ - status: attackDiscoveryStatus.running, - }), - authenticatedUser: mockAuthenticatedUser, - }); - expect(result).toEqual({ attackDiscoveryId: existingAd.id, currentAd: existingAd }); - }); - - it('should create a new attack discovery if none exists', async () => { - const newAd = { id: 'new-id', backingIndex: 'index' }; - findAttackDiscoveryByConnectorId.mockResolvedValue(null); - createAttackDiscovery.mockResolvedValue(newAd); - - const result = await updateAttackDiscoveryStatusToRunning( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig - ); - - expect(createAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryCreate: expect.objectContaining({ - status: attackDiscoveryStatus.running, - }), - authenticatedUser: mockAuthenticatedUser, - }); - expect(result).toEqual({ attackDiscoveryId: newAd.id, currentAd: newAd }); - }); - - it('should throw an error if updating or creating attack discovery fails', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(null); - createAttackDiscovery.mockResolvedValue(null); - - await expect( - updateAttackDiscoveryStatusToRunning(mockDataClient, mockAuthenticatedUser, mockApiConfig) - ).rejects.toThrow('Could not create attack discovery for connectorId: connector-id'); - }); - }); - - describe('updateAttackDiscoveryStatusToCanceled', () => { - const existingAd = { - id: 'existing-id', - backingIndex: 'index', - status: attackDiscoveryStatus.running, - }; - it('should update existing attack discovery to canceled', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); - updateAttackDiscovery.mockResolvedValue(existingAd); - - const result = await updateAttackDiscoveryStatusToCanceled( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig.connectorId - ); - - expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({ - connectorId: mockApiConfig.connectorId, - authenticatedUser: mockAuthenticatedUser, - }); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: expect.objectContaining({ - status: attackDiscoveryStatus.canceled, - }), - authenticatedUser: mockAuthenticatedUser, - }); - expect(result).toEqual(existingAd); - }); - - it('should throw an error if attack discovery is not running', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue({ - ...existingAd, - status: attackDiscoveryStatus.succeeded, - }); - await expect( - updateAttackDiscoveryStatusToCanceled( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig.connectorId - ) - ).rejects.toThrow( - 'Connector id connector-id does not have a running attack discovery, and therefore cannot be canceled.' - ); - }); - - it('should throw an error if attack discovery does not exist', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(null); - await expect( - updateAttackDiscoveryStatusToCanceled( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig.connectorId - ) - ).rejects.toThrow('Could not find attack discovery for connector id: connector-id'); - }); - it('should throw error if updateAttackDiscovery returns null', async () => { - findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd); - updateAttackDiscovery.mockResolvedValue(null); - - await expect( - updateAttackDiscoveryStatusToCanceled( - mockDataClient, - mockAuthenticatedUser, - mockApiConfig.connectorId - ) - ).rejects.toThrow('Could not update attack discovery for connector id: connector-id'); - }); - }); - - describe('updateAttackDiscoveries', () => { - const mockAttackDiscoveryId = 'attack-discovery-id'; - const mockLatestReplacements = {}; - const mockRawAttackDiscoveries = JSON.stringify({ - alertsContextCount: 5, - attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }], - }); - const mockSize = 10; - const mockStartTime = moment('2024-03-28T22:25:28.000Z'); - - const mockArgs = { - apiConfig: mockApiConfig, - attackDiscoveryId: mockAttackDiscoveryId, - authenticatedUser: mockAuthenticatedUser, - dataClient: mockDataClient, - latestReplacements: mockLatestReplacements, - logger: mockLogger, - rawAttackDiscoveries: mockRawAttackDiscoveries, - size: mockSize, - startTime: mockStartTime, - telemetry: mockTelemetry, - }; - - it('should update attack discoveries and report success telemetry', async () => { - await updateAttackDiscoveries(mockArgs); - - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - alertsContextCount: 5, - attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }], - status: attackDiscoveryStatus.succeeded, - id: mockAttackDiscoveryId, - replacements: mockLatestReplacements, - backingIndex: mockCurrentAd.backingIndex, - generationIntervals: [ - { date, durationMs: 120000 }, - ...mockCurrentAd.generationIntervals, - ], - }, - authenticatedUser: mockAuthenticatedUser, - }); - - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', { - actionTypeId: mockApiConfig.actionTypeId, - alertsContextCount: 5, - alertsCount: 3, - configuredAlertsCount: mockSize, - discoveriesGenerated: 2, - durationMs: 120000, - model: mockApiConfig.model, - provider: mockApiConfig.provider, - }); - }); - - it('should update attack discoveries without generation interval if no discoveries are found', async () => { - const noDiscoveriesRaw = JSON.stringify({ - alertsContextCount: 0, - attackDiscoveries: [], - }); - - await updateAttackDiscoveries({ - ...mockArgs, - rawAttackDiscoveries: noDiscoveriesRaw, - }); - - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - alertsContextCount: 0, - attackDiscoveries: [], - status: attackDiscoveryStatus.succeeded, - id: mockAttackDiscoveryId, - replacements: mockLatestReplacements, - backingIndex: mockCurrentAd.backingIndex, - }, - authenticatedUser: mockAuthenticatedUser, - }); - - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', { - actionTypeId: mockApiConfig.actionTypeId, - alertsContextCount: 0, - alertsCount: 0, - configuredAlertsCount: mockSize, - discoveriesGenerated: 0, - durationMs: 120000, - model: mockApiConfig.model, - provider: mockApiConfig.provider, - }); - }); - - it('should catch and log an error if raw attack discoveries is null', async () => { - await updateAttackDiscoveries({ - ...mockArgs, - rawAttackDiscoveries: null, - }); - expect(mockLogger.error).toHaveBeenCalledTimes(1); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: 'tool returned no attack discoveries', - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - - it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => { - getAttackDiscovery.mockResolvedValue({ - ...mockCurrentAd, - status: attackDiscoveryStatus.canceled, - }); - await updateAttackDiscoveries(mockArgs); - - expect(mockLogger.error).not.toHaveBeenCalled(); - expect(updateAttackDiscovery).not.toHaveBeenCalled(); - }); - - it('should log the error and report telemetry when getAttackDiscovery rejects', async () => { - getAttackDiscovery.mockRejectedValue(mockError); - await updateAttackDiscoveries(mockArgs); - - expect(mockLogger.error).toHaveBeenCalledWith(mockError); - expect(updateAttackDiscovery).not.toHaveBeenCalled(); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: mockError.message, - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - }); - - describe('handleToolError', () => { - const mockArgs = { - apiConfig: mockApiConfig, - attackDiscoveryId: 'discovery-id', - authenticatedUser: mockAuthenticatedUser, - backingIndex: 'backing-index', - dataClient: mockDataClient, - err: mockError, - latestReplacements: {}, - logger: mockLogger, - telemetry: mockTelemetry, - }; - - it('should log the error and update attack discovery status to failed', async () => { - await handleToolError(mockArgs); - - expect(mockLogger.error).toHaveBeenCalledWith(mockError); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - status: attackDiscoveryStatus.failed, - attackDiscoveries: [], - backingIndex: 'foo', - failureReason: 'Test error', - id: 'discovery-id', - replacements: {}, - }, - authenticatedUser: mockArgs.authenticatedUser, - }); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: mockError.message, - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - - it('should log the error and report telemetry when updateAttackDiscovery rejects', async () => { - updateAttackDiscovery.mockRejectedValue(mockError); - await handleToolError(mockArgs); - - expect(mockLogger.error).toHaveBeenCalledWith(mockError); - expect(updateAttackDiscovery).toHaveBeenCalledWith({ - attackDiscoveryUpdateProps: { - status: attackDiscoveryStatus.failed, - attackDiscoveries: [], - backingIndex: 'foo', - failureReason: 'Test error', - id: 'discovery-id', - replacements: {}, - }, - authenticatedUser: mockArgs.authenticatedUser, - }); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: mockError.message, - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - - it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => { - getAttackDiscovery.mockResolvedValue({ - ...mockCurrentAd, - status: attackDiscoveryStatus.canceled, - }); - await handleToolError(mockArgs); - - expect(mockTelemetry.reportEvent).not.toHaveBeenCalled(); - expect(updateAttackDiscovery).not.toHaveBeenCalled(); - }); - - it('should log the error and report telemetry when getAttackDiscovery rejects', async () => { - getAttackDiscovery.mockRejectedValue(mockError); - await handleToolError(mockArgs); - - expect(mockLogger.error).toHaveBeenCalledWith(mockError); - expect(updateAttackDiscovery).not.toHaveBeenCalled(); - expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', { - actionTypeId: mockArgs.apiConfig.actionTypeId, - errorMessage: mockError.message, - model: mockArgs.apiConfig.model, - provider: mockArgs.apiConfig.provider, - }); - }); - }); - }); - describe('getAttackDiscoveryStats', () => { - const mockDiscoveries = [ - { - timestamp: '2024-06-13T17:55:11.360Z', - id: '8abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:55:11.360Z', - updatedAt: '2024-06-17T20:47:57.556Z', - lastViewedAt: '2024-06-17T20:47:57.556Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'failed', - alertsContextCount: undefined, - apiConfig: { - connectorId: 'my-bedrock-old', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: - 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', - }, - { - timestamp: '2024-06-13T17:55:11.360Z', - id: '9abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:55:11.360Z', - updatedAt: '2024-06-17T20:47:57.556Z', - lastViewedAt: '2024-06-17T20:46:57.556Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'failed', - alertsContextCount: undefined, - apiConfig: { - connectorId: 'my-bedrock-old', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: - 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', - }, - { - timestamp: '2024-06-12T19:54:50.428Z', - id: '745e005b-7248-4c08-b8b6-4cad263b4be0', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T19:54:50.428Z', - updatedAt: '2024-06-17T20:47:27.182Z', - lastViewedAt: '2024-06-17T20:27:27.182Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'running', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-gen-ai', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-13T17:50:59.409Z', - id: 'f48da2ca-b63e-4387-82d7-1423a68500aa', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-13T17:50:59.409Z', - updatedAt: '2024-06-17T20:47:59.969Z', - lastViewedAt: '2024-06-17T20:47:35.227Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'succeeded', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-gpt4o-ai', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-12T21:18:56.377Z', - id: '82fced1d-de48-42db-9f56-e45122dee017', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T21:18:56.377Z', - updatedAt: '2024-06-17T20:47:39.372Z', - lastViewedAt: '2024-06-17T20:47:39.372Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'canceled', - alertsContextCount: 20, - apiConfig: { - connectorId: 'my-bedrock', - actionTypeId: '.bedrock', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: mockCurrentAd.attackDiscoveries, - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: undefined, - }, - { - timestamp: '2024-06-12T16:44:23.107Z', - id: 'a4709094-6116-484b-b096-1e8d151cb4b7', - backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', - createdAt: '2024-06-12T16:44:23.107Z', - updatedAt: '2024-06-17T20:48:16.961Z', - lastViewedAt: '2024-06-17T20:47:16.961Z', - users: [mockAuthenticatedUser], - namespace: 'default', - status: 'succeeded', - alertsContextCount: 0, - apiConfig: { - connectorId: 'my-gen-a2i', - actionTypeId: '.gen-ai', - defaultSystemPromptId: undefined, - model: undefined, - provider: undefined, - }, - attackDiscoveries: [ - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ...mockCurrentAd.attackDiscoveries, - ], - replacements: {}, - generationIntervals: mockCurrentAd.generationIntervals, - averageIntervalMs: mockCurrentAd.averageIntervalMs, - failureReason: 'steph threw an error', - }, - ]; - beforeEach(() => { - findAllAttackDiscoveries.mockResolvedValue(mockDiscoveries); - }); - it('returns the formatted stats object', async () => { - const stats = await getAttackDiscoveryStats({ - authenticatedUser: mockAuthenticatedUser, - dataClient: mockDataClient, - }); - expect(stats).toEqual([ - { - hasViewed: true, - status: 'failed', - count: 0, - connectorId: 'my-bedrock-old', - }, - { - hasViewed: false, - status: 'failed', - count: 0, - connectorId: 'my-bedrock-old', - }, - { - hasViewed: false, - status: 'running', - count: 1, - connectorId: 'my-gen-ai', - }, - { - hasViewed: false, - status: 'succeeded', - count: 1, - connectorId: 'my-gpt4o-ai', - }, - { - hasViewed: true, - status: 'canceled', - count: 1, - connectorId: 'my-bedrock', - }, - { - hasViewed: false, - status: 'succeeded', - count: 4, - connectorId: 'my-gen-a2i', - }, - ]); - }); - }); -}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts new file mode 100644 index 0000000000000..2e0a545eb083a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.test.ts @@ -0,0 +1,273 @@ +/* + * 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 { AuthenticatedUser } from '@kbn/core-security-common'; + +import { getAttackDiscoveryStats } from './helpers'; +import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; +import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; + +jest.mock('lodash/fp', () => ({ + uniq: jest.fn((arr) => Array.from(new Set(arr))), +})); + +jest.mock('@kbn/securitysolution-es-utils', () => ({ + transformError: jest.fn((err) => err), +})); +jest.mock('@kbn/langchain/server', () => ({ + ActionsClientLlm: jest.fn(), +})); +jest.mock('../../evaluate/utils', () => ({ + getLangSmithTracer: jest.fn().mockReturnValue([]), +})); +jest.mock('../../utils', () => ({ + getLlmType: jest.fn().mockReturnValue('llm-type'), +})); +const findAttackDiscoveryByConnectorId = jest.fn(); +const updateAttackDiscovery = jest.fn(); +const createAttackDiscovery = jest.fn(); +const getAttackDiscovery = jest.fn(); +const findAllAttackDiscoveries = jest.fn(); +const mockDataClient = { + findAttackDiscoveryByConnectorId, + updateAttackDiscovery, + createAttackDiscovery, + getAttackDiscovery, + findAllAttackDiscoveries, +} as unknown as AttackDiscoveryDataClient; + +const mockAuthenticatedUser = { + username: 'user', + profile_uid: '1234', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, +} as AuthenticatedUser; + +const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0]; + +describe('helpers', () => { + const date = '2024-03-28T22:27:28.000Z'; + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + beforeEach(() => { + jest.clearAllMocks(); + jest.setSystemTime(new Date(date)); + getAttackDiscovery.mockResolvedValue(mockCurrentAd); + updateAttackDiscovery.mockResolvedValue({}); + }); + + describe('getAttackDiscoveryStats', () => { + const mockDiscoveries = [ + { + timestamp: '2024-06-13T17:55:11.360Z', + id: '8abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:55:11.360Z', + updatedAt: '2024-06-17T20:47:57.556Z', + lastViewedAt: '2024-06-17T20:47:57.556Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'failed', + alertsContextCount: undefined, + apiConfig: { + connectorId: 'my-bedrock-old', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: + 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', + }, + { + timestamp: '2024-06-13T17:55:11.360Z', + id: '9abb49bd-2f5d-43d2-bc2f-dd3c3cab25ad', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:55:11.360Z', + updatedAt: '2024-06-17T20:47:57.556Z', + lastViewedAt: '2024-06-17T20:46:57.556Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'failed', + alertsContextCount: undefined, + apiConfig: { + connectorId: 'my-bedrock-old', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: + 'ActionsClientLlm: action result status is error: an error occurred while running the action - Response validation failed (Error: [usage.input_tokens]: expected value of type [number] but got [undefined])', + }, + { + timestamp: '2024-06-12T19:54:50.428Z', + id: '745e005b-7248-4c08-b8b6-4cad263b4be0', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T19:54:50.428Z', + updatedAt: '2024-06-17T20:47:27.182Z', + lastViewedAt: '2024-06-17T20:27:27.182Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'running', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-gen-ai', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-13T17:50:59.409Z', + id: 'f48da2ca-b63e-4387-82d7-1423a68500aa', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-13T17:50:59.409Z', + updatedAt: '2024-06-17T20:47:59.969Z', + lastViewedAt: '2024-06-17T20:47:35.227Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'succeeded', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-gpt4o-ai', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-12T21:18:56.377Z', + id: '82fced1d-de48-42db-9f56-e45122dee017', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T21:18:56.377Z', + updatedAt: '2024-06-17T20:47:39.372Z', + lastViewedAt: '2024-06-17T20:47:39.372Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'canceled', + alertsContextCount: 20, + apiConfig: { + connectorId: 'my-bedrock', + actionTypeId: '.bedrock', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: mockCurrentAd.attackDiscoveries, + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: undefined, + }, + { + timestamp: '2024-06-12T16:44:23.107Z', + id: 'a4709094-6116-484b-b096-1e8d151cb4b7', + backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001', + createdAt: '2024-06-12T16:44:23.107Z', + updatedAt: '2024-06-17T20:48:16.961Z', + lastViewedAt: '2024-06-17T20:47:16.961Z', + users: [mockAuthenticatedUser], + namespace: 'default', + status: 'succeeded', + alertsContextCount: 0, + apiConfig: { + connectorId: 'my-gen-a2i', + actionTypeId: '.gen-ai', + defaultSystemPromptId: undefined, + model: undefined, + provider: undefined, + }, + attackDiscoveries: [ + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ...mockCurrentAd.attackDiscoveries, + ], + replacements: {}, + generationIntervals: mockCurrentAd.generationIntervals, + averageIntervalMs: mockCurrentAd.averageIntervalMs, + failureReason: 'steph threw an error', + }, + ]; + beforeEach(() => { + findAllAttackDiscoveries.mockResolvedValue(mockDiscoveries); + }); + it('returns the formatted stats object', async () => { + const stats = await getAttackDiscoveryStats({ + authenticatedUser: mockAuthenticatedUser, + dataClient: mockDataClient, + }); + expect(stats).toEqual([ + { + hasViewed: true, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'failed', + count: 0, + connectorId: 'my-bedrock-old', + }, + { + hasViewed: false, + status: 'running', + count: 1, + connectorId: 'my-gen-ai', + }, + { + hasViewed: false, + status: 'succeeded', + count: 1, + connectorId: 'my-gpt4o-ai', + }, + { + hasViewed: true, + status: 'canceled', + count: 1, + connectorId: 'my-bedrock', + }, + { + hasViewed: false, + status: 'succeeded', + count: 4, + connectorId: 'my-gen-a2i', + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts similarity index 55% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts index f016d6ac29118..188976f0b3f5c 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers/helpers.ts @@ -5,38 +5,29 @@ * 2.0. */ -import { AnalyticsServiceSetup, AuthenticatedUser, KibanaRequest, Logger } from '@kbn/core/server'; -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { AnalyticsServiceSetup, AuthenticatedUser, Logger } from '@kbn/core/server'; import { ApiConfig, AttackDiscovery, - AttackDiscoveryPostRequestBody, AttackDiscoveryResponse, AttackDiscoveryStat, AttackDiscoveryStatus, - ExecuteConnectorRequestBody, GenerationInterval, Replacements, } from '@kbn/elastic-assistant-common'; import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import type { Document } from '@langchain/core/documents'; import { v4 as uuidv4 } from 'uuid'; -import { ActionsClientLlm } from '@kbn/langchain/server'; - import { Moment } from 'moment'; import { transformError } from '@kbn/securitysolution-es-utils'; -import type { ActionsClient } from '@kbn/actions-plugin/server'; import moment from 'moment/moment'; import { uniq } from 'lodash/fp'; -import { PublicMethodsOf } from '@kbn/utility-types'; -import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; -import { getLlmType } from '../utils'; -import type { GetRegisteredTools } from '../../services/app_context'; + import { ATTACK_DISCOVERY_ERROR_EVENT, ATTACK_DISCOVERY_SUCCESS_EVENT, -} from '../../lib/telemetry/event_based_telemetry'; -import { AssistantToolParams } from '../../types'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; +} from '../../../lib/telemetry/event_based_telemetry'; +import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [ { @@ -53,116 +44,6 @@ export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [ }, ]; -export const getAssistantToolParams = ({ - actionsClient, - alertsIndexPattern, - anonymizationFields, - apiConfig, - esClient, - connectorTimeout, - langChainTimeout, - langSmithProject, - langSmithApiKey, - logger, - latestReplacements, - onNewReplacements, - request, - size, -}: { - actionsClient: PublicMethodsOf; - alertsIndexPattern: string; - anonymizationFields?: AnonymizationFieldResponse[]; - apiConfig: ApiConfig; - esClient: ElasticsearchClient; - connectorTimeout: number; - langChainTimeout: number; - langSmithProject?: string; - langSmithApiKey?: string; - logger: Logger; - latestReplacements: Replacements; - onNewReplacements: (newReplacements: Replacements) => void; - request: KibanaRequest< - unknown, - unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody - >; - size: number; -}) => { - const traceOptions = { - projectName: langSmithProject, - tracers: [ - ...getLangSmithTracer({ - apiKey: langSmithApiKey, - projectName: langSmithProject, - logger, - }), - ], - }; - - const llm = new ActionsClientLlm({ - actionsClient, - connectorId: apiConfig.connectorId, - llmType: getLlmType(apiConfig.actionTypeId), - logger, - temperature: 0, // zero temperature for attack discovery, because we want structured JSON output - timeout: connectorTimeout, - traceOptions, - }); - - return formatAssistantToolParams({ - alertsIndexPattern, - anonymizationFields, - esClient, - latestReplacements, - langChainTimeout, - llm, - logger, - onNewReplacements, - request, - size, - }); -}; - -const formatAssistantToolParams = ({ - alertsIndexPattern, - anonymizationFields, - esClient, - langChainTimeout, - latestReplacements, - llm, - logger, - onNewReplacements, - request, - size, -}: { - alertsIndexPattern: string; - anonymizationFields?: AnonymizationFieldResponse[]; - esClient: ElasticsearchClient; - langChainTimeout: number; - latestReplacements: Replacements; - llm: ActionsClientLlm; - logger: Logger; - onNewReplacements: (newReplacements: Replacements) => void; - request: KibanaRequest< - unknown, - unknown, - ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody - >; - size: number; -}): Omit => ({ - alertsIndexPattern, - anonymizationFields: [...(anonymizationFields ?? []), ...REQUIRED_FOR_ATTACK_DISCOVERY], - isEnabledKnowledgeBase: false, // not required for attack discovery - esClient, - langChainTimeout, - llm, - logger, - onNewReplacements, - replacements: latestReplacements, - request, - size, -}); - export const attackDiscoveryStatus: { [k: string]: AttackDiscoveryStatus } = { canceled: 'canceled', failed: 'failed', @@ -187,7 +68,8 @@ export const addGenerationInterval = ( export const updateAttackDiscoveryStatusToRunning = async ( dataClient: AttackDiscoveryDataClient, authenticatedUser: AuthenticatedUser, - apiConfig: ApiConfig + apiConfig: ApiConfig, + alertsContextCount: number ): Promise<{ currentAd: AttackDiscoveryResponse; attackDiscoveryId: string; @@ -199,6 +81,7 @@ export const updateAttackDiscoveryStatusToRunning = async ( const currentAd = foundAttackDiscovery ? await dataClient?.updateAttackDiscovery({ attackDiscoveryUpdateProps: { + alertsContextCount, backingIndex: foundAttackDiscovery.backingIndex, id: foundAttackDiscovery.id, status: attackDiscoveryStatus.running, @@ -207,6 +90,7 @@ export const updateAttackDiscoveryStatusToRunning = async ( }) : await dataClient?.createAttackDiscovery({ attackDiscoveryCreate: { + alertsContextCount, apiConfig, attackDiscoveries: [], status: attackDiscoveryStatus.running, @@ -261,38 +145,32 @@ export const updateAttackDiscoveryStatusToCanceled = async ( return updatedAttackDiscovery; }; -const getDataFromJSON = (adStringified: string) => { - const { alertsContextCount, attackDiscoveries } = JSON.parse(adStringified); - return { alertsContextCount, attackDiscoveries }; -}; - export const updateAttackDiscoveries = async ({ + anonymizedAlerts, apiConfig, + attackDiscoveries, attackDiscoveryId, authenticatedUser, dataClient, latestReplacements, logger, - rawAttackDiscoveries, size, startTime, telemetry, }: { + anonymizedAlerts: Document[]; apiConfig: ApiConfig; + attackDiscoveries: AttackDiscovery[] | null; attackDiscoveryId: string; authenticatedUser: AuthenticatedUser; dataClient: AttackDiscoveryDataClient; latestReplacements: Replacements; logger: Logger; - rawAttackDiscoveries: string | null; size: number; startTime: Moment; telemetry: AnalyticsServiceSetup; }) => { try { - if (rawAttackDiscoveries == null) { - throw new Error('tool returned no attack discoveries'); - } const currentAd = await dataClient.getAttackDiscovery({ id: attackDiscoveryId, authenticatedUser, @@ -302,12 +180,12 @@ export const updateAttackDiscoveries = async ({ } const endTime = moment(); const durationMs = endTime.diff(startTime); - const { alertsContextCount, attackDiscoveries } = getDataFromJSON(rawAttackDiscoveries); + const alertsContextCount = anonymizedAlerts.length; const updateProps = { alertsContextCount, - attackDiscoveries, + attackDiscoveries: attackDiscoveries ?? undefined, status: attackDiscoveryStatus.succeeded, - ...(alertsContextCount === 0 || attackDiscoveries === 0 + ...(alertsContextCount === 0 ? {} : { generationIntervals: addGenerationInterval(currentAd.generationIntervals, { @@ -327,13 +205,14 @@ export const updateAttackDiscoveries = async ({ telemetry.reportEvent(ATTACK_DISCOVERY_SUCCESS_EVENT.eventType, { actionTypeId: apiConfig.actionTypeId, alertsContextCount: updateProps.alertsContextCount, - alertsCount: uniq( - updateProps.attackDiscoveries.flatMap( - (attackDiscovery: AttackDiscovery) => attackDiscovery.alertIds - ) - ).length, + alertsCount: + uniq( + updateProps.attackDiscoveries?.flatMap( + (attackDiscovery: AttackDiscovery) => attackDiscovery.alertIds + ) + ).length ?? 0, configuredAlertsCount: size, - discoveriesGenerated: updateProps.attackDiscoveries.length, + discoveriesGenerated: updateProps.attackDiscoveries?.length ?? 0, durationMs, model: apiConfig.model, provider: apiConfig.provider, @@ -350,70 +229,6 @@ export const updateAttackDiscoveries = async ({ } }; -export const handleToolError = async ({ - apiConfig, - attackDiscoveryId, - authenticatedUser, - dataClient, - err, - latestReplacements, - logger, - telemetry, -}: { - apiConfig: ApiConfig; - attackDiscoveryId: string; - authenticatedUser: AuthenticatedUser; - dataClient: AttackDiscoveryDataClient; - err: Error; - latestReplacements: Replacements; - logger: Logger; - telemetry: AnalyticsServiceSetup; -}) => { - try { - logger.error(err); - const error = transformError(err); - const currentAd = await dataClient.getAttackDiscovery({ - id: attackDiscoveryId, - authenticatedUser, - }); - - if (currentAd === null || currentAd?.status === 'canceled') { - return; - } - await dataClient.updateAttackDiscovery({ - attackDiscoveryUpdateProps: { - attackDiscoveries: [], - status: attackDiscoveryStatus.failed, - id: attackDiscoveryId, - replacements: latestReplacements, - backingIndex: currentAd.backingIndex, - failureReason: error.message, - }, - authenticatedUser, - }); - telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { - actionTypeId: apiConfig.actionTypeId, - errorMessage: error.message, - model: apiConfig.model, - provider: apiConfig.provider, - }); - } catch (updateErr) { - const updateError = transformError(updateErr); - telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { - actionTypeId: apiConfig.actionTypeId, - errorMessage: updateError.message, - model: apiConfig.model, - provider: apiConfig.provider, - }); - } -}; - -export const getAssistantTool = (getRegisteredTools: GetRegisteredTools, pluginName: string) => { - // get the attack discovery tool: - const assistantTools = getRegisteredTools(pluginName); - return assistantTools.find((tool) => tool.id === 'attack-discovery'); -}; - export const updateAttackDiscoveryLastViewedAt = async ({ connectorId, authenticatedUser, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts similarity index 80% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts index 66aca77f1eb8b..9f5efbe5041d5 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.test.ts @@ -8,15 +8,23 @@ import { cancelAttackDiscoveryRoute } from './cancel_attack_discovery'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { serverMock } from '../../__mocks__/server'; -import { requestContextMock } from '../../__mocks__/request_context'; +import { serverMock } from '../../../../__mocks__/server'; +import { requestContextMock } from '../../../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; -import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; -import { getCancelAttackDiscoveryRequest } from '../../__mocks__/request'; -import { updateAttackDiscoveryStatusToCanceled } from './helpers'; -jest.mock('./helpers'); +import { AttackDiscoveryDataClient } from '../../../../lib/attack_discovery/persistence'; +import { transformESSearchToAttackDiscovery } from '../../../../lib/attack_discovery/persistence/transforms/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../../../__mocks__/attack_discovery_schema.mock'; +import { getCancelAttackDiscoveryRequest } from '../../../../__mocks__/request'; +import { updateAttackDiscoveryStatusToCanceled } from '../../helpers/helpers'; + +jest.mock('../../helpers/helpers', () => { + const original = jest.requireActual('../../helpers/helpers'); + + return { + ...original, + updateAttackDiscoveryStatusToCanceled: jest.fn(), + }; +}); const { clients, context } = requestContextMock.createTools(); const server: ReturnType = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts similarity index 91% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts index 47b748c9c432a..86631708b1cf7 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/cancel_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/cancel/cancel_attack_discovery.ts @@ -14,16 +14,16 @@ import { } from '@kbn/elastic-assistant-common'; import { transformError } from '@kbn/securitysolution-es-utils'; -import { updateAttackDiscoveryStatusToCanceled } from './helpers'; -import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID } from '../../../common/constants'; -import { buildResponse } from '../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { updateAttackDiscoveryStatusToCanceled } from '../../helpers/helpers'; +import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID } from '../../../../../common/constants'; +import { buildResponse } from '../../../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../../../types'; export const cancelAttackDiscoveryRoute = ( router: IRouter ) => { router.versioned - .put({ + .post({ access: 'internal', path: ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID, options: { diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx new file mode 100644 index 0000000000000..e58b67bdcc1ad --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/handle_graph_error/index.tsx @@ -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 { AnalyticsServiceSetup, AuthenticatedUser, Logger } from '@kbn/core/server'; +import { ApiConfig, Replacements } from '@kbn/elastic-assistant-common'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { AttackDiscoveryDataClient } from '../../../../../lib/attack_discovery/persistence'; +import { attackDiscoveryStatus } from '../../../helpers/helpers'; +import { ATTACK_DISCOVERY_ERROR_EVENT } from '../../../../../lib/telemetry/event_based_telemetry'; + +export const handleGraphError = async ({ + apiConfig, + attackDiscoveryId, + authenticatedUser, + dataClient, + err, + latestReplacements, + logger, + telemetry, +}: { + apiConfig: ApiConfig; + attackDiscoveryId: string; + authenticatedUser: AuthenticatedUser; + dataClient: AttackDiscoveryDataClient; + err: Error; + latestReplacements: Replacements; + logger: Logger; + telemetry: AnalyticsServiceSetup; +}) => { + try { + logger.error(err); + const error = transformError(err); + const currentAd = await dataClient.getAttackDiscovery({ + id: attackDiscoveryId, + authenticatedUser, + }); + + if (currentAd === null || currentAd?.status === 'canceled') { + return; + } + + await dataClient.updateAttackDiscovery({ + attackDiscoveryUpdateProps: { + attackDiscoveries: [], + status: attackDiscoveryStatus.failed, + id: attackDiscoveryId, + replacements: latestReplacements, + backingIndex: currentAd.backingIndex, + failureReason: error.message, + }, + authenticatedUser, + }); + telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: error.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } catch (updateErr) { + const updateError = transformError(updateErr); + telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, { + actionTypeId: apiConfig.actionTypeId, + errorMessage: updateError.message, + model: apiConfig.model, + provider: apiConfig.provider, + }); + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx new file mode 100644 index 0000000000000..8a8c49f796500 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/invoke_attack_discovery_graph/index.tsx @@ -0,0 +1,127 @@ +/* + * 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 { ActionsClient } from '@kbn/actions-plugin/server'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { Logger } from '@kbn/core/server'; +import { ApiConfig, AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen'; +import { ActionsClientLlm } from '@kbn/langchain/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import type { Document } from '@langchain/core/documents'; + +import { getDefaultAttackDiscoveryGraph } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph'; +import { + ATTACK_DISCOVERY_GRAPH_RUN_NAME, + ATTACK_DISCOVERY_TAG, +} from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/constants'; +import { GraphState } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/types'; +import { throwIfErrorCountsExceeded } from '../throw_if_error_counts_exceeded'; +import { getLlmType } from '../../../../utils'; + +export const invokeAttackDiscoveryGraph = async ({ + actionsClient, + alertsIndexPattern, + anonymizationFields, + apiConfig, + connectorTimeout, + esClient, + langSmithProject, + langSmithApiKey, + latestReplacements, + logger, + onNewReplacements, + size, +}: { + actionsClient: PublicMethodsOf; + alertsIndexPattern: string; + anonymizationFields: AnonymizationFieldResponse[]; + apiConfig: ApiConfig; + connectorTimeout: number; + esClient: ElasticsearchClient; + langSmithProject?: string; + langSmithApiKey?: string; + latestReplacements: Replacements; + logger: Logger; + onNewReplacements: (newReplacements: Replacements) => void; + size: number; +}): Promise<{ + anonymizedAlerts: Document[]; + attackDiscoveries: AttackDiscovery[] | null; +}> => { + const llmType = getLlmType(apiConfig.actionTypeId); + const model = apiConfig.model; + const tags = [ATTACK_DISCOVERY_TAG, llmType, model].flatMap((tag) => tag ?? []); + + const traceOptions = { + projectName: langSmithProject, + tracers: [ + ...getLangSmithTracer({ + apiKey: langSmithApiKey, + projectName: langSmithProject, + logger, + }), + ], + }; + + const llm = new ActionsClientLlm({ + actionsClient, + connectorId: apiConfig.connectorId, + llmType, + logger, + temperature: 0, // zero temperature for attack discovery, because we want structured JSON output + timeout: connectorTimeout, + traceOptions, + }); + + if (llm == null) { + throw new Error('LLM is required for attack discoveries'); + } + + const graph = getDefaultAttackDiscoveryGraph({ + alertsIndexPattern, + anonymizationFields, + esClient, + llm, + logger, + onNewReplacements, + replacements: latestReplacements, + size, + }); + + logger?.debug(() => 'invokeAttackDiscoveryGraph: invoking the Attack discovery graph'); + + const result: GraphState = await graph.invoke( + {}, + { + callbacks: [...(traceOptions?.tracers ?? [])], + runName: ATTACK_DISCOVERY_GRAPH_RUN_NAME, + tags, + } + ); + const { + attackDiscoveries, + anonymizedAlerts, + errors, + generationAttempts, + hallucinationFailures, + maxGenerationAttempts, + maxHallucinationFailures, + } = result; + + throwIfErrorCountsExceeded({ + errors, + generationAttempts, + hallucinationFailures, + logger, + maxGenerationAttempts, + maxHallucinationFailures, + }); + + return { anonymizedAlerts, attackDiscoveries }; +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx new file mode 100644 index 0000000000000..9cbf3fa06510d --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { KibanaRequest } from '@kbn/core-http-server'; +import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; + +import { mockAnonymizationFields } from '../../../../../lib/attack_discovery/graphs/default_attack_discovery_graph/mock/mock_anonymization_fields'; +import { requestIsValid } from '.'; + +describe('requestIsValid', () => { + const alertsIndexPattern = '.alerts-security.alerts-default'; + const replacements = { uuid: 'original_value' }; + const size = 20; + const request = { + body: { + actionTypeId: '.bedrock', + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + connectorId: 'test-connector-id', + replacements, + size, + subAction: 'invokeAI', + }, + } as unknown as KibanaRequest; + + it('returns false when the request is missing required anonymization parameters', () => { + const requestMissingAnonymizationParams = { + body: { + alertsIndexPattern: '.alerts-security.alerts-default', + isEnabledKnowledgeBase: false, + size: 20, + }, + } as unknown as KibanaRequest; + + const params = { + alertsIndexPattern, + request: requestMissingAnonymizationParams, // <-- missing required anonymization parameters + size, + }; + + expect(requestIsValid(params)).toBe(false); + }); + + it('returns false when the alertsIndexPattern is undefined', () => { + const params = { + alertsIndexPattern: undefined, // <-- alertsIndexPattern is undefined + request, + size, + }; + + expect(requestIsValid(params)).toBe(false); + }); + + it('returns false when size is undefined', () => { + const params = { + alertsIndexPattern, + request, + size: undefined, // <-- size is undefined + }; + + expect(requestIsValid(params)).toBe(false); + }); + + it('returns false when size is out of range', () => { + const params = { + alertsIndexPattern, + request, + size: 0, // <-- size is out of range + }; + + expect(requestIsValid(params)).toBe(false); + }); + + it('returns true if all required params are provided', () => { + const params = { + alertsIndexPattern, + request, + size, + }; + + expect(requestIsValid(params)).toBe(true); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx new file mode 100644 index 0000000000000..36487d8f6b3e2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/request_is_valid/index.tsx @@ -0,0 +1,33 @@ +/* + * 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 { KibanaRequest } from '@kbn/core/server'; +import { + AttackDiscoveryPostRequestBody, + ExecuteConnectorRequestBody, + sizeIsOutOfRange, +} from '@kbn/elastic-assistant-common'; + +import { requestHasRequiredAnonymizationParams } from '../../../../../lib/langchain/helpers'; + +export const requestIsValid = ({ + alertsIndexPattern, + request, + size, +}: { + alertsIndexPattern: string | undefined; + request: KibanaRequest< + unknown, + unknown, + ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody + >; + size: number | undefined; +}): boolean => + requestHasRequiredAnonymizationParams(request) && + alertsIndexPattern != null && + size != null && + !sizeIsOutOfRange(size); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts new file mode 100644 index 0000000000000..409ee2da74cd2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/index.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; + +import * as i18n from './translations'; + +export const throwIfErrorCountsExceeded = ({ + errors, + generationAttempts, + hallucinationFailures, + logger, + maxGenerationAttempts, + maxHallucinationFailures, +}: { + errors: string[]; + generationAttempts: number; + hallucinationFailures: number; + logger?: Logger; + maxGenerationAttempts: number; + maxHallucinationFailures: number; +}): void => { + if (hallucinationFailures >= maxHallucinationFailures) { + const hallucinationFailuresError = `${i18n.MAX_HALLUCINATION_FAILURES( + hallucinationFailures + )}\n${errors.join(',\n')}`; + + logger?.error(hallucinationFailuresError); + throw new Error(hallucinationFailuresError); + } + + if (generationAttempts >= maxGenerationAttempts) { + const generationAttemptsError = `${i18n.MAX_GENERATION_ATTEMPTS( + generationAttempts + )}\n${errors.join(',\n')}`; + + logger?.error(generationAttemptsError); + throw new Error(generationAttemptsError); + } +}; diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts new file mode 100644 index 0000000000000..fbe06d0e73b2a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/helpers/throw_if_error_counts_exceeded/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const MAX_HALLUCINATION_FAILURES = (hallucinationFailures: number) => + i18n.translate( + 'xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxHallucinationFailuresErrorMessage', + { + defaultMessage: + 'Maximum hallucination failures ({hallucinationFailures}) reached. Try sending fewer alerts to this model.', + values: { hallucinationFailures }, + } + ); + +export const MAX_GENERATION_ATTEMPTS = (generationAttempts: number) => + i18n.translate( + 'xpack.elasticAssistantPlugin.attackDiscovery.defaultAttackDiscoveryGraph.nodes.retriever.helpers.throwIfErrorCountsExceeded.maxGenerationAttemptsErrorMessage', + { + defaultMessage: + 'Maximum generation attempts ({generationAttempts}) reached. Try sending fewer alerts to this model.', + values: { generationAttempts }, + } + ); diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts similarity index 79% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts index cbd3e6063fbd2..d50987317b0e3 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.test.ts @@ -7,22 +7,27 @@ import { AuthenticatedUser } from '@kbn/core-security-common'; import { postAttackDiscoveryRoute } from './post_attack_discovery'; -import { serverMock } from '../../__mocks__/server'; -import { requestContextMock } from '../../__mocks__/request_context'; +import { serverMock } from '../../../__mocks__/server'; +import { requestContextMock } from '../../../__mocks__/request_context'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; -import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery'; -import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms'; -import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock'; -import { postAttackDiscoveryRequest } from '../../__mocks__/request'; +import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence'; +import { transformESSearchToAttackDiscovery } from '../../../lib/attack_discovery/persistence/transforms/transforms'; +import { getAttackDiscoverySearchEsMock } from '../../../__mocks__/attack_discovery_schema.mock'; +import { postAttackDiscoveryRequest } from '../../../__mocks__/request'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants'; import { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; -import { - getAssistantTool, - getAssistantToolParams, - updateAttackDiscoveryStatusToRunning, -} from './helpers'; -jest.mock('./helpers'); + +import { updateAttackDiscoveryStatusToRunning } from '../helpers/helpers'; + +jest.mock('../helpers/helpers', () => { + const original = jest.requireActual('../helpers/helpers'); + + return { + ...original, + updateAttackDiscoveryStatusToRunning: jest.fn(), + }; +}); const { clients, context } = requestContextMock.createTools(); const server: ReturnType = serverMock.create(); @@ -72,8 +77,6 @@ describe('postAttackDiscoveryRoute', () => { context.elasticAssistant.actions = actionsMock.createStart(); postAttackDiscoveryRoute(server.router); findAttackDiscoveryByConnectorId.mockResolvedValue(mockCurrentAd); - (getAssistantTool as jest.Mock).mockReturnValue({ getTool: jest.fn() }); - (getAssistantToolParams as jest.Mock).mockReturnValue({ tool: 'tool' }); (updateAttackDiscoveryStatusToRunning as jest.Mock).mockResolvedValue({ currentAd: runningAd, attackDiscoveryId: mockCurrentAd.id, @@ -117,15 +120,6 @@ describe('postAttackDiscoveryRoute', () => { }); }); - it('should handle assistantTool null response', async () => { - (getAssistantTool as jest.Mock).mockReturnValue(null); - const response = await server.inject( - postAttackDiscoveryRequest(mockRequestBody), - requestContextMock.convertContext(context) - ); - expect(response.status).toEqual(404); - }); - it('should handle updateAttackDiscoveryStatusToRunning error', async () => { (updateAttackDiscoveryStatusToRunning as jest.Mock).mockRejectedValue(new Error('Oh no!')); const response = await server.inject( diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts similarity index 79% rename from x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts rename to x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts index b9c680dde3d1d..b0273741bdf5e 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post_attack_discovery.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/post/post_attack_discovery.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { type IKibanaResponse, IRouter, Logger } from '@kbn/core/server'; import { AttackDiscoveryPostRequestBody, @@ -13,20 +12,17 @@ import { ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, Replacements, } from '@kbn/elastic-assistant-common'; +import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { transformError } from '@kbn/securitysolution-es-utils'; import moment from 'moment/moment'; -import { ATTACK_DISCOVERY } from '../../../common/constants'; -import { - getAssistantTool, - getAssistantToolParams, - handleToolError, - updateAttackDiscoveries, - updateAttackDiscoveryStatusToRunning, -} from './helpers'; -import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../helpers'; -import { buildResponse } from '../../lib/build_response'; -import { ElasticAssistantRequestHandlerContext } from '../../types'; +import { ATTACK_DISCOVERY } from '../../../../common/constants'; +import { handleGraphError } from './helpers/handle_graph_error'; +import { updateAttackDiscoveries, updateAttackDiscoveryStatusToRunning } from '../helpers/helpers'; +import { buildResponse } from '../../../lib/build_response'; +import { ElasticAssistantRequestHandlerContext } from '../../../types'; +import { invokeAttackDiscoveryGraph } from './helpers/invoke_attack_discovery_graph'; +import { requestIsValid } from './helpers/request_is_valid'; const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes const LANG_CHAIN_TIMEOUT = ROUTE_HANDLER_TIMEOUT - 10_000; // 9 minutes 50 seconds @@ -85,11 +81,6 @@ export const postAttackDiscoveryRoute = ( statusCode: 500, }); } - const pluginName = getPluginNameFromRequest({ - request, - defaultPluginName: DEFAULT_PLUGIN_NAME, - logger, - }); // get parameters from the request body const alertsIndexPattern = decodeURIComponent(request.body.alertsIndexPattern); @@ -102,6 +93,19 @@ export const postAttackDiscoveryRoute = ( size, } = request.body; + if ( + !requestIsValid({ + alertsIndexPattern, + request, + size, + }) + ) { + return resp.error({ + body: 'Bad Request', + statusCode: 400, + }); + } + // get an Elasticsearch client for the authenticated user: const esClient = (await context.core).elasticsearch.client.asCurrentUser; @@ -111,59 +115,45 @@ export const postAttackDiscoveryRoute = ( latestReplacements = { ...latestReplacements, ...newReplacements }; }; - const assistantTool = getAssistantTool( - (await context.elasticAssistant).getRegisteredTools, - pluginName + const { currentAd, attackDiscoveryId } = await updateAttackDiscoveryStatusToRunning( + dataClient, + authenticatedUser, + apiConfig, + size ); - if (!assistantTool) { - return response.notFound(); // attack discovery tool not found - } - - const assistantToolParams = getAssistantToolParams({ + // Don't await the results of invoking the graph; (just the metadata will be returned from the route handler): + invokeAttackDiscoveryGraph({ actionsClient, alertsIndexPattern, anonymizationFields, apiConfig, - esClient, - latestReplacements, connectorTimeout: CONNECTOR_TIMEOUT, - langChainTimeout: LANG_CHAIN_TIMEOUT, + esClient, langSmithProject, langSmithApiKey, + latestReplacements, logger, onNewReplacements, - request, size, - }); - - // invoke the attack discovery tool: - const toolInstance = assistantTool.getTool(assistantToolParams); - - const { currentAd, attackDiscoveryId } = await updateAttackDiscoveryStatusToRunning( - dataClient, - authenticatedUser, - apiConfig - ); - - toolInstance - ?.invoke('') - .then((rawAttackDiscoveries: string) => + }) + .then(({ anonymizedAlerts, attackDiscoveries }) => updateAttackDiscoveries({ + anonymizedAlerts, apiConfig, + attackDiscoveries, attackDiscoveryId, authenticatedUser, dataClient, latestReplacements, logger, - rawAttackDiscoveries, size, startTime, telemetry, }) ) .catch((err) => - handleToolError({ + handleGraphError({ apiConfig, attackDiscoveryId, authenticatedUser, diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts new file mode 100644 index 0000000000000..c0320c9ff6adf --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/get_graphs_from_names/index.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ASSISTANT_GRAPH_MAP, + AssistantGraphMetadata, + AttackDiscoveryGraphMetadata, +} from '../../../lib/langchain/graphs'; + +export interface GetGraphsFromNamesResults { + attackDiscoveryGraphs: AttackDiscoveryGraphMetadata[]; + assistantGraphs: AssistantGraphMetadata[]; +} + +export const getGraphsFromNames = (graphNames: string[]): GetGraphsFromNamesResults => + graphNames.reduce( + (acc, graphName) => { + const graph = ASSISTANT_GRAPH_MAP[graphName]; + if (graph != null) { + return graph.graphType === 'assistant' + ? { ...acc, assistantGraphs: [...acc.assistantGraphs, graph] } + : { ...acc, attackDiscoveryGraphs: [...acc.attackDiscoveryGraphs, graph] }; + } + + return acc; + }, + { + attackDiscoveryGraphs: [], + assistantGraphs: [], + } + ); diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts index 29a7527964677..eb12946a9b61f 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts @@ -29,6 +29,7 @@ import { createStructuredChatAgent, createToolCallingAgent, } from 'langchain/agents'; +import { omit } from 'lodash/fp'; import { buildResponse } from '../../lib/build_response'; import { AssistantDataClients } from '../../lib/langchain/executors/types'; import { AssistantToolParams, ElasticAssistantRequestHandlerContext, GetElser } from '../../types'; @@ -36,6 +37,7 @@ import { DEFAULT_PLUGIN_NAME, isV2KnowledgeBaseEnabled, performChecks } from '.. import { fetchLangSmithDataset } from './utils'; import { transformESSearchToAnonymizationFields } from '../../ai_assistant_data_clients/anonymization_fields/helpers'; import { EsAnonymizationFieldsSchema } from '../../ai_assistant_data_clients/anonymization_fields/types'; +import { evaluateAttackDiscovery } from '../../lib/attack_discovery/evaluation'; import { DefaultAssistantGraph, getDefaultAssistantGraph, @@ -47,9 +49,12 @@ import { structuredChatAgentPrompt, } from '../../lib/langchain/graphs/default_assistant_graph/prompts'; import { getLlmClass, getLlmType, isOpenSourceModel } from '../utils'; +import { getGraphsFromNames } from './get_graphs_from_names'; const DEFAULT_SIZE = 20; const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes +const LANG_CHAIN_TIMEOUT = ROUTE_HANDLER_TIMEOUT - 10_000; // 9 minutes 50 seconds +const CONNECTOR_TIMEOUT = LANG_CHAIN_TIMEOUT - 10_000; // 9 minutes 40 seconds export const postEvaluateRoute = ( router: IRouter, @@ -106,8 +111,10 @@ export const postEvaluateRoute = ( const { alertsIndexPattern, datasetName, + evaluatorConnectorId, graphs: graphNames, langSmithApiKey, + langSmithProject, connectorIds, size, replacements, @@ -124,7 +131,9 @@ export const postEvaluateRoute = ( logger.info('postEvaluateRoute:'); logger.info(`request.query:\n${JSON.stringify(request.query, null, 2)}`); - logger.info(`request.body:\n${JSON.stringify(request.body, null, 2)}`); + logger.info( + `request.body:\n${JSON.stringify(omit(['langSmithApiKey'], request.body), null, 2)}` + ); logger.info(`Evaluation ID: ${evaluationId}`); const totalExecutions = connectorIds.length * graphNames.length * dataset.length; @@ -170,6 +179,38 @@ export const postEvaluateRoute = ( // Fetch any tools registered to the security assistant const assistantTools = assistantContext.getRegisteredTools(DEFAULT_PLUGIN_NAME); + const { attackDiscoveryGraphs } = getGraphsFromNames(graphNames); + + if (attackDiscoveryGraphs.length > 0) { + try { + // NOTE: we don't wait for the evaluation to finish here, because + // the client will retry / timeout when evaluations take too long + void evaluateAttackDiscovery({ + actionsClient, + alertsIndexPattern, + attackDiscoveryGraphs, + connectors, + connectorTimeout: CONNECTOR_TIMEOUT, + datasetName, + esClient, + evaluationId, + evaluatorConnectorId, + langSmithApiKey, + langSmithProject, + logger, + runName, + size, + }); + } catch (err) { + logger.error(() => `Error evaluating attack discovery: ${err}`); + } + + // Return early if we're only running attack discovery graphs + return response.ok({ + body: { evaluationId, success: true }, + }); + } + const graphs: Array<{ name: string; graph: DefaultAssistantGraph; diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts index 34f009e266515..0260c47b4bd29 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/utils.ts @@ -21,7 +21,7 @@ export const fetchLangSmithDataset = async ( logger: Logger, langSmithApiKey?: string ): Promise => { - if (datasetName === undefined || !isLangSmithEnabled()) { + if (datasetName === undefined || (langSmithApiKey == null && !isLangSmithEnabled())) { throw new Error('LangSmith dataset name not provided or LangSmith not enabled'); } diff --git a/x-pack/plugins/elastic_assistant/server/routes/index.ts b/x-pack/plugins/elastic_assistant/server/routes/index.ts index 43e1229250f46..a6d7a4298c2b7 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/index.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/index.ts @@ -9,8 +9,8 @@ export { postActionsConnectorExecuteRoute } from './post_actions_connector_execute'; // Attack Discovery -export { postAttackDiscoveryRoute } from './attack_discovery/post_attack_discovery'; -export { getAttackDiscoveryRoute } from './attack_discovery/get_attack_discovery'; +export { postAttackDiscoveryRoute } from './attack_discovery/post/post_attack_discovery'; +export { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_discovery'; // Knowledge Base export { deleteKnowledgeBaseRoute } from './knowledge_base/delete_knowledge_base'; diff --git a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts index 56eb9760e442a..7898629e15b5c 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/register_routes.ts @@ -7,9 +7,9 @@ import type { Logger } from '@kbn/core/server'; -import { cancelAttackDiscoveryRoute } from './attack_discovery/cancel_attack_discovery'; -import { getAttackDiscoveryRoute } from './attack_discovery/get_attack_discovery'; -import { postAttackDiscoveryRoute } from './attack_discovery/post_attack_discovery'; +import { cancelAttackDiscoveryRoute } from './attack_discovery/post/cancel/cancel_attack_discovery'; +import { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_discovery'; +import { postAttackDiscoveryRoute } from './attack_discovery/post/post_attack_discovery'; import { ElasticAssistantPluginRouter, GetElser } from '../types'; import { createConversationRoute } from './user_conversations/create_route'; import { deleteConversationRoute } from './user_conversations/delete_route'; diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index 45bd5a4149b58..e84b97ab43d7a 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -43,10 +43,10 @@ import { ActionsClientGeminiChatModel, ActionsClientLlm, } from '@kbn/langchain/server'; - import type { InferenceServerStart } from '@kbn/inference-plugin/server'; + import type { GetAIAssistantKnowledgeBaseDataClientParams } from './ai_assistant_data_clients/knowledge_base'; -import { AttackDiscoveryDataClient } from './ai_assistant_data_clients/attack_discovery'; +import { AttackDiscoveryDataClient } from './lib/attack_discovery/persistence'; import { AIAssistantConversationsDataClient } from './ai_assistant_data_clients/conversations'; import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context'; import { AIAssistantDataClient } from './ai_assistant_data_clients'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx index 885ab18c879a7..dd995d115b6c3 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx @@ -6,7 +6,11 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; -import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import { + replaceAnonymizedValuesWithOriginalValues, + type AttackDiscovery, + type Replacements, +} from '@kbn/elastic-assistant-common'; import React, { useMemo } from 'react'; import { AttackDiscoveryMarkdownFormatter } from '../../attack_discovery_markdown_formatter'; @@ -23,26 +27,41 @@ const ActionableSummaryComponent: React.FC = ({ replacements, showAnonymized = false, }) => { - const entitySummaryMarkdownWithReplacements = useMemo( + const entitySummary = useMemo( () => - Object.entries(replacements ?? {}).reduce( - (acc, [key, value]) => acc.replace(key, value), - attackDiscovery.entitySummaryMarkdown - ), - [attackDiscovery.entitySummaryMarkdown, replacements] + showAnonymized + ? attackDiscovery.entitySummaryMarkdown + : replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.entitySummaryMarkdown ?? '', + replacements: { ...replacements }, + }), + + [attackDiscovery.entitySummaryMarkdown, replacements, showAnonymized] + ); + + // title will be used as a fallback if entitySummaryMarkdown is empty + const title = useMemo( + () => + showAnonymized + ? attackDiscovery.title + : replaceAnonymizedValuesWithOriginalValues({ + messageContent: attackDiscovery.title, + replacements: { ...replacements }, + }), + + [attackDiscovery.title, replacements, showAnonymized] ); + const entitySummaryOrTitle = + entitySummary != null && entitySummary.length > 0 ? entitySummary : title; + return ( diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx index 2aaac0449886a..c6ac9c70e8413 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx @@ -49,8 +49,15 @@ const AttackDiscoveryPanelComponent: React.FC = ({ ); const buttonContent = useMemo( - () => , - [attackDiscovery.title] + () => ( + <Title + isLoading={false} + replacements={replacements} + showAnonymized={showAnonymized} + title={attackDiscovery.title} + /> + ), + [attackDiscovery.title, replacements, showAnonymized] ); return ( diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx index 4b0375e4fe503..13326a07adc70 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx @@ -7,20 +7,41 @@ import { EuiFlexGroup, EuiFlexItem, EuiSkeletonTitle, EuiTitle, useEuiTheme } from '@elastic/eui'; import { AssistantAvatar } from '@kbn/elastic-assistant'; +import { + replaceAnonymizedValuesWithOriginalValues, + type Replacements, +} from '@kbn/elastic-assistant-common'; import { css } from '@emotion/react'; -import React from 'react'; +import React, { useMemo } from 'react'; const AVATAR_SIZE = 24; // px interface Props { isLoading: boolean; + replacements?: Replacements; + showAnonymized?: boolean; title: string; } -const TitleComponent: React.FC<Props> = ({ isLoading, title }) => { +const TitleComponent: React.FC<Props> = ({ + isLoading, + replacements, + showAnonymized = false, + title, +}) => { const { euiTheme } = useEuiTheme(); + const titleWithReplacements = useMemo( + () => + replaceAnonymizedValuesWithOriginalValues({ + messageContent: title, + replacements: { ...replacements }, + }), + + [replacements, title] + ); + return ( <EuiFlexGroup alignItems="center" data-test-subj="title" gutterSize="s"> <EuiFlexItem @@ -53,7 +74,7 @@ const TitleComponent: React.FC<Props> = ({ isLoading, title }) => { /> ) : ( <EuiTitle data-test-subj="titleText" size="xs"> - <h2>{title}</h2> + <h2>{showAnonymized ? title : titleWithReplacements}</h2> </EuiTitle> )} </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts b/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts index 5309ef1de6bb2..0ae524c25ee95 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts @@ -56,7 +56,7 @@ export const getAttackDiscoveryMarkdown = ({ replacements?: Replacements; }): string => { const title = getMarkdownFields(attackDiscovery.title); - const entitySummaryMarkdown = getMarkdownFields(attackDiscovery.entitySummaryMarkdown); + const entitySummaryMarkdown = getMarkdownFields(attackDiscovery.entitySummaryMarkdown ?? ''); const summaryMarkdown = getMarkdownFields(attackDiscovery.summaryMarkdown); const detailsMarkdown = getMarkdownFields(attackDiscovery.detailsMarkdown); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx index 874a4d1c99ded..ab0a5ac4ede96 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx @@ -106,7 +106,9 @@ export const usePollApi = ({ ...attackDiscovery, id: attackDiscovery.id ?? uuid.v4(), detailsMarkdown: replaceNewlineLiterals(attackDiscovery.detailsMarkdown), - entitySummaryMarkdown: replaceNewlineLiterals(attackDiscovery.entitySummaryMarkdown), + entitySummaryMarkdown: replaceNewlineLiterals( + attackDiscovery.entitySummaryMarkdown ?? '' + ), summaryMarkdown: replaceNewlineLiterals(attackDiscovery.summaryMarkdown), })), }; @@ -123,7 +125,7 @@ export const usePollApi = ({ const rawResponse = await http.fetch( `/internal/elastic_assistant/attack_discovery/cancel/${connectorId}`, { - method: 'PUT', + method: 'POST', version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION, } ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx index 5dd4cb8fc4267..533b95bf7087f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx @@ -52,7 +52,7 @@ const AnimatedCounterComponent: React.FC<Props> = ({ animationDurationMs = 1000 css={css` height: 32px; margin-right: ${euiTheme.size.xs}; - width: ${count < 100 ? 40 : 53}px; + width: ${count < 100 ? 40 : 60}px; `} data-test-subj="animatedCounter" ref={d3Ref} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx index 56b2205b28726..0707950383046 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx @@ -16,6 +16,8 @@ jest.mock('../../../assistant/use_assistant_availability'); describe('EmptyPrompt', () => { const alertsCount = 20; + const aiConnectorsCount = 2; + const attackDiscoveriesCount = 0; const onGenerate = jest.fn(); beforeEach(() => { @@ -33,6 +35,8 @@ describe('EmptyPrompt', () => { <TestProviders> <EmptyPrompt alertsCount={alertsCount} + aiConnectorsCount={aiConnectorsCount} + attackDiscoveriesCount={attackDiscoveriesCount} isLoading={false} isDisabled={false} onGenerate={onGenerate} @@ -69,8 +73,34 @@ describe('EmptyPrompt', () => { }); describe('when loading is true', () => { - const isLoading = true; + beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <EmptyPrompt + aiConnectorsCount={2} // <-- non-null + attackDiscoveriesCount={0} // <-- no discoveries + alertsCount={alertsCount} + isLoading={true} // <-- loading + isDisabled={false} + onGenerate={onGenerate} + /> + </TestProviders> + ); + }); + + it('returns null', () => { + const emptyPrompt = screen.queryByTestId('emptyPrompt'); + expect(emptyPrompt).not.toBeInTheDocument(); + }); + }); + + describe('when aiConnectorsCount is null', () => { beforeEach(() => { (useAssistantAvailability as jest.Mock).mockReturnValue({ hasAssistantPrivilege: true, @@ -80,8 +110,10 @@ describe('EmptyPrompt', () => { render( <TestProviders> <EmptyPrompt + aiConnectorsCount={null} // <-- null + attackDiscoveriesCount={0} // <-- no discoveries alertsCount={alertsCount} - isLoading={isLoading} + isLoading={false} // <-- not loading isDisabled={false} onGenerate={onGenerate} /> @@ -89,10 +121,38 @@ describe('EmptyPrompt', () => { ); }); - it('disables the generate button while loading', () => { - const generateButton = screen.getByTestId('generate'); + it('returns null', () => { + const emptyPrompt = screen.queryByTestId('emptyPrompt'); - expect(generateButton).toBeDisabled(); + expect(emptyPrompt).not.toBeInTheDocument(); + }); + }); + + describe('when there are attack discoveries', () => { + beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: true, + isAssistantEnabled: true, + }); + + render( + <TestProviders> + <EmptyPrompt + aiConnectorsCount={2} // <-- non-null + attackDiscoveriesCount={7} // there are discoveries + alertsCount={alertsCount} + isLoading={false} // <-- not loading + isDisabled={false} + onGenerate={onGenerate} + /> + </TestProviders> + ); + }); + + it('returns null', () => { + const emptyPrompt = screen.queryByTestId('emptyPrompt'); + + expect(emptyPrompt).not.toBeInTheDocument(); }); }); @@ -109,6 +169,8 @@ describe('EmptyPrompt', () => { <TestProviders> <EmptyPrompt alertsCount={alertsCount} + aiConnectorsCount={2} // <-- non-null + attackDiscoveriesCount={0} // <-- no discoveries isLoading={false} isDisabled={isDisabled} onGenerate={onGenerate} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx index 75c8533efcc92..3d89f5be87030 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx @@ -7,7 +7,6 @@ import { AssistantAvatar } from '@kbn/elastic-assistant'; import { - EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, @@ -15,24 +14,28 @@ import { EuiLink, EuiSpacer, EuiText, - EuiToolTip, useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useMemo } from 'react'; import { AnimatedCounter } from './animated_counter'; +import { Generate } from '../generate'; import * as i18n from './translations'; interface Props { + aiConnectorsCount: number | null; // null when connectors are not configured alertsCount: number; + attackDiscoveriesCount: number; isDisabled?: boolean; isLoading: boolean; onGenerate: () => void; } const EmptyPromptComponent: React.FC<Props> = ({ + aiConnectorsCount, alertsCount, + attackDiscoveriesCount, isLoading, isDisabled = false, onGenerate, @@ -110,25 +113,13 @@ const EmptyPromptComponent: React.FC<Props> = ({ ); const actions = useMemo(() => { - const disabled = isLoading || isDisabled; - - return ( - <EuiToolTip - content={disabled ? i18n.SELECT_A_CONNECTOR : null} - data-test-subj="generateTooltip" - > - <EuiButton - color="primary" - data-test-subj="generate" - disabled={disabled} - onClick={onGenerate} - > - {i18n.GENERATE} - </EuiButton> - </EuiToolTip> - ); + return <Generate isLoading={isLoading} isDisabled={isDisabled} onGenerate={onGenerate} />; }, [isDisabled, isLoading, onGenerate]); + if (isLoading || aiConnectorsCount == null || attackDiscoveriesCount > 0) { + return null; + } + return ( <EuiFlexGroup alignItems="center" diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts new file mode 100644 index 0000000000000..e2c7018ef5826 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts @@ -0,0 +1,36 @@ +/* + * 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 { + showEmptyPrompt, + showFailurePrompt, + showNoAlertsPrompt, + showWelcomePrompt, +} from '../../../helpers'; + +export const showEmptyStates = ({ + aiConnectorsCount, + alertsContextCount, + attackDiscoveriesCount, + connectorId, + failureReason, + isLoading, +}: { + aiConnectorsCount: number | null; + alertsContextCount: number | null; + attackDiscoveriesCount: number; + connectorId: string | undefined; + failureReason: string | null; + isLoading: boolean; +}): boolean => { + const showWelcome = showWelcomePrompt({ aiConnectorsCount, isLoading }); + const showFailure = showFailurePrompt({ connectorId, failureReason, isLoading }); + const showNoAlerts = showNoAlertsPrompt({ alertsContextCount, connectorId, isLoading }); + const showEmpty = showEmptyPrompt({ aiConnectorsCount, attackDiscoveriesCount, isLoading }); + + return showWelcome || showFailure || showNoAlerts || showEmpty; +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx index 3b5b87ada83ec..9eacd696a2ff1 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { render, screen } from '@testing-library/react'; import React from 'react'; @@ -18,7 +19,6 @@ describe('EmptyStates', () => { const aiConnectorsCount = 0; // <-- no connectors configured const alertsContextCount = null; - const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = false; @@ -29,12 +29,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -59,7 +59,6 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 0; // <-- no alerts to analyze - const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const isLoading = false; @@ -70,12 +69,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -104,8 +103,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 10; - const alertsCount = 10; - const attackDiscoveriesCount = 10; + const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -115,12 +113,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={"you're a failure"} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -149,8 +147,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 10; - const alertsCount = 10; - const attackDiscoveriesCount = 10; + const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const failureReason = 'this failure should NOT be displayed, because we are loading'; // <-- failureReason is provided const isLoading = true; // <-- loading data @@ -161,12 +158,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={failureReason} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -195,8 +192,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed - const alertsCount = 0; // <-- no alerts contributed to attack discoveries - const attackDiscoveriesCount = 0; // <-- no attack discoveries were generated from the alerts + const attackDiscoveriesCount = 0; const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -206,12 +202,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -240,7 +236,6 @@ describe('EmptyStates', () => { const aiConnectorsCount = null; // <-- no connectors configured const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed - const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = false; @@ -251,12 +246,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -287,7 +282,6 @@ describe('EmptyStates', () => { const aiConnectorsCount = 0; // <-- no connectors configured (welcome prompt should be shown if not loading) const alertsContextCount = null; - const alertsCount = 0; const attackDiscoveriesCount = 0; const connectorId = undefined; const isLoading = true; // <-- loading data @@ -298,12 +292,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); @@ -338,8 +332,7 @@ describe('EmptyStates', () => { const aiConnectorsCount = 1; const alertsContextCount = 20; // <-- alerts were sent as context to be analyzed - const alertsCount = 10; // <-- alerts contributed to attack discoveries - const attackDiscoveriesCount = 3; // <-- attack discoveries were generated from the alerts + const attackDiscoveriesCount = 7; // <-- attack discoveries are present const connectorId = 'test-connector-id'; const isLoading = false; const onGenerate = jest.fn(); @@ -349,12 +342,12 @@ describe('EmptyStates', () => { <EmptyStates aiConnectorsCount={aiConnectorsCount} alertsContextCount={alertsContextCount} - alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} connectorId={connectorId} failureReason={null} isLoading={isLoading} onGenerate={onGenerate} + upToAlertsCount={DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS} /> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx index 49b4557c72192..a083ec7b77fdd 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx @@ -9,51 +9,55 @@ import React from 'react'; import { Failure } from '../failure'; import { EmptyPrompt } from '../empty_prompt'; -import { showEmptyPrompt, showNoAlertsPrompt, showWelcomePrompt } from '../helpers'; +import { showFailurePrompt, showNoAlertsPrompt, showWelcomePrompt } from '../helpers'; import { NoAlerts } from '../no_alerts'; import { Welcome } from '../welcome'; interface Props { - aiConnectorsCount: number | null; - alertsContextCount: number | null; - alertsCount: number; + aiConnectorsCount: number | null; // null when connectors are not configured + alertsContextCount: number | null; // null when unavailable for the current connector attackDiscoveriesCount: number; connectorId: string | undefined; failureReason: string | null; isLoading: boolean; onGenerate: () => Promise<void>; + upToAlertsCount: number; } const EmptyStatesComponent: React.FC<Props> = ({ aiConnectorsCount, alertsContextCount, - alertsCount, attackDiscoveriesCount, connectorId, failureReason, isLoading, onGenerate, + upToAlertsCount, }) => { + const isDisabled = connectorId == null; + if (showWelcomePrompt({ aiConnectorsCount, isLoading })) { return <Welcome />; - } else if (!isLoading && failureReason != null) { + } + + if (showFailurePrompt({ connectorId, failureReason, isLoading })) { return <Failure failureReason={failureReason} />; - } else if (showNoAlertsPrompt({ alertsContextCount, isLoading })) { - return <NoAlerts />; - } else if (showEmptyPrompt({ aiConnectorsCount, attackDiscoveriesCount, isLoading })) { - return ( - <EmptyPrompt - alertsCount={alertsCount} - isDisabled={connectorId == null} - isLoading={isLoading} - onGenerate={onGenerate} - /> - ); } - return null; -}; + if (showNoAlertsPrompt({ alertsContextCount, connectorId, isLoading })) { + return <NoAlerts isLoading={isLoading} isDisabled={isDisabled} onGenerate={onGenerate} />; + } -EmptyStatesComponent.displayName = 'EmptyStates'; + return ( + <EmptyPrompt + aiConnectorsCount={aiConnectorsCount} + alertsCount={upToAlertsCount} + attackDiscoveriesCount={attackDiscoveriesCount} + isDisabled={isDisabled} + isLoading={isLoading} + onGenerate={onGenerate} + /> + ); +}; export const EmptyStates = React.memo(EmptyStatesComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx index 4318f3f78536a..c9c27446fe51c 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx @@ -5,13 +5,53 @@ * 2.0. */ -import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; +import { + EuiAccordion, + EuiCodeBlock, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, +} from '@elastic/eui'; import { css } from '@emotion/react'; -import React from 'react'; +import React, { useMemo } from 'react'; import * as i18n from './translations'; -const FailureComponent: React.FC<{ failureReason: string }> = ({ failureReason }) => { +interface Props { + failureReason: string | null | undefined; +} + +const FailureComponent: React.FC<Props> = ({ failureReason }) => { + const Failures = useMemo(() => { + const failures = failureReason != null ? failureReason.split('\n') : ''; + const [firstFailure, ...restFailures] = failures; + + return ( + <> + <p>{firstFailure}</p> + + {restFailures.length > 0 && ( + <EuiAccordion + id="failuresFccordion" + buttonContent={i18n.DETAILS} + data-test-subj="failuresAccordion" + paddingSize="s" + > + <> + {restFailures.map((failure, i) => ( + <EuiCodeBlock fontSize="m" key={i} paddingSize="m"> + {failure} + </EuiCodeBlock> + ))} + </> + </EuiAccordion> + )} + </> + ); + }, [failureReason]); + return ( <EuiFlexGroup alignItems="center" data-test-subj="failure" direction="column"> <EuiFlexItem data-test-subj="emptyPromptContainer" grow={false}> @@ -26,7 +66,7 @@ const FailureComponent: React.FC<{ failureReason: string }> = ({ failureReason } `} data-test-subj="bodyText" > - {failureReason} + {Failures} </EuiText> } title={<h2 data-test-subj="failureTitle">{i18n.FAILURE_TITLE}</h2>} diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts index b36104d202ba8..ecaa7fad240e1 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts @@ -7,10 +7,10 @@ import { i18n } from '@kbn/i18n'; -export const LEARN_MORE = i18n.translate( - 'xpack.securitySolution.attackDiscovery.pages.failure.learnMoreLink', +export const DETAILS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.pages.failure.detailsAccordionButton', { - defaultMessage: 'Learn more about Attack discovery', + defaultMessage: 'Details', } ); @@ -20,3 +20,10 @@ export const FAILURE_TITLE = i18n.translate( defaultMessage: 'Attack discovery generation failed', } ); + +export const LEARN_MORE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.pages.failure.learnMoreLink', + { + defaultMessage: 'Learn more about Attack discovery', + } +); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx new file mode 100644 index 0000000000000..16ed376dd3af4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx @@ -0,0 +1,36 @@ +/* + * 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 { EuiButton, EuiToolTip } from '@elastic/eui'; +import React from 'react'; + +import * as i18n from '../empty_prompt/translations'; + +interface Props { + isDisabled?: boolean; + isLoading: boolean; + onGenerate: () => void; +} + +const GenerateComponent: React.FC<Props> = ({ isLoading, isDisabled = false, onGenerate }) => { + const disabled = isLoading || isDisabled; + + return ( + <EuiToolTip + content={disabled ? i18n.SELECT_A_CONNECTOR : null} + data-test-subj="generateTooltip" + > + <EuiButton color="primary" data-test-subj="generate" disabled={disabled} onClick={onGenerate}> + {i18n.GENERATE} + </EuiButton> + </EuiToolTip> + ); +}; + +GenerateComponent.displayName = 'Generate'; + +export const Generate = React.memo(GenerateComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx index aee53d889c7ac..7b0688eadafef 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; @@ -31,9 +32,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onGenerate={jest.fn()} onConnectorIdSelected={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -54,9 +57,11 @@ describe('Header', () => { connectorsAreConfigured={connectorsAreConfigured} isDisabledActions={false} isLoading={false} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onGenerate={jest.fn()} onConnectorIdSelected={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -77,9 +82,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={onGenerate} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -102,9 +109,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={isLoading} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -126,9 +135,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={isLoading} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={onCancel} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); @@ -150,9 +161,11 @@ describe('Header', () => { connectorsAreConfigured={true} isDisabledActions={false} isLoading={false} + localStorageAttackDiscoveryMaxAlerts={`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`} onCancel={jest.fn()} onConnectorIdSelected={jest.fn()} onGenerate={jest.fn()} + setLocalStorageAttackDiscoveryMaxAlerts={jest.fn()} /> </TestProviders> ); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx index 583bcc25d0eb6..ff170805670a6 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/index.tsx @@ -9,10 +9,11 @@ import type { EuiButtonProps } from '@elastic/eui'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { ConnectorSelectorInline } from '@kbn/elastic-assistant'; +import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import type { AttackDiscoveryStats } from '@kbn/elastic-assistant-common'; +import { SettingsModal } from './settings_modal'; import { StatusBell } from './status_bell'; import * as i18n from './translations'; @@ -21,9 +22,11 @@ interface Props { connectorsAreConfigured: boolean; isLoading: boolean; isDisabledActions: boolean; + localStorageAttackDiscoveryMaxAlerts: string | undefined; onGenerate: () => void; onCancel: () => void; onConnectorIdSelected: (connectorId: string) => void; + setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>; stats: AttackDiscoveryStats | null; } @@ -32,9 +35,11 @@ const HeaderComponent: React.FC<Props> = ({ connectorsAreConfigured, isLoading, isDisabledActions, + localStorageAttackDiscoveryMaxAlerts, onGenerate, onConnectorIdSelected, onCancel, + setLocalStorageAttackDiscoveryMaxAlerts, stats, }) => { const { euiTheme } = useEuiTheme(); @@ -68,6 +73,7 @@ const HeaderComponent: React.FC<Props> = ({ }, [isLoading, handleCancel, onGenerate] ); + return ( <EuiFlexGroup alignItems="center" @@ -78,6 +84,14 @@ const HeaderComponent: React.FC<Props> = ({ data-test-subj="header" gutterSize="none" > + <EuiFlexItem grow={false}> + <SettingsModal + connectorId={connectorId} + isLoading={isLoading} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} + setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts} + /> + </EuiFlexItem> <StatusBell stats={stats} /> {connectorsAreConfigured && ( <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx new file mode 100644 index 0000000000000..b51a1fc3f85c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/alerts_settings/index.tsx @@ -0,0 +1,77 @@ +/* + * 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 { SingleRangeChangeEvent } from '@kbn/elastic-assistant'; +import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { + AlertsRange, + SELECT_FEWER_ALERTS, + YOUR_ANONYMIZATION_SETTINGS, +} from '@kbn/elastic-assistant'; +import React, { useCallback } from 'react'; + +import * as i18n from '../translations'; + +export const MAX_ALERTS = 500; +export const MIN_ALERTS = 50; +export const ROW_MIN_WITH = 550; // px +export const STEP = 50; + +interface Props { + maxAlerts: string; + setMaxAlerts: React.Dispatch<React.SetStateAction<string>>; +} + +const AlertsSettingsComponent: React.FC<Props> = ({ maxAlerts, setMaxAlerts }) => { + const onChangeAlertsRange = useCallback( + (e: SingleRangeChangeEvent) => { + setMaxAlerts(e.currentTarget.value); + }, + [setMaxAlerts] + ); + + return ( + <EuiForm component="form"> + <EuiFormRow hasChildLabel={false} label={i18n.ALERTS}> + <EuiFlexGroup direction="column" gutterSize="none"> + <EuiFlexItem grow={false}> + <AlertsRange + maxAlerts={MAX_ALERTS} + minAlerts={MIN_ALERTS} + onChange={onChangeAlertsRange} + step={STEP} + value={maxAlerts} + /> + <EuiSpacer size="m" /> + </EuiFlexItem> + + <EuiFlexItem grow={true}> + <EuiText color="subdued" size="xs"> + <span>{i18n.LATEST_AND_RISKIEST_OPEN_ALERTS(Number(maxAlerts))}</span> + </EuiText> + </EuiFlexItem> + + <EuiFlexItem grow={true}> + <EuiText color="subdued" size="xs"> + <span>{YOUR_ANONYMIZATION_SETTINGS}</span> + </EuiText> + </EuiFlexItem> + + <EuiFlexItem grow={true}> + <EuiText color="subdued" size="xs"> + <span>{SELECT_FEWER_ALERTS}</span> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> + </EuiForm> + ); +}; + +AlertsSettingsComponent.displayName = 'AlertsSettings'; + +export const AlertsSettings = React.memo(AlertsSettingsComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx new file mode 100644 index 0000000000000..0066376a0e198 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/footer/index.tsx @@ -0,0 +1,57 @@ +/* + * 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 { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +import * as i18n from '../translations'; + +interface Props { + closeModal: () => void; + onReset: () => void; + onSave: () => void; +} + +const FooterComponent: React.FC<Props> = ({ closeModal, onReset, onSave }) => { + const { euiTheme } = useEuiTheme(); + + return ( + <EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty data-test-sub="reset" flush="both" onClick={onReset} size="s"> + {i18n.RESET} + </EuiButtonEmpty> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="center" gutterSize="none"> + <EuiFlexItem + css={css` + margin-right: ${euiTheme.size.s}; + `} + grow={false} + > + <EuiButtonEmpty data-test-sub="cancel" onClick={closeModal} size="s"> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiButton data-test-sub="save" fill onClick={onSave} size="s"> + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +FooterComponent.displayName = 'Footer'; + +export const Footer = React.memo(FooterComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx new file mode 100644 index 0000000000000..0d342c591f32b --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/index.tsx @@ -0,0 +1,160 @@ +/* + * 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 { + EuiButtonIcon, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, + EuiToolTip, + EuiTourStep, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { + ATTACK_DISCOVERY_STORAGE_KEY, + DEFAULT_ASSISTANT_NAMESPACE, + DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY, +} from '@kbn/elastic-assistant'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocalStorage } from 'react-use'; + +import { AlertsSettings } from './alerts_settings'; +import { useSpaceId } from '../../../../common/hooks/use_space_id'; +import { Footer } from './footer'; +import { getIsTourEnabled } from './is_tour_enabled'; +import * as i18n from './translations'; + +interface Props { + connectorId: string | undefined; + isLoading: boolean; + localStorageAttackDiscoveryMaxAlerts: string | undefined; + setLocalStorageAttackDiscoveryMaxAlerts: React.Dispatch<React.SetStateAction<string | undefined>>; +} + +const SettingsModalComponent: React.FC<Props> = ({ + connectorId, + isLoading, + localStorageAttackDiscoveryMaxAlerts, + setLocalStorageAttackDiscoveryMaxAlerts, +}) => { + const spaceId = useSpaceId() ?? 'default'; + const modalTitleId = useGeneratedHtmlId(); + + const [maxAlerts, setMaxAlerts] = useState( + localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` + ); + + const [isModalVisible, setIsModalVisible] = useState(false); + const showModal = useCallback(() => { + setMaxAlerts(localStorageAttackDiscoveryMaxAlerts ?? `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`); + + setIsModalVisible(true); + }, [localStorageAttackDiscoveryMaxAlerts]); + const closeModal = useCallback(() => setIsModalVisible(false), []); + + const onReset = useCallback(() => setMaxAlerts(`${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}`), []); + + const onSave = useCallback(() => { + setLocalStorageAttackDiscoveryMaxAlerts(maxAlerts); + closeModal(); + }, [closeModal, maxAlerts, setLocalStorageAttackDiscoveryMaxAlerts]); + + const [showSettingsTour, setShowSettingsTour] = useLocalStorage<boolean>( + `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${SHOW_SETTINGS_TOUR_LOCAL_STORAGE_KEY}.v8.16`, + true + ); + const onTourFinished = useCallback(() => setShowSettingsTour(() => false), [setShowSettingsTour]); + const [tourDelayElapsed, setTourDelayElapsed] = useState(false); + + useEffect(() => { + // visible EuiTourStep anchors don't follow the button when the layout changes (i.e. when the connectors finish loading) + const timeout = setTimeout(() => setTourDelayElapsed(true), 10000); + return () => clearTimeout(timeout); + }, []); + + const onSettingsClicked = useCallback(() => { + showModal(); + setShowSettingsTour(() => false); + }, [setShowSettingsTour, showModal]); + + const SettingsButton = useMemo( + () => ( + <EuiToolTip content={i18n.SETTINGS}> + <EuiButtonIcon + aria-label={i18n.SETTINGS} + data-test-subj="settings" + iconType="gear" + onClick={onSettingsClicked} + /> + </EuiToolTip> + ), + [onSettingsClicked] + ); + + const isTourEnabled = getIsTourEnabled({ + connectorId, + isLoading, + tourDelayElapsed, + showSettingsTour, + }); + + return ( + <> + {isTourEnabled ? ( + <EuiTourStep + anchorPosition="downCenter" + content={ + <> + <EuiText size="s"> + <p> + <span>{i18n.ATTACK_DISCOVERY_SENDS_MORE_ALERTS}</span> + <br /> + <span>{i18n.CONFIGURE_YOUR_SETTINGS_HERE}</span> + </p> + </EuiText> + </> + } + isStepOpen={showSettingsTour} + minWidth={300} + onFinish={onTourFinished} + step={1} + stepsTotal={1} + subtitle={i18n.RECENT_ATTACK_DISCOVERY_IMPROVEMENTS} + title={i18n.SEND_MORE_ALERTS} + > + {SettingsButton} + </EuiTourStep> + ) : ( + <>{SettingsButton}</> + )} + + {isModalVisible && ( + <EuiModal aria-labelledby={modalTitleId} data-test-subj="modal" onClose={closeModal}> + <EuiModalHeader> + <EuiModalHeaderTitle id={modalTitleId}>{i18n.SETTINGS}</EuiModalHeaderTitle> + </EuiModalHeader> + + <EuiModalBody> + <AlertsSettings maxAlerts={maxAlerts} setMaxAlerts={setMaxAlerts} /> + </EuiModalBody> + + <EuiModalFooter> + <Footer closeModal={closeModal} onReset={onReset} onSave={onSave} /> + </EuiModalFooter> + </EuiModal> + )} + </> + ); +}; + +SettingsModalComponent.displayName = 'SettingsModal'; + +export const SettingsModal = React.memo(SettingsModalComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts new file mode 100644 index 0000000000000..7f2f356114902 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/is_tour_enabled/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const getIsTourEnabled = ({ + connectorId, + isLoading, + tourDelayElapsed, + showSettingsTour, +}: { + connectorId: string | undefined; + isLoading: boolean; + tourDelayElapsed: boolean; + showSettingsTour: boolean | undefined; +}): boolean => !isLoading && connectorId != null && tourDelayElapsed && !!showSettingsTour; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts new file mode 100644 index 0000000000000..dc42db84f2d8a --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/header/settings_modal/translations.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.alertsLabel', + { + defaultMessage: 'Alerts', + } +); + +export const ATTACK_DISCOVERY_SENDS_MORE_ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.attackDiscoverySendsMoreAlertsTourText', + { + defaultMessage: 'Attack discovery sends more alerts as context.', + } +); + +export const CANCEL = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.cancelButton', + { + defaultMessage: 'Cancel', + } +); + +export const CONFIGURE_YOUR_SETTINGS_HERE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.configureYourSettingsHereTourText', + { + defaultMessage: 'Configure your settings here.', + } +); + +export const LATEST_AND_RISKIEST_OPEN_ALERTS = (alertsCount: number) => + i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.latestAndRiskiestOpenAlertsLabel', + { + defaultMessage: + 'Send Attack discovery information about your {alertsCount} newest and riskiest open or acknowledged alerts.', + values: { alertsCount }, + } + ); + +export const SAVE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.saveButton', + { + defaultMessage: 'Save', + } +); + +export const SEND_MORE_ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.tourTitle', + { + defaultMessage: 'Send more alerts', + } +); + +export const SETTINGS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.settingsLabel', + { + defaultMessage: 'Settings', + } +); + +export const RECENT_ATTACK_DISCOVERY_IMPROVEMENTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.tourSubtitle', + { + defaultMessage: 'Recent Attack discovery improvements', + } +); + +export const RESET = i18n.translate( + 'xpack.securitySolution.attackDiscovery.settingsModal.resetLabel', + { + defaultMessage: 'Reset', + } +); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts index e94687611ea8f..c7e1c579418b4 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.test.ts @@ -12,6 +12,7 @@ describe('helpers', () => { it('returns true when isLoading is false and alertsContextCount is 0', () => { const result = showNoAlertsPrompt({ alertsContextCount: 0, + connectorId: 'test', isLoading: false, }); @@ -21,6 +22,7 @@ describe('helpers', () => { it('returns false when isLoading is true', () => { const result = showNoAlertsPrompt({ alertsContextCount: 0, + connectorId: 'test', isLoading: true, }); @@ -30,6 +32,7 @@ describe('helpers', () => { it('returns false when alertsContextCount is null', () => { const result = showNoAlertsPrompt({ alertsContextCount: null, + connectorId: 'test', isLoading: false, }); @@ -39,6 +42,7 @@ describe('helpers', () => { it('returns false when alertsContextCount greater than 0', () => { const result = showNoAlertsPrompt({ alertsContextCount: 20, + connectorId: 'test', isLoading: false, }); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts index e3d3be963bacd..b990c3ccf1555 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/helpers.ts @@ -75,11 +75,14 @@ export const getErrorToastText = ( export const showNoAlertsPrompt = ({ alertsContextCount, + connectorId, isLoading, }: { alertsContextCount: number | null; + connectorId: string | undefined; isLoading: boolean; -}): boolean => !isLoading && alertsContextCount != null && alertsContextCount === 0; +}): boolean => + connectorId != null && !isLoading && alertsContextCount != null && alertsContextCount === 0; export const showWelcomePrompt = ({ aiConnectorsCount, @@ -111,12 +114,26 @@ export const showLoading = ({ loadingConnectorId: string | null; }): boolean => isLoading && (loadingConnectorId === connectorId || attackDiscoveriesCount === 0); -export const showSummary = ({ +export const showSummary = (attackDiscoveriesCount: number) => attackDiscoveriesCount > 0; + +export const showFailurePrompt = ({ connectorId, - attackDiscoveriesCount, - loadingConnectorId, + failureReason, + isLoading, }: { connectorId: string | undefined; - attackDiscoveriesCount: number; - loadingConnectorId: string | null; -}): boolean => loadingConnectorId !== connectorId && attackDiscoveriesCount > 0; + failureReason: string | null; + isLoading: boolean; +}): boolean => connectorId != null && !isLoading && failureReason != null; + +export const getSize = ({ + defaultMaxAlerts, + localStorageAttackDiscoveryMaxAlerts, +}: { + defaultMaxAlerts: number; + localStorageAttackDiscoveryMaxAlerts: string | undefined; +}): number => { + const size = Number(localStorageAttackDiscoveryMaxAlerts); + + return isNaN(size) || size <= 0 ? defaultMaxAlerts : size; +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx index ea5c16fc3cbba..e55b2fe5083b6 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx @@ -5,11 +5,13 @@ * 2.0. */ -import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLoadingLogo, EuiSpacer } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiLoadingLogo, EuiSpacer } from '@elastic/eui'; import { css } from '@emotion/react'; import { ATTACK_DISCOVERY_STORAGE_KEY, DEFAULT_ASSISTANT_NAMESPACE, + DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + MAX_ALERTS_LOCAL_STORAGE_KEY, useAssistantContext, useLoadConnectors, } from '@kbn/elastic-assistant'; @@ -23,23 +25,16 @@ import { HeaderPage } from '../../common/components/header_page'; import { useSpaceId } from '../../common/hooks/use_space_id'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { Header } from './header'; -import { - CONNECTOR_ID_LOCAL_STORAGE_KEY, - getInitialIsOpen, - showLoading, - showSummary, -} from './helpers'; -import { AttackDiscoveryPanel } from '../attack_discovery_panel'; -import { EmptyStates } from './empty_states'; +import { CONNECTOR_ID_LOCAL_STORAGE_KEY, getSize, showLoading } from './helpers'; import { LoadingCallout } from './loading_callout'; import { PageTitle } from './page_title'; -import { Summary } from './summary'; +import { Results } from './results'; import { useAttackDiscovery } from '../use_attack_discovery'; const AttackDiscoveryPageComponent: React.FC = () => { const spaceId = useSpaceId() ?? 'default'; - const { http, knowledgeBase } = useAssistantContext(); + const { http } = useAssistantContext(); const { data: aiConnectors } = useLoadConnectors({ http, }); @@ -54,6 +49,12 @@ const AttackDiscoveryPageComponent: React.FC = () => { `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${CONNECTOR_ID_LOCAL_STORAGE_KEY}` ); + const [localStorageAttackDiscoveryMaxAlerts, setLocalStorageAttackDiscoveryMaxAlerts] = + useLocalStorage<string>( + `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${MAX_ALERTS_LOCAL_STORAGE_KEY}`, + `${DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS}` + ); + const [connectorId, setConnectorId] = React.useState<string | undefined>( localStorageAttackDiscoveryConnectorId ); @@ -78,6 +79,10 @@ const AttackDiscoveryPageComponent: React.FC = () => { } = useAttackDiscovery({ connectorId, setLoadingConnectorId, + size: getSize({ + defaultMaxAlerts: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + localStorageAttackDiscoveryMaxAlerts, + }), }); // get last updated from the cached attack discoveries if it exists: @@ -159,9 +164,11 @@ const AttackDiscoveryPageComponent: React.FC = () => { isLoading={isLoading} // disable header actions before post request has completed isDisabledActions={isLoadingPost} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} onConnectorIdSelected={onConnectorIdSelected} onGenerate={onGenerate} onCancel={onCancel} + setLocalStorageAttackDiscoveryMaxAlerts={setLocalStorageAttackDiscoveryMaxAlerts} stats={stats} /> <EuiSpacer size="m" /> @@ -170,68 +177,37 @@ const AttackDiscoveryPageComponent: React.FC = () => { <EuiEmptyPrompt data-test-subj="animatedLogo" icon={animatedLogo} /> ) : ( <> - {showSummary({ + {showLoading({ attackDiscoveriesCount, connectorId, + isLoading: isLoading || isLoadingPost, loadingConnectorId, - }) && ( - <Summary + }) ? ( + <LoadingCallout + alertsContextCount={alertsContextCount} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} + approximateFutureTime={approximateFutureTime} + connectorIntervals={connectorIntervals} + /> + ) : ( + <Results + aiConnectorsCount={aiConnectors?.length ?? null} + alertsContextCount={alertsContextCount} alertsCount={alertsCount} attackDiscoveriesCount={attackDiscoveriesCount} - lastUpdated={selectedConnectorLastUpdated} + connectorId={connectorId} + failureReason={failureReason} + isLoading={isLoading} + isLoadingPost={isLoadingPost} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} + onGenerate={onGenerate} onToggleShowAnonymized={onToggleShowAnonymized} + selectedConnectorAttackDiscoveries={selectedConnectorAttackDiscoveries} + selectedConnectorLastUpdated={selectedConnectorLastUpdated} + selectedConnectorReplacements={selectedConnectorReplacements} showAnonymized={showAnonymized} /> )} - - <> - {showLoading({ - attackDiscoveriesCount, - connectorId, - isLoading: isLoading || isLoadingPost, - loadingConnectorId, - }) ? ( - <LoadingCallout - alertsCount={knowledgeBase.latestAlerts} - approximateFutureTime={approximateFutureTime} - connectorIntervals={connectorIntervals} - /> - ) : ( - selectedConnectorAttackDiscoveries.map((attackDiscovery, i) => ( - <React.Fragment key={attackDiscovery.id}> - <AttackDiscoveryPanel - attackDiscovery={attackDiscovery} - initialIsOpen={getInitialIsOpen(i)} - showAnonymized={showAnonymized} - replacements={selectedConnectorReplacements} - /> - <EuiSpacer size="l" /> - </React.Fragment> - )) - )} - </> - <EuiFlexGroup - css={css` - max-height: 100%; - min-height: 100%; - `} - direction="column" - gutterSize="none" - > - <EuiSpacer size="xxl" /> - <EuiFlexItem grow={false}> - <EmptyStates - aiConnectorsCount={aiConnectors?.length ?? null} - alertsContextCount={alertsContextCount} - alertsCount={knowledgeBase.latestAlerts} - attackDiscoveriesCount={attackDiscoveriesCount} - failureReason={failureReason} - connectorId={connectorId} - isLoading={isLoading || isLoadingPost} - onGenerate={onGenerate} - /> - </EuiFlexItem> - </EuiFlexGroup> </> )} <SpyRoute pageName={SecurityPageName.attackDiscovery} /> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx index af6efafb3c1dd..f755017288300 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.test.tsx @@ -29,9 +29,10 @@ describe('LoadingCallout', () => { ]; const defaultProps = { - alertsCount: 30, + alertsContextCount: 30, approximateFutureTime: new Date(), connectorIntervals, + localStorageAttackDiscoveryMaxAlerts: '50', }; it('renders the animated loading icon', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx index 7e392e3165711..aee8241ec73fc 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/index.tsx @@ -20,13 +20,15 @@ const BACKGROUND_COLOR_DARK = '#0B2030'; const BORDER_COLOR_DARK = '#0B2030'; interface Props { - alertsCount: number; + alertsContextCount: number | null; approximateFutureTime: Date | null; connectorIntervals: GenerationInterval[]; + localStorageAttackDiscoveryMaxAlerts: string | undefined; } const LoadingCalloutComponent: React.FC<Props> = ({ - alertsCount, + alertsContextCount, + localStorageAttackDiscoveryMaxAlerts, approximateFutureTime, connectorIntervals, }) => { @@ -46,11 +48,14 @@ const LoadingCalloutComponent: React.FC<Props> = ({ `} grow={false} > - <LoadingMessages alertsCount={alertsCount} /> + <LoadingMessages + alertsContextCount={alertsContextCount} + localStorageAttackDiscoveryMaxAlerts={localStorageAttackDiscoveryMaxAlerts} + /> </EuiFlexItem> </EuiFlexGroup> ), - [alertsCount, euiTheme.size.m] + [alertsContextCount, euiTheme.size.m, localStorageAttackDiscoveryMaxAlerts] ); const isDarkMode = theme.getTheme().darkMode === true; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts new file mode 100644 index 0000000000000..9a3061272ca15 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/get_loading_callout_alerts_count/index.ts @@ -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. + */ + +export const getLoadingCalloutAlertsCount = ({ + alertsContextCount, + defaultMaxAlerts, + localStorageAttackDiscoveryMaxAlerts, +}: { + alertsContextCount: number | null; + defaultMaxAlerts: number; + localStorageAttackDiscoveryMaxAlerts: string | undefined; +}): number => { + if (alertsContextCount != null && !isNaN(alertsContextCount) && alertsContextCount > 0) { + return alertsContextCount; + } + + const size = Number(localStorageAttackDiscoveryMaxAlerts); + + return isNaN(size) || size <= 0 ? defaultMaxAlerts : size; +}; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx index 250a25055791a..8b3f174792c5e 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.test.tsx @@ -16,7 +16,7 @@ describe('LoadingMessages', () => { it('renders the expected loading message', () => { render( <TestProviders> - <LoadingMessages alertsCount={20} /> + <LoadingMessages alertsContextCount={20} localStorageAttackDiscoveryMaxAlerts={'30'} /> </TestProviders> ); const attackDiscoveryGenerationInProgress = screen.getByTestId( @@ -31,7 +31,7 @@ describe('LoadingMessages', () => { it('renders the loading message with the expected alerts count', () => { render( <TestProviders> - <LoadingMessages alertsCount={20} /> + <LoadingMessages alertsContextCount={20} localStorageAttackDiscoveryMaxAlerts={'30'} /> </TestProviders> ); const aiCurrentlyAnalyzing = screen.getByTestId('aisCurrentlyAnalyzing'); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx index 9acd7b4d2dbbf..1a84771e5c635 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/loading_callout/loading_messages/index.tsx @@ -7,22 +7,34 @@ import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { css } from '@emotion/react'; +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import React from 'react'; import { useKibana } from '../../../../common/lib/kibana'; +import { getLoadingCalloutAlertsCount } from './get_loading_callout_alerts_count'; import * as i18n from '../translations'; const TEXT_COLOR = '#343741'; interface Props { - alertsCount: number; + alertsContextCount: number | null; + localStorageAttackDiscoveryMaxAlerts: string | undefined; } -const LoadingMessagesComponent: React.FC<Props> = ({ alertsCount }) => { +const LoadingMessagesComponent: React.FC<Props> = ({ + alertsContextCount, + localStorageAttackDiscoveryMaxAlerts, +}) => { const { theme } = useKibana().services; const isDarkMode = theme.getTheme().darkMode === true; + const alertsCount = getLoadingCalloutAlertsCount({ + alertsContextCount, + defaultMaxAlerts: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, + localStorageAttackDiscoveryMaxAlerts, + }); + return ( <EuiFlexGroup data-test-subj="loadingMessages" direction="column" gutterSize="none"> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx index 6c2640623e370..6c6bbfb25cb7f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx @@ -13,7 +13,7 @@ import { ATTACK_DISCOVERY_ONLY, LEARN_MORE, NO_ALERTS_TO_ANALYZE } from './trans describe('NoAlerts', () => { beforeEach(() => { - render(<NoAlerts />); + render(<NoAlerts isDisabled={false} isLoading={false} onGenerate={jest.fn()} />); }); it('renders the avatar', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx index a7b0cd929336b..ace75f568bf3d 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx @@ -17,8 +17,15 @@ import { import React, { useMemo } from 'react'; import * as i18n from './translations'; +import { Generate } from '../generate'; -const NoAlertsComponent: React.FC = () => { +interface Props { + isDisabled: boolean; + isLoading: boolean; + onGenerate: () => void; +} + +const NoAlertsComponent: React.FC<Props> = ({ isDisabled, isLoading, onGenerate }) => { const title = useMemo( () => ( <EuiFlexGroup @@ -83,6 +90,14 @@ const NoAlertsComponent: React.FC = () => { {i18n.LEARN_MORE} </EuiLink> </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiSpacer size="m" /> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <Generate isDisabled={isDisabled} isLoading={isLoading} onGenerate={onGenerate} /> + </EuiFlexItem> </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx new file mode 100644 index 0000000000000..6e3e43127e711 --- /dev/null +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx @@ -0,0 +1,112 @@ +/* + * 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 { EuiSpacer } from '@elastic/eui'; +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; +import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; +import React from 'react'; + +import { AttackDiscoveryPanel } from '../../attack_discovery_panel'; +import { EmptyStates } from '../empty_states'; +import { showEmptyStates } from '../empty_states/helpers/show_empty_states'; +import { getInitialIsOpen, showSummary } from '../helpers'; +import { Summary } from '../summary'; + +interface Props { + aiConnectorsCount: number | null; // null when connectors are not configured + alertsContextCount: number | null; // null when unavailable for the current connector + alertsCount: number; + attackDiscoveriesCount: number; + connectorId: string | undefined; + failureReason: string | null; + isLoading: boolean; + isLoadingPost: boolean; + localStorageAttackDiscoveryMaxAlerts: string | undefined; + onGenerate: () => Promise<void>; + onToggleShowAnonymized: () => void; + selectedConnectorAttackDiscoveries: AttackDiscovery[]; + selectedConnectorLastUpdated: Date | null; + selectedConnectorReplacements: Replacements; + showAnonymized: boolean; +} + +const ResultsComponent: React.FC<Props> = ({ + aiConnectorsCount, + alertsContextCount, + alertsCount, + attackDiscoveriesCount, + connectorId, + failureReason, + isLoading, + isLoadingPost, + localStorageAttackDiscoveryMaxAlerts, + onGenerate, + onToggleShowAnonymized, + selectedConnectorAttackDiscoveries, + selectedConnectorLastUpdated, + selectedConnectorReplacements, + showAnonymized, +}) => { + if ( + showEmptyStates({ + aiConnectorsCount, + alertsContextCount, + attackDiscoveriesCount, + connectorId, + failureReason, + isLoading, + }) + ) { + return ( + <> + <EuiSpacer size="xxl" /> + <EmptyStates + aiConnectorsCount={aiConnectorsCount} + alertsContextCount={alertsContextCount} + attackDiscoveriesCount={attackDiscoveriesCount} + failureReason={failureReason} + connectorId={connectorId} + isLoading={isLoading || isLoadingPost} + onGenerate={onGenerate} + upToAlertsCount={Number( + localStorageAttackDiscoveryMaxAlerts ?? DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS + )} + /> + </> + ); + } + + return ( + <> + {showSummary(attackDiscoveriesCount) && ( + <Summary + alertsCount={alertsCount} + attackDiscoveriesCount={attackDiscoveriesCount} + lastUpdated={selectedConnectorLastUpdated} + onToggleShowAnonymized={onToggleShowAnonymized} + showAnonymized={showAnonymized} + /> + )} + + {selectedConnectorAttackDiscoveries.map((attackDiscovery, i) => ( + <React.Fragment key={attackDiscovery.id}> + <AttackDiscoveryPanel + attackDiscovery={attackDiscovery} + initialIsOpen={getInitialIsOpen(i)} + showAnonymized={showAnonymized} + replacements={selectedConnectorReplacements} + /> + <EuiSpacer size="l" /> + </React.Fragment> + ))} + </> + ); +}; + +ResultsComponent.displayName = 'Results'; + +export const Results = React.memo(ResultsComponent); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts index f2fd17d5978b7..cc0034c90d1fa 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import { omit } from 'lodash/fp'; @@ -132,9 +133,7 @@ describe('getRequestBody', () => { }, ], }; - const knowledgeBase = { - latestAlerts: 20, - }; + const traceOptions = { apmUrl: '/app/apm', langSmithProject: '', @@ -145,7 +144,7 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern, anonymizationFields, - knowledgeBase, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -160,8 +159,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: knowledgeBase.latestAlerts, replacements: {}, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); @@ -170,7 +169,7 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern: undefined, anonymizationFields, - knowledgeBase, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -185,8 +184,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: knowledgeBase.latestAlerts, replacements: {}, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); @@ -195,7 +194,7 @@ describe('getRequestBody', () => { const withLangSmith = { alertsIndexPattern, anonymizationFields, - knowledgeBase, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions: { apmUrl: '/app/apm', langSmithProject: 'A project', @@ -216,7 +215,7 @@ describe('getRequestBody', () => { }, langSmithApiKey: withLangSmith.traceOptions.langSmithApiKey, langSmithProject: withLangSmith.traceOptions.langSmithProject, - size: knowledgeBase.latestAlerts, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, replacements: {}, subAction: 'invokeAI', }); @@ -226,8 +225,8 @@ describe('getRequestBody', () => { const result = getRequestBody({ alertsIndexPattern, anonymizationFields, - knowledgeBase, selectedConnector: connector, // <-- selectedConnector is provided + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -242,7 +241,7 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: knowledgeBase.latestAlerts, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, replacements: {}, subAction: 'invokeAI', }); @@ -258,8 +257,8 @@ describe('getRequestBody', () => { alertsIndexPattern, anonymizationFields, genAiConfig, // <-- genAiConfig is provided - knowledgeBase, selectedConnector: connector, // <-- selectedConnector is provided + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, traceOptions, }); @@ -274,8 +273,8 @@ describe('getRequestBody', () => { }, langSmithProject: undefined, langSmithApiKey: undefined, - size: knowledgeBase.latestAlerts, replacements: {}, + size: DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS, subAction: 'invokeAI', }); }); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts index 97eb132bdaaeb..7aa9bfdd118d9 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts @@ -5,10 +5,7 @@ * 2.0. */ -import type { - KnowledgeBaseConfig, - TraceOptions, -} from '@kbn/elastic-assistant/impl/assistant/types'; +import type { TraceOptions } from '@kbn/elastic-assistant/impl/assistant/types'; import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; import type { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types'; @@ -60,8 +57,8 @@ export const getRequestBody = ({ alertsIndexPattern, anonymizationFields, genAiConfig, - knowledgeBase, selectedConnector, + size, traceOptions, }: { alertsIndexPattern: string | undefined; @@ -83,7 +80,7 @@ export const getRequestBody = ({ }>; }; genAiConfig?: GenAiConfig; - knowledgeBase: KnowledgeBaseConfig; + size: number; selectedConnector?: ActionConnector; traceOptions: TraceOptions; }): AttackDiscoveryPostRequestBody => ({ @@ -95,8 +92,8 @@ export const getRequestBody = ({ langSmithApiKey: isEmpty(traceOptions?.langSmithApiKey) ? undefined : traceOptions?.langSmithApiKey, - size: knowledgeBase.latestAlerts, replacements: {}, // no need to re-use replacements in the current implementation + size, subAction: 'invokeAI', // non-streaming apiConfig: { connectorId: selectedConnector?.id ?? '', diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx index 6329ce5ca699a..59659ee6d8649 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx @@ -106,6 +106,8 @@ const mockAttackDiscoveries = [ const setLoadingConnectorId = jest.fn(); const setStatus = jest.fn(); +const SIZE = 20; + describe('useAttackDiscovery', () => { const mockPollApi = { cancelAttackDiscovery: jest.fn(), @@ -126,7 +128,11 @@ describe('useAttackDiscovery', () => { it('initializes with correct default values', () => { const { result } = renderHook(() => - useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId }) + useAttackDiscovery({ + connectorId: 'test-id', + setLoadingConnectorId, + size: 20, + }) ); expect(result.current.alertsContextCount).toBeNull(); @@ -144,14 +150,15 @@ describe('useAttackDiscovery', () => { it('fetches attack discoveries and updates state correctly', async () => { (mockedUseKibana.services.http.fetch as jest.Mock).mockResolvedValue(mockAttackDiscoveryPost); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); + await act(async () => { await result.current.fetchAttackDiscoveries(); }); expect(mockedUseKibana.services.http.fetch).toHaveBeenCalledWith( '/internal/elastic_assistant/attack_discovery', { - body: '{"alertsIndexPattern":"alerts-index-pattern","anonymizationFields":[],"size":20,"replacements":{},"subAction":"invokeAI","apiConfig":{"connectorId":"test-id","actionTypeId":".gen-ai"}}', + body: `{"alertsIndexPattern":"alerts-index-pattern","anonymizationFields":[],"replacements":{},"size":${SIZE},"subAction":"invokeAI","apiConfig":{"connectorId":"test-id","actionTypeId":".gen-ai"}}`, method: 'POST', version: '1', } @@ -167,7 +174,7 @@ describe('useAttackDiscovery', () => { const error = new Error(errorMessage); (mockedUseKibana.services.http.fetch as jest.Mock).mockRejectedValue(error); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); await act(async () => { await result.current.fetchAttackDiscoveries(); @@ -184,7 +191,11 @@ describe('useAttackDiscovery', () => { it('sets loading state based on poll status', async () => { (usePollApi as jest.Mock).mockReturnValue({ ...mockPollApi, status: 'running' }); const { result } = renderHook(() => - useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId }) + useAttackDiscovery({ + connectorId: 'test-id', + setLoadingConnectorId, + size: SIZE, + }) ); expect(result.current.isLoading).toBe(true); @@ -202,7 +213,7 @@ describe('useAttackDiscovery', () => { }, status: 'succeeded', }); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); expect(result.current.alertsContextCount).toEqual(20); // this is set from usePollApi @@ -227,7 +238,7 @@ describe('useAttackDiscovery', () => { }, status: 'failed', }); - const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' })); + const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id', size: SIZE })); expect(result.current.failureReason).toEqual('something bad'); expect(result.current.isLoading).toBe(false); @@ -241,7 +252,13 @@ describe('useAttackDiscovery', () => { data: [], // <-- zero connectors configured }); - renderHook(() => useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId })); + renderHook(() => + useAttackDiscovery({ + connectorId: 'test-id', + setLoadingConnectorId, + size: SIZE, + }) + ); }); afterEach(() => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx index deb1c556bdb43..4ad78981d4540 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx @@ -43,9 +43,11 @@ export interface UseAttackDiscovery { export const useAttackDiscovery = ({ connectorId, + size, setLoadingConnectorId, }: { connectorId: string | undefined; + size: number; setLoadingConnectorId?: (loadingConnectorId: string | null) => void; }): UseAttackDiscovery => { // get Kibana services and connectors @@ -75,7 +77,7 @@ export const useAttackDiscovery = ({ const [isLoading, setIsLoading] = useState(false); // get alerts index pattern and allow lists from the assistant context: - const { alertsIndexPattern, knowledgeBase, traceOptions } = useAssistantContext(); + const { alertsIndexPattern, traceOptions } = useAssistantContext(); const { data: anonymizationFields } = useFetchAnonymizationFields(); @@ -95,18 +97,11 @@ export const useAttackDiscovery = ({ alertsIndexPattern, anonymizationFields, genAiConfig, - knowledgeBase, + size, selectedConnector, traceOptions, }); - }, [ - aiConnectors, - alertsIndexPattern, - anonymizationFields, - connectorId, - knowledgeBase, - traceOptions, - ]); + }, [aiConnectors, alertsIndexPattern, anonymizationFields, connectorId, size, traceOptions]); useEffect(() => { if ( @@ -140,7 +135,7 @@ export const useAttackDiscovery = ({ useEffect(() => { if (pollData !== null && pollData.connectorId === connectorId) { if (pollData.alertsContextCount != null) setAlertsContextCount(pollData.alertsContextCount); - if (pollData.attackDiscoveries.length) { + if (pollData.attackDiscoveries.length && pollData.attackDiscoveries[0].timestamp != null) { // get last updated from timestamp, not from updatedAt since this can indicate the last time the status was updated setLastUpdated(new Date(pollData.attackDiscoveries[0].timestamp)); } diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts deleted file mode 100644 index 4d06751f57d7d..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; -import type { KibanaRequest } from '@kbn/core-http-server'; -import type { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common'; -import type { ActionsClientLlm } from '@kbn/langchain/server'; -import type { DynamicTool } from '@langchain/core/tools'; - -import { loggerMock } from '@kbn/logging-mocks'; - -import { ATTACK_DISCOVERY_TOOL } from './attack_discovery_tool'; -import { mockAnonymizationFields } from '../mock/mock_anonymization_fields'; -import { mockEmptyOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_empty_open_and_acknowledged_alerts_qery_results'; -import { mockOpenAndAcknowledgedAlertsQueryResults } from '../mock/mock_open_and_acknowledged_alerts_query_results'; - -jest.mock('langchain/chains', () => { - const mockLLMChain = jest.fn().mockImplementation(() => ({ - call: jest.fn().mockResolvedValue({ - records: [ - { - alertIds: [ - 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', - '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', - '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', - 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', - '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', - ], - detailsMarkdown: - '- Malicious Go application named "My Go Application.app" is being executed from temporary directories, likely indicating malware delivery\n- The malicious application is spawning child processes like `osascript` to display fake system dialogs and attempt to phish user credentials ({{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }}, {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }})\n- The malicious application is also executing `chmod` to make the file `unix1` executable ({{ file.path /Users/james/unix1 }})\n- `unix1` is a potentially malicious executable that is being run with suspicious arguments related to the macOS keychain ({{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }})\n- Multiple detections indicate the presence of malware on the host attempting credential access and execution of malicious payloads', - entitySummaryMarkdown: - 'Malicious activity detected on {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} involving user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', - mitreAttackTactics: ['Credential Access', 'Execution'], - summaryMarkdown: - 'Multiple detections indicate the presence of malware on a macOS host {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} attempting credential theft and execution of malicious payloads targeting the user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', - title: 'Malware Delivering Malicious Payloads on macOS', - }, - ], - }), - })); - - return { - LLMChain: mockLLMChain, - }; -}); - -describe('AttackDiscoveryTool', () => { - const alertsIndexPattern = '.alerts-security.alerts-default'; - const replacements = { uuid: 'original_value' }; - const size = 20; - const request = { - body: { - actionTypeId: '.bedrock', - alertsIndexPattern, - anonymizationFields: mockAnonymizationFields, - connectorId: 'test-connector-id', - replacements, - size, - subAction: 'invokeAI', - }, - } as unknown as KibanaRequest<unknown, unknown, AttackDiscoveryPostRequestBody>; - - const esClient = { - search: jest.fn(), - } as unknown as ElasticsearchClient; - const llm = jest.fn() as unknown as ActionsClientLlm; - const logger = loggerMock.create(); - - const rest = { - anonymizationFields: mockAnonymizationFields, - isEnabledKnowledgeBase: false, - llm, - logger, - onNewReplacements: jest.fn(), - size, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - (esClient.search as jest.Mock).mockResolvedValue(mockOpenAndAcknowledgedAlertsQueryResults); - }); - - describe('isSupported', () => { - it('returns false when the request is missing required anonymization parameters', () => { - const requestMissingAnonymizationParams = { - body: { - isEnabledKnowledgeBase: false, - alertsIndexPattern: '.alerts-security.alerts-default', - size: 20, - }, - } as unknown as KibanaRequest<unknown, unknown, AttackDiscoveryPostRequestBody>; - - const params = { - alertsIndexPattern, - esClient, - request: requestMissingAnonymizationParams, // <-- request is missing required anonymization parameters - ...rest, - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false when the alertsIndexPattern is undefined', () => { - const params = { - esClient, - request, - ...rest, - alertsIndexPattern: undefined, // <-- alertsIndexPattern is undefined - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false when size is undefined', () => { - const params = { - alertsIndexPattern, - esClient, - request, - ...rest, - size: undefined, // <-- size is undefined - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false when size is out of range', () => { - const params = { - alertsIndexPattern, - esClient, - request, - ...rest, - size: 0, // <-- size is out of range - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false when llm is undefined', () => { - const params = { - alertsIndexPattern, - esClient, - request, - ...rest, - llm: undefined, // <-- llm is undefined - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(false); - }); - - it('returns true if all required params are provided', () => { - const params = { - alertsIndexPattern, - esClient, - request, - ...rest, - }; - - expect(ATTACK_DISCOVERY_TOOL.isSupported(params)).toBe(true); - }); - }); - - describe('getTool', () => { - it('returns null when llm is undefined', () => { - const tool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - llm: undefined, // <-- llm is undefined - }); - - expect(tool).toBeNull(); - }); - - it('returns a `DynamicTool` with a `func` that calls `esClient.search()` with the expected alerts query', async () => { - const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - }) as DynamicTool; - - await tool.func(''); - - expect(esClient.search).toHaveBeenCalledWith({ - allow_no_indices: true, - body: { - _source: false, - fields: mockAnonymizationFields.map(({ field }) => ({ - field, - include_unmapped: true, - })), - query: { - bool: { - filter: [ - { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'kibana.alert.workflow_status': 'open', - }, - }, - { - match_phrase: { - 'kibana.alert.workflow_status': 'acknowledged', - }, - }, - ], - }, - }, - { - range: { - '@timestamp': { - format: 'strict_date_optional_time', - gte: 'now-24h', - lte: 'now', - }, - }, - }, - ], - must: [], - must_not: [ - { - exists: { - field: 'kibana.alert.building_block_type', - }, - }, - ], - should: [], - }, - }, - ], - }, - }, - runtime_mappings: {}, - size, - sort: [ - { - 'kibana.alert.risk_score': { - order: 'desc', - }, - }, - { - '@timestamp': { - order: 'desc', - }, - }, - ], - }, - ignore_unavailable: true, - index: [alertsIndexPattern], - }); - }); - - it('returns a `DynamicTool` with a `func` returns an empty attack discoveries array when getAnonymizedAlerts returns no alerts', async () => { - (esClient.search as jest.Mock).mockResolvedValue( - mockEmptyOpenAndAcknowledgedAlertsQueryResults // <-- no alerts - ); - - const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - }) as DynamicTool; - - const result = await tool.func(''); - const expected = JSON.stringify({ alertsContextCount: 0, attackDiscoveries: [] }, null, 2); // <-- empty attack discoveries array - - expect(result).toEqual(expected); - }); - - it('returns a `DynamicTool` with a `func` that returns the expected results', async () => { - const tool: DynamicTool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - }) as DynamicTool; - - await tool.func(''); - - const result = await tool.func(''); - const expected = JSON.stringify( - { - alertsContextCount: 20, - attackDiscoveries: [ - { - alertIds: [ - 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', - '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', - '600eb9eca925f4c5b544b4e9d3cf95d83b7829f8f74c5bd746369cb4c2968b9a', - 'e1f4a4ed70190eb4bd256c813029a6a9101575887cdbfa226ac330fbd3063f0c', - '2a7a4809ca625dfe22ccd35fbef7a7ba8ed07f109e5cbd17250755cfb0bc615f', - ], - detailsMarkdown: - '- Malicious Go application named "My Go Application.app" is being executed from temporary directories, likely indicating malware delivery\n- The malicious application is spawning child processes like `osascript` to display fake system dialogs and attempt to phish user credentials ({{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }}, {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }})\n- The malicious application is also executing `chmod` to make the file `unix1` executable ({{ file.path /Users/james/unix1 }})\n- `unix1` is a potentially malicious executable that is being run with suspicious arguments related to the macOS keychain ({{ process.command_line /Users/james/unix1 /Users/james/library/Keychains/login.keychain-db TempTemp1234!! }})\n- Multiple detections indicate the presence of malware on the host attempting credential access and execution of malicious payloads', - entitySummaryMarkdown: - 'Malicious activity detected on {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} involving user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', - mitreAttackTactics: ['Credential Access', 'Execution'], - summaryMarkdown: - 'Multiple detections indicate the presence of malware on a macOS host {{ host.name 6c57a4f7-b30b-465d-a670-47377655b1bb }} attempting credential theft and execution of malicious payloads targeting the user {{ user.name 639fab6d-369b-4879-beae-7767a7145c7f }}.', - title: 'Malware Delivering Malicious Payloads on macOS', - }, - ], - }, - null, - 2 - ); - - expect(result).toEqual(expected); - }); - - it('returns a tool instance with the expected tags', () => { - const tool = ATTACK_DISCOVERY_TOOL.getTool({ - alertsIndexPattern, - esClient, - replacements, - request, - ...rest, - }) as DynamicTool; - - expect(tool.tags).toEqual(['attack-discovery']); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts deleted file mode 100644 index 264862d76b8f5..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { PromptTemplate } from '@langchain/core/prompts'; -import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; -import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-plugin/server'; -import { LLMChain } from 'langchain/chains'; -import { OutputFixingParser } from 'langchain/output_parsers'; -import { DynamicTool } from '@langchain/core/tools'; - -import { APP_UI_ID } from '../../../../common'; -import { getAnonymizedAlerts } from './get_anonymized_alerts'; -import { getOutputParser } from './get_output_parser'; -import { sizeIsOutOfRange } from '../open_and_acknowledged_alerts/helpers'; -import { getAttackDiscoveryPrompt } from './get_attack_discovery_prompt'; - -export interface AttackDiscoveryToolParams extends AssistantToolParams { - alertsIndexPattern: string; - size: number; -} - -export const ATTACK_DISCOVERY_TOOL_DESCRIPTION = - 'Call this for attack discoveries containing `markdown` that should be displayed verbatim (with no additional processing).'; - -/** - * Returns a tool for generating attack discoveries from open and acknowledged - * alerts, or null if the request doesn't have all the required parameters. - */ -export const ATTACK_DISCOVERY_TOOL: AssistantTool = { - id: 'attack-discovery', - name: 'AttackDiscoveryTool', - description: ATTACK_DISCOVERY_TOOL_DESCRIPTION, - sourceRegister: APP_UI_ID, - isSupported: (params: AssistantToolParams): params is AttackDiscoveryToolParams => { - const { alertsIndexPattern, llm, request, size } = params; - - return ( - requestHasRequiredAnonymizationParams(request) && - alertsIndexPattern != null && - size != null && - !sizeIsOutOfRange(size) && - llm != null - ); - }, - getTool(params: AssistantToolParams) { - if (!this.isSupported(params)) return null; - - const { - alertsIndexPattern, - anonymizationFields, - esClient, - langChainTimeout, - llm, - onNewReplacements, - replacements, - size, - } = params as AttackDiscoveryToolParams; - - return new DynamicTool({ - name: 'AttackDiscoveryTool', - description: ATTACK_DISCOVERY_TOOL_DESCRIPTION, - func: async () => { - if (llm == null) { - throw new Error('LLM is required for attack discoveries'); - } - - const anonymizedAlerts = await getAnonymizedAlerts({ - alertsIndexPattern, - anonymizationFields, - esClient, - onNewReplacements, - replacements, - size, - }); - - const alertsContextCount = anonymizedAlerts.length; - if (alertsContextCount === 0) { - // No alerts to analyze, so return an empty attack discoveries array - return JSON.stringify({ alertsContextCount, attackDiscoveries: [] }, null, 2); - } - - const outputParser = getOutputParser(); - const outputFixingParser = OutputFixingParser.fromLLM(llm, outputParser); - - const prompt = new PromptTemplate({ - template: `Answer the user's question as best you can:\n{format_instructions}\n{query}`, - inputVariables: ['query'], - partialVariables: { - format_instructions: outputFixingParser.getFormatInstructions(), - }, - }); - - const answerFormattingChain = new LLMChain({ - llm, - prompt, - outputKey: 'records', - outputParser: outputFixingParser, - }); - - const result = await answerFormattingChain.call({ - query: getAttackDiscoveryPrompt({ anonymizedAlerts }), - timeout: langChainTimeout, - }); - const attackDiscoveries = result.records; - - return JSON.stringify({ alertsContextCount, attackDiscoveries }, null, 2); - }, - tags: ['attack-discovery'], - }); - }, -}; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts deleted file mode 100644 index df211f0bd0a7d..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_attack_discovery_prompt.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// NOTE: we ask the LLM to `provide insights`. We do NOT use the feature name, `AttackDiscovery`, in the prompt. -export const getAttackDiscoveryPrompt = ({ - anonymizedAlerts, -}: { - anonymizedAlerts: string[]; -}) => `You are a cyber security analyst tasked with analyzing security events from Elastic Security to identify and report on potential cyber attacks or progressions. Your report should focus on high-risk incidents that could severely impact the organization, rather than isolated alerts. Present your findings in a way that can be easily understood by anyone, regardless of their technical expertise, as if you were briefing the CISO. Break down your response into sections based on timing, hosts, and users involved. When correlating alerts, use kibana.alert.original_time when it's available, otherwise use @timestamp. Include appropriate context about the affected hosts and users. Describe how the attack progression might have occurred and, if feasible, attribute it to known threat groups. Prioritize high and critical alerts, but include lower-severity alerts if desired. In the description field, provide as much detail as possible, in a bulleted list explaining any attack progressions. Accuracy is of utmost importance. Escape backslashes to respect JSON validation. New lines must always be escaped with double backslashes, i.e. \\\\n to ensure valid JSON. Only return JSON output, as described above. Do not add any additional text to describe your output. - -Use context from the following open and acknowledged alerts to provide insights: - -""" -${anonymizedAlerts.join('\n\n')} -""" -`; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts deleted file mode 100644 index 5ad2cd11f817a..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { getOutputParser } from './get_output_parser'; - -describe('getOutputParser', () => { - it('returns a structured output parser with the expected format instructions', () => { - const outputParser = getOutputParser(); - - const expected = `You must format your output as a JSON value that adheres to a given \"JSON Schema\" instance. - -\"JSON Schema\" is a declarative language that allows you to annotate and validate JSON documents. - -For example, the example \"JSON Schema\" instance {{\"properties\": {{\"foo\": {{\"description\": \"a list of test words\", \"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}}}, \"required\": [\"foo\"]}}}} -would match an object with one required property, \"foo\". The \"type\" property specifies \"foo\" must be an \"array\", and the \"description\" property semantically describes it as \"a list of test words\". The items within \"foo\" must be strings. -Thus, the object {{\"foo\": [\"bar\", \"baz\"]}} is a well-formatted instance of this example \"JSON Schema\". The object {{\"properties\": {{\"foo\": [\"bar\", \"baz\"]}}}} is not well-formatted. - -Your output will be parsed and type-checked according to the provided schema instance, so make sure all fields in your output match the schema exactly and there are no trailing commas! - -Here is the JSON Schema instance your output must adhere to. Include the enclosing markdown codeblock: -\`\`\`json -{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"alertIds\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"The alert IDs that the insight is based on.\"},\"detailsMarkdown\":{\"type\":\"string\",\"description\":\"A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\"},\"entitySummaryMarkdown\":{\"type\":\"string\",\"description\":\"A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"mitreAttackTactics\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"An array of MITRE ATT&CK tactic for the insight, using one of the following values: Reconnaissance,Initial Access,Execution,Persistence,Privilege Escalation,Discovery,Lateral Movement,Command and Control,Exfiltration\"},\"summaryMarkdown\":{\"type\":\"string\",\"description\":\"A markdown summary of insight, using the same {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax\"},\"title\":{\"type\":\"string\",\"description\":\"A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.\"}},\"required\":[\"alertIds\",\"detailsMarkdown\",\"summaryMarkdown\",\"title\"],\"additionalProperties\":false},\"description\":\"Insights with markdown that always uses special {{ field.name fieldValue1 fieldValue2 fieldValueN }} syntax for field names and values from the source data. Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }} Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}\",\"$schema\":\"http://json-schema.org/draft-07/schema#\"} -\`\`\` -`; - - expect(outputParser.getFormatInstructions()).toEqual(expected); - }); -}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts deleted file mode 100644 index 3d66257f060e4..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/get_output_parser.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { StructuredOutputParser } from 'langchain/output_parsers'; -import { z } from '@kbn/zod'; - -export const SYNTAX = '{{ field.name fieldValue1 fieldValue2 fieldValueN }}'; -const GOOD_SYNTAX_EXAMPLES = - 'Examples of CORRECT syntax (includes field names and values): {{ host.name hostNameValue }} {{ user.name userNameValue }} {{ source.ip sourceIpValue }}'; - -const BAD_SYNTAX_EXAMPLES = - 'Examples of INCORRECT syntax (bad, because the field names are not included): {{ hostNameValue }} {{ userNameValue }} {{ sourceIpValue }}'; - -const RECONNAISSANCE = 'Reconnaissance'; -const INITIAL_ACCESS = 'Initial Access'; -const EXECUTION = 'Execution'; -const PERSISTENCE = 'Persistence'; -const PRIVILEGE_ESCALATION = 'Privilege Escalation'; -const DISCOVERY = 'Discovery'; -const LATERAL_MOVEMENT = 'Lateral Movement'; -const COMMAND_AND_CONTROL = 'Command and Control'; -const EXFILTRATION = 'Exfiltration'; - -const MITRE_ATTACK_TACTICS = [ - RECONNAISSANCE, - INITIAL_ACCESS, - EXECUTION, - PERSISTENCE, - PRIVILEGE_ESCALATION, - DISCOVERY, - LATERAL_MOVEMENT, - COMMAND_AND_CONTROL, - EXFILTRATION, -] as const; - -// NOTE: we ask the LLM for `insight`s. We do NOT use the feature name, `AttackDiscovery`, in the prompt. -export const getOutputParser = () => - StructuredOutputParser.fromZodSchema( - z - .array( - z.object({ - alertIds: z.string().array().describe(`The alert IDs that the insight is based on.`), - detailsMarkdown: z - .string() - .describe( - `A detailed insight with markdown, where each markdown bullet contains a description of what happened that reads like a story of the attack as it played out and always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` - ), - entitySummaryMarkdown: z - .string() - .optional() - .describe( - `A short (no more than a sentence) summary of the insight featuring only the host.name and user.name fields (when they are applicable), using the same ${SYNTAX} syntax` - ), - mitreAttackTactics: z - .string() - .array() - .optional() - .describe( - `An array of MITRE ATT&CK tactic for the insight, using one of the following values: ${MITRE_ATTACK_TACTICS.join( - ',' - )}` - ), - summaryMarkdown: z - .string() - .describe(`A markdown summary of insight, using the same ${SYNTAX} syntax`), - title: z - .string() - .describe( - 'A short, no more than 7 words, title for the insight, NOT formatted with special syntax or markdown. This must be as brief as possible.' - ), - }) - ) - .describe( - `Insights with markdown that always uses special ${SYNTAX} syntax for field names and values from the source data. ${GOOD_SYNTAX_EXAMPLES} ${BAD_SYNTAX_EXAMPLES}` - ) - ); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/index.ts b/x-pack/plugins/security_solution/server/assistant/tools/index.ts index a704aaa44d0a1..1b6e90eb7280f 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/index.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/index.ts @@ -10,7 +10,6 @@ import type { AssistantTool } from '@kbn/elastic-assistant-plugin/server'; import { NL_TO_ESQL_TOOL } from './esql/nl_to_esql_tool'; import { ALERT_COUNTS_TOOL } from './alert_counts/alert_counts_tool'; import { OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL } from './open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool'; -import { ATTACK_DISCOVERY_TOOL } from './attack_discovery/attack_discovery_tool'; import { KNOWLEDGE_BASE_RETRIEVAL_TOOL } from './knowledge_base/knowledge_base_retrieval_tool'; import { KNOWLEDGE_BASE_WRITE_TOOL } from './knowledge_base/knowledge_base_write_tool'; import { SECURITY_LABS_KNOWLEDGE_BASE_TOOL } from './security_labs/security_labs_tool'; @@ -22,7 +21,6 @@ export const getAssistantTools = ({ }): AssistantTool[] => { const tools = [ ALERT_COUNTS_TOOL, - ATTACK_DISCOVERY_TOOL, NL_TO_ESQL_TOOL, KNOWLEDGE_BASE_RETRIEVAL_TOOL, KNOWLEDGE_BASE_WRITE_TOOL, diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts deleted file mode 100644 index 722936a368b36..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - getRawDataOrDefault, - isRawDataValid, - MAX_SIZE, - MIN_SIZE, - sizeIsOutOfRange, -} from './helpers'; - -describe('helpers', () => { - describe('isRawDataValid', () => { - it('returns true for valid raw data', () => { - const rawData = { - field1: [1, 2, 3], // the Fields API may return a number array - field2: ['a', 'b', 'c'], // the Fields API may return a string array - }; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns true when a field array is empty', () => { - const rawData = { - field1: [1, 2, 3], // the Fields API may return a number array - field2: ['a', 'b', 'c'], // the Fields API may return a string array - field3: [], // the Fields API may return an empty array - }; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns false when a field does not have an array of values', () => { - const rawData = { - field1: [1, 2, 3], - field2: 'invalid', - }; - - expect(isRawDataValid(rawData)).toBe(false); - }); - - it('returns true for empty raw data', () => { - const rawData = {}; - - expect(isRawDataValid(rawData)).toBe(true); - }); - - it('returns false when raw data is an unexpected type', () => { - const rawData = 1234; - - // @ts-expect-error - expect(isRawDataValid(rawData)).toBe(false); - }); - }); - - describe('getRawDataOrDefault', () => { - it('returns the raw data when it is valid', () => { - const rawData = { - field1: [1, 2, 3], - field2: ['a', 'b', 'c'], - }; - - expect(getRawDataOrDefault(rawData)).toEqual(rawData); - }); - - it('returns an empty object when the raw data is invalid', () => { - const rawData = { - field1: [1, 2, 3], - field2: 'invalid', - }; - - expect(getRawDataOrDefault(rawData)).toEqual({}); - }); - }); - - describe('sizeIsOutOfRange', () => { - it('returns true when size is undefined', () => { - const size = undefined; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns true when size is less than MIN_SIZE', () => { - const size = MIN_SIZE - 1; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns true when size is greater than MAX_SIZE', () => { - const size = MAX_SIZE + 1; - - expect(sizeIsOutOfRange(size)).toBe(true); - }); - - it('returns false when size is exactly MIN_SIZE', () => { - const size = MIN_SIZE; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); - - it('returns false when size is exactly MAX_SIZE', () => { - const size = MAX_SIZE; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); - - it('returns false when size is within the valid range', () => { - const size = MIN_SIZE + 1; - - expect(sizeIsOutOfRange(size)).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts deleted file mode 100644 index dcb30e04e9dbc..0000000000000 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/helpers.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; - -export const MIN_SIZE = 10; -export const MAX_SIZE = 10000; - -export type MaybeRawData = SearchResponse['fields'] | undefined; // note: this is the type of the "fields" property in the ES response - -export const isRawDataValid = (rawData: MaybeRawData): rawData is Record<string, unknown[]> => - typeof rawData === 'object' && Object.keys(rawData).every((x) => Array.isArray(rawData[x])); - -export const getRawDataOrDefault = (rawData: MaybeRawData): Record<string, unknown[]> => - isRawDataValid(rawData) ? rawData : {}; - -export const sizeIsOutOfRange = (size?: number): boolean => - size == null || size < MIN_SIZE || size > MAX_SIZE; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts index 09bae1639f1b1..45587b65f5f4c 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts @@ -10,12 +10,13 @@ import type { KibanaRequest } from '@kbn/core-http-server'; import type { DynamicTool } from '@langchain/core/tools'; import { OPEN_AND_ACKNOWLEDGED_ALERTS_TOOL } from './open_and_acknowledged_alerts_tool'; -import { MAX_SIZE } from './helpers'; import type { RetrievalQAChain } from 'langchain/chains'; import { mockAlertsFieldsApi } from '@kbn/elastic-assistant-plugin/server/__mocks__/alerts'; import type { ExecuteConnectorRequestBody } from '@kbn/elastic-assistant-common/impl/schemas/actions_connector/post_actions_connector_execute_route.gen'; import { loggerMock } from '@kbn/logging-mocks'; +const MAX_SIZE = 10000; + describe('OpenAndAcknowledgedAlertsTool', () => { const alertsIndexPattern = 'alerts-index'; const esClient = { diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts index d6b0ad58d8adb..cab015183f4a2 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.ts @@ -7,13 +7,17 @@ import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { Replacements } from '@kbn/elastic-assistant-common'; -import { getAnonymizedValue, transformRawData } from '@kbn/elastic-assistant-common'; +import { + getAnonymizedValue, + getOpenAndAcknowledgedAlertsQuery, + getRawDataOrDefault, + sizeIsOutOfRange, + transformRawData, +} from '@kbn/elastic-assistant-common'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { requestHasRequiredAnonymizationParams } from '@kbn/elastic-assistant-plugin/server/lib/langchain/helpers'; import { z } from '@kbn/zod'; import type { AssistantTool, AssistantToolParams } from '@kbn/elastic-assistant-plugin/server'; -import { getOpenAndAcknowledgedAlertsQuery } from './get_open_and_acknowledged_alerts_query'; -import { getRawDataOrDefault, sizeIsOutOfRange } from './helpers'; import { APP_UI_ID } from '../../../../common'; export interface OpenAndAcknowledgedAlertsToolParams extends AssistantToolParams { diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 0d369f3c620c4..ce79bd061548f 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -205,7 +205,6 @@ "@kbn/search-types", "@kbn/field-utils", "@kbn/core-saved-objects-api-server-mocks", - "@kbn/langchain", "@kbn/core-analytics-browser", "@kbn/core-i18n-browser", "@kbn/core-theme-browser",