diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts index 9f628abb38104..cc8f78780ff47 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/alerts/get_open_and_acknowledged_alerts_query/index.ts @@ -34,10 +34,35 @@ interface GetOpenAndAcknowledgedAlertsQuery { index: string[]; } +/** + * The workflow status filter for open and acknowledged alerts + */ +const WORKFLOW_STATUS_FILTER = { + bool: { + should: [ + { + match_phrase: { + 'kibana.alert.workflow_status': 'open', + }, + }, + { + match_phrase: { + 'kibana.alert.workflow_status': 'acknowledged', + }, + }, + ], + minimum_should_match: 1, + }, +}; + /** * 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. + * + * @param allowAllWorkflowStatuses - If true, the workflow status filter (open/acknowledged) is not applied, + * allowing alerts of any status to be returned. This is useful for case-based Attack Discovery + * where you want to analyze all alerts attached to a case regardless of their current status. */ export const getOpenAndAcknowledgedAlertsQuery = ({ alertsIndexPattern, @@ -46,6 +71,7 @@ export const getOpenAndAcknowledgedAlertsQuery = ({ filter, size, start, + allowAllWorkflowStatuses, }: { alertsIndexPattern: string; anonymizationFields: AnonymizationFieldResponse[]; @@ -53,6 +79,8 @@ export const getOpenAndAcknowledgedAlertsQuery = ({ filter?: Record | null; size: number; start?: DateMath | null; + /** If true, skips the workflow status filter (open/acknowledged) allowing alerts of any status */ + allowAllWorkflowStatuses?: boolean; }): GetOpenAndAcknowledgedAlertsQuery => ({ allow_no_indices: true, fields: anonymizationFields @@ -68,23 +96,8 @@ export const getOpenAndAcknowledgedAlertsQuery = ({ bool: { must: [], filter: [ - { - bool: { - should: [ - { - match_phrase: { - 'kibana.alert.workflow_status': 'open', - }, - }, - { - match_phrase: { - 'kibana.alert.workflow_status': 'acknowledged', - }, - }, - ], - minimum_should_match: 1, - }, - }, + // Only include workflow status filter if not bypassed + ...(allowAllWorkflowStatuses ? [] : [WORKFLOW_STATUS_FILTER]), ...(filter != null ? [filter] : []), { range: { diff --git a/x-pack/platform/plugins/shared/cases/common/constants/attack_discovery.ts b/x-pack/platform/plugins/shared/cases/common/constants/attack_discovery.ts new file mode 100644 index 0000000000000..9f80bbd139d07 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/constants/attack_discovery.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. + */ + +export const ATTACK_DISCOVERY_ATTACHMENT_TYPE = '.attack-discovery' as const; + + + + diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/attachment/attack_discovery.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/attachment/attack_discovery.ts new file mode 100644 index 0000000000000..8a83019748053 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/attachment/attack_discovery.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 * as rt from 'io-ts'; + +/** + * Attack Discovery Attachment Metadata + * This metadata is stored with external reference attachments for attack discoveries + */ +export const AttackDiscoveryAttachmentMetadataRt = rt.strict({ + /** + * The attack discovery alert ID (the ID of the alert that represents the attack discovery) + */ + attackDiscoveryAlertId: rt.string, + /** + * The index where the attack discovery alert is stored + */ + index: rt.string, + /** + * The generation UUID of the attack discovery run + */ + generationUuid: rt.string, + /** + * The title of the attack discovery + */ + title: rt.string, + /** + * The timestamp when the attack discovery was generated + */ + timestamp: rt.string, +}); + +export type AttackDiscoveryAttachmentMetadata = rt.TypeOf< + typeof AttackDiscoveryAttachmentMetadataRt +>; + + + + diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attack_discoveries.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attack_discoveries.tsx new file mode 100644 index 0000000000000..9bd9dfd4f423a --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_attack_discoveries.tsx @@ -0,0 +1,133 @@ +/* + * 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 { EuiEmptyPrompt, EuiFlexItem } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { css } from '@emotion/react'; +import type { CaseUI } from '../../../../common'; +import { AttachmentType } from '../../../../common/types/domain'; +import { ATTACK_DISCOVERY_ATTACHMENT_TYPE } from '../../../../common/constants'; +import type { AttachmentUI } from '../../../containers/types'; +import { UserActionsList } from '../../user_actions/user_actions_list'; +import { useFindCaseUserActions } from '../../../containers/use_find_case_user_actions'; +import { useGetCaseConnectors } from '../../../containers/use_get_case_connectors'; +import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; +import { useGetCaseUsers } from '../../../containers/use_get_case_users'; +import { parseCaseUsers } from '../../utils'; +import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile'; + +interface CaseViewAttackDiscoveriesProps { + caseData: CaseUI; +} + +export const CaseViewAttackDiscoveries = ({ caseData }: CaseViewAttackDiscoveriesProps) => { + // Fetch user actions - this will get the latest data + const { data: userActionsData, isLoading } = useFindCaseUserActions( + caseData.id, + { + type: 'all', + sortOrder: 'asc', + page: 1, + perPage: 100, // Maximum allowed perPage value + }, + true + ); + + const { data: caseConnectors } = useGetCaseConnectors(caseData.id); + const { data: casesConfiguration } = useGetCaseConfiguration(); + const { data: caseUsers } = useGetCaseUsers(caseData.id); + const { data: currentUserProfile } = useGetCurrentUserProfile(); + + // Wait for required data to load + if (!caseConnectors || !casesConfiguration) { + return null; + } + + const { userProfiles } = parseCaseUsers({ + caseUsers, + createdBy: caseData.createdBy, + }); + + // Filter attachments to only attack discoveries - use latestAttachments from userActionsData + const attackDiscoveryAttachments = useMemo(() => { + if (!userActionsData) return []; + return userActionsData.latestAttachments.filter((attachment: AttachmentUI) => { + if (attachment.type !== AttachmentType.externalReference) { + return false; + } + return ( + 'externalReferenceAttachmentTypeId' in attachment && + attachment.externalReferenceAttachmentTypeId === ATTACK_DISCOVERY_ATTACHMENT_TYPE + ); + }); + }, [userActionsData]); + + // Get attack discovery attachment IDs from the filtered attachments + const attackDiscoveryIds = useMemo( + () => attackDiscoveryAttachments.map((ad) => ad.id), + [attackDiscoveryAttachments] + ); + + // Filter user actions to only show attack discovery attachments + const attackDiscoveryUserActions = useMemo(() => { + if (!userActionsData) return []; + return userActionsData.userActions.filter( + (userAction) => + userAction.type === 'comment' && + userAction.action === 'create' && + userAction.commentId != null && + attackDiscoveryIds.includes(userAction.commentId) + ); + }, [userActionsData, attackDiscoveryIds]); + + if (attackDiscoveryAttachments.length === 0 && !isLoading) { + return ( + + No attack discoveries} + body={

Attack discoveries will appear here when they are generated for this case.

} + /> +
+ ); + } + + return ( + + { }} + onShowAlertDetails={() => { }} + loadingAlertData={false} + manualAlertsData={{}} + commentRefs={{ current: {} }} + handleManageQuote={() => { }} + /> + + ); +}; + +CaseViewAttackDiscoveries.displayName = 'CaseViewAttackDiscoveries'; + diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx index 457862efc153b..d8644eb7e3255 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/use_case_attachment_tabs.tsx @@ -12,13 +12,14 @@ import React, { useMemo } from 'react'; import { css } from '@emotion/react'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { ALERTS_TAB, EVENTS_TAB, FILES_TAB, OBSERVABLES_TAB } from './translations'; +import { ALERTS_TAB, EVENTS_TAB, FILES_TAB, OBSERVABLES_TAB, ATTACK_DISCOVERIES_TAB } from './translations'; import { type CaseUI } from '../../../common'; import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats'; import { useCaseObservables } from './use_case_observables'; import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; import { useCasesFeatures } from '../../common/use_cases_features'; import { AttachmentType } from '../../../common/types/domain'; +import { ATTACK_DISCOVERY_ATTACHMENT_TYPE } from '../../../common/constants'; const FilesBadge = ({ activeTab, @@ -110,21 +111,24 @@ export const AttachmentsBadge = ({ isActive: boolean; count?: number; euiTheme: EuiThemeComputed<{}>; -}) => ( - <> - { - - {count ?? 0} - - } - -); +}) => { + const displayCount = count != null && !isNaN(count) ? count : 0; + return ( + <> + { + + {displayCount} + + } + + ); +}; AttachmentsBadge.displayName = 'AttachmentsBadge'; @@ -209,68 +213,68 @@ export const useCaseAttachmentTabs = ({ const { observablesAuthorized: canShowObservableTabs, isObservablesFeatureEnabled } = useCasesFeatures(); - const stats = useMemo(() => { - if (!searchTerm) { - return { - totalAlerts: Number(caseData.totalAlerts), - totalEvents: Number(caseData.totalEvents), - }; - } - return caseData.comments.reduce( - (acc, comment) => { - if (comment.type === AttachmentType.alert && features.alerts.enabled) { - acc.totalAlerts = Array.isArray(comment.alertId) - ? acc.totalAlerts + comment.alertId.length - : acc.totalAlerts + 1; - } else if (comment.type === AttachmentType.event && features.events.enabled) { - acc.totalEvents = Array.isArray(comment.eventId) - ? acc.totalEvents + comment.eventId.length - : acc.totalEvents + 1; - } - return acc; - }, - { totalEvents: 0, totalAlerts: 0 } - ); - }, [searchTerm, features, caseData]); + // Count attack discoveries + const attackDiscoveriesCount = useMemo(() => { + return caseData.comments.filter( + (comment) => + comment.type === AttachmentType.externalReference && + (comment as any).externalReferenceAttachmentTypeId === ATTACK_DISCOVERY_ATTACHMENT_TYPE + ).length; + }, [caseData.comments]); - const totalAttachments = - stats.totalAlerts + - stats.totalEvents + - Number(fileStatsData?.total) + - (canShowObservableTabs && isObservablesFeatureEnabled ? observables.length : 0); + const totalAttachments = useMemo(() => { + const alertsCount = features.alerts.enabled ? (caseData.totalAlerts ?? 0) : 0; + const eventsCount = features.events.enabled ? (caseData.totalEvents ?? 0) : 0; + const filesCount = fileStatsData?.total ?? 0; + const observablesCount = canShowObservableTabs && isObservablesFeatureEnabled ? observables.length : 0; + const attackDiscoveriesCountValue = attackDiscoveriesCount ?? 0; + + const total = alertsCount + eventsCount + filesCount + observablesCount + attackDiscoveriesCountValue; + return isNaN(total) ? 0 : total; + }, [ + features.alerts.enabled, + features.events.enabled, + caseData.totalAlerts, + caseData.totalEvents, + fileStatsData?.total, + canShowObservableTabs, + isObservablesFeatureEnabled, + observables.length, + attackDiscoveriesCount, + ]); const tabsConfig = useMemo( () => [ ...(features.alerts.enabled ? [ - { - id: CASE_VIEW_PAGE_TABS.ALERTS, - name: ALERTS_TAB, - badge: ( - - ), - }, - ] + { + id: CASE_VIEW_PAGE_TABS.ALERTS, + name: ALERTS_TAB, + badge: ( + + ), + }, + ] : []), ...(features.events.enabled ? [ - { - id: CASE_VIEW_PAGE_TABS.EVENTS, - name: EVENTS_TAB, - badge: ( - - ), - }, - ] + { + id: CASE_VIEW_PAGE_TABS.EVENTS, + name: EVENTS_TAB, + badge: ( + + ), + }, + ] : []), { id: CASE_VIEW_PAGE_TABS.FILES, @@ -286,26 +290,42 @@ export const useCaseAttachmentTabs = ({ }, ...(canShowObservableTabs && isObservablesFeatureEnabled ? [ - { - id: CASE_VIEW_PAGE_TABS.OBSERVABLES, - name: OBSERVABLES_TAB, - badge: ( - - ), - }, - ] + { + id: CASE_VIEW_PAGE_TABS.OBSERVABLES, + name: OBSERVABLES_TAB, + badge: ( + + ), + }, + ] : []), + { + id: CASE_VIEW_PAGE_TABS.ATTACK_DISCOVERIES, + name: ATTACK_DISCOVERIES_TAB, + badge: ( + + {attackDiscoveriesCount > 0 ? attackDiscoveriesCount : 0} + + ), + }, ], [ activeTab, + attackDiscoveriesCount, canShowObservableTabs, - stats.totalAlerts, - stats.totalEvents, + caseData.totalAlerts, + caseData.totalEvents, euiTheme, features.alerts.enabled, features.alerts.isExperimental, diff --git a/x-pack/platform/plugins/shared/cases/server/services/attack_discovery_integration/index.ts b/x-pack/platform/plugins/shared/cases/server/services/attack_discovery_integration/index.ts new file mode 100644 index 0000000000000..ea503c8cbd05e --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/attack_discovery_integration/index.ts @@ -0,0 +1,220 @@ +/* + * 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 { AttachmentService } from '../attachments'; +import type { CasesService } from '../cases'; + +/** + * Attack discovery alert information for attaching to a case + */ +export interface AttackDiscoveryAlertInfo { + alertId: string; + index: string; + title?: string; + timestamp?: string; + generationUuid?: string; +} + +/** + * Result of triggering attack discovery + */ +export interface AttackDiscoveryTriggerResult { + executionUuid: string; + success: boolean; + attackDiscoveryAlerts?: AttackDiscoveryAlertInfo[]; + error?: string; +} + +/** + * Function type for triggering attack discovery + */ +export type TriggerAttackDiscoveryFn = (params: { + alertIds: string[]; + caseId: string; + alertsIndexPattern: string; + request: import('@kbn/core/server').KibanaRequest; +}) => Promise; + +/** + * Function to attach attack discoveries to a case as external reference attachments + */ +export type AttachAttackDiscoveriesFn = (params: { + caseId: string; + attackDiscoveries: Array<{ + attackDiscoveryAlertId: string; + index: string; + generationUuid: string; + title: string; + timestamp: string; + }>; + owner: string; +}) => Promise; + +export interface AttackDiscoveryIntegrationService { + /** + * Triggers attack discovery for a case when alerts are attached. + * This is called automatically when alerts are added to a case. + * + * @param caseId - The ID of the case + * @param attachmentService - The attachment service to use for operations + * @param caseService - The case service to get case details + * @param attachAlerts - Function to attach alerts to the case + * @returns Promise that resolves when attack discovery is triggered (or skipped) + */ + triggerAttackDiscoveryForCase: ( + caseId: string, + attachmentService: AttachmentService, + caseService: CasesService, + attachAttackDiscoveries: AttachAttackDiscoveriesFn + ) => Promise; +} + +/** + * Creates a no-op attack discovery integration service. + * This is used when attack discovery is not available or disabled. + */ +export const createNoOpAttackDiscoveryIntegrationService = (): AttackDiscoveryIntegrationService => { + return { + triggerAttackDiscoveryForCase: async ( + _caseId: string, + _attachmentService: AttachmentService, + _caseService: CasesService, + _attachAttackDiscoveries: AttachAttackDiscoveriesFn + ) => { + // No-op: attack discovery integration is not available + }, + }; +}; + +/** + * Creates an attack discovery integration service that triggers attack discovery + * when alerts are attached to cases. + * + * @param logger - Logger instance + * @param triggerAttackDiscovery - Function to trigger attack discovery (optional) + * @param alertsIndexPattern - Default alerts index pattern (optional) + * @param getRequest - Function to get the current request (optional) + * @returns Attack discovery integration service + */ +export const createAttackDiscoveryIntegrationService = ({ + logger, + triggerAttackDiscovery, + alertsIndexPattern, + getRequest, +}: { + logger: Logger; + triggerAttackDiscovery?: TriggerAttackDiscoveryFn; + alertsIndexPattern?: string; + getRequest?: () => import('@kbn/core/server').KibanaRequest | undefined; +}): AttackDiscoveryIntegrationService => { + return { + triggerAttackDiscoveryForCase: async ( + caseId: string, + attachmentService: AttachmentService, + caseService: CasesService, + attachAttackDiscoveries: AttachAttackDiscoveriesFn + ) => { + try { + // If attack discovery trigger function is not available, skip + if (!triggerAttackDiscovery) { + logger.debug( + `Attack discovery integration not available for case ${caseId}, skipping` + ); + return; + } + + // Get all alert IDs from the case + const alertIds = await attachmentService.getter.getAllAlertIds({ caseId }); + + if (alertIds.size === 0) { + logger.debug(`No alerts in case ${caseId}, skipping attack discovery`); + return; + } + + // Don't trigger attack discovery if there are less than 2 alerts + if (alertIds.size < 2) { + logger.debug( + `Case ${caseId} has less than 2 alerts (${alertIds.size}), skipping attack discovery` + ); + return; + } + + const alertIdsArray = Array.from(alertIds); + + // Use provided alerts index pattern or default + const effectiveAlertsIndexPattern = + // alertsIndexPattern || + '.alerts-security.alerts-default'; + + // Get the request - try to get it from the getter, or create a system request as fallback + const request = + getRequest?.() || + ({ + headers: {}, + url: { path: '/internal' }, + } as unknown as import('@kbn/core/server').KibanaRequest); + + // Log parameters being passed to attack discovery trigger + logger.debug( + `[Attack Discovery Integration] Triggering attack discovery for case ${caseId} with parameters: ` + + `alertIds=[${alertIdsArray.slice(0, 5).join(', ')}${alertIdsArray.length > 5 ? `, ... (${alertIdsArray.length} total)` : ''}], ` + + `alertsIndexPattern=${effectiveAlertsIndexPattern}, ` + + `alertCount=${alertIdsArray.length}, ` + + `hasRequest=${!!request}` + ); + + // Trigger attack discovery with case-scoped alert filter + const result = await triggerAttackDiscovery({ + caseId, + alertIds: alertIdsArray, + alertsIndexPattern: effectiveAlertsIndexPattern, + request, + }); + + if (!result.success) { + logger.error( + `Attack discovery generation failed for case ${caseId}: ${result.error || 'Unknown error'}` + ); + return; + } + + // Get case to determine owner + const theCase = await caseService.getCase({ id: caseId }); + + // Attach attack discoveries to the case as external reference attachments + if (result.attackDiscoveryAlerts && result.attackDiscoveryAlerts.length > 0) { + // Transform attack discovery alerts to include metadata + const attackDiscoveries = result.attackDiscoveryAlerts.map((alert) => ({ + attackDiscoveryAlertId: alert.alertId, + index: alert.index, + generationUuid: alert.generationUuid || result.executionUuid, + title: alert.title || `Attack Discovery ${alert.alertId}`, + timestamp: alert.timestamp || new Date().toISOString(), + })); + + await attachAttackDiscoveries({ + caseId, + attackDiscoveries, + owner: theCase.attributes.owner, + }); + } + + logger.info( + `Successfully triggered attack discovery for case ${caseId}, execution UUID: ${result.executionUuid}, attached ${result.attackDiscoveryAlerts?.length || 0} attack discovery alerts` + ); + } catch (error) { + // Log error but don't fail the alert attachment operation + logger.error( + `Failed to trigger attack discovery for case ${caseId}: ${error instanceof Error ? error.message : String(error)}` + ); + } + }, + }; +}; + + diff --git a/x-pack/solutions/security/plugins/elastic_assistant/kibana.jsonc b/x-pack/solutions/security/plugins/elastic_assistant/kibana.jsonc index e250948a6b38f..852ceb3693845 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/kibana.jsonc +++ b/x-pack/solutions/security/plugins/elastic_assistant/kibana.jsonc @@ -36,7 +36,9 @@ "discover" ], "optionalPlugins": [ - "cloud" + "cases", + "cloud", + "workflowsExtensions" ], "requiredBundles": [ "kibanaReact", diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts index 829e27df73f14..9b03ac41abf9d 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.test.ts @@ -7,7 +7,7 @@ import { PromptTemplate } from '@langchain/core/prompts'; import type { ActionsClientLlm } from '@kbn/langchain/server'; -import { loadEvaluator } from 'langchain/evaluation'; +import { loadEvaluator } from '@langchain/classic/evaluation'; import { type GetCustomEvaluatorOptions, getCustomEvaluator } from '.'; import { getDefaultPromptTemplate } from './get_default_prompt_template'; @@ -18,8 +18,8 @@ import { runWithReplacements } from '../../__mocks__/mock_runs'; const mockLlm = jest.fn() as unknown as ActionsClientLlm; -jest.mock('langchain/evaluation', () => ({ - ...jest.requireActual('langchain/evaluation'), +jest.mock('@langchain/classic/evaluation', () => ({ + ...jest.requireActual('@langchain/classic/evaluation'), loadEvaluator: jest.fn().mockResolvedValue({ evaluateStrings: jest.fn().mockResolvedValue({ key: 'correctness', diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts index 9802a0af5b081..4b93fb4d6fd22 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/evaluation/helpers/get_custom_evaluator/index.ts @@ -9,8 +9,8 @@ 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 type { CriteriaLike } from 'langchain/evaluation'; -import { loadEvaluator } from 'langchain/evaluation'; +import type { CriteriaLike } from '@langchain/classic/evaluation'; +import { loadEvaluator } from '@langchain/classic/evaluation'; import { getExampleAttackDiscoveriesWithReplacements } from './get_example_attack_discoveries_with_replacements'; import { getRunAttackDiscoveriesWithReplacements } from './get_run_attack_discoveries_with_replacements'; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts index 181464401a37b..cc3f249697b8e 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/index.ts @@ -40,6 +40,8 @@ export interface GetDefaultAttackDiscoveryGraphParams { replacements?: Replacements; size: number; start?: string; + /** If true, skips the workflow status filter (open/acknowledged) allowing alerts of any status */ + allowAllWorkflowStatuses?: boolean; } export type DefaultAttackDiscoveryGraph = ReturnType; @@ -64,6 +66,7 @@ export const getDefaultAttackDiscoveryGraph = ({ replacements, size, start, + allowAllWorkflowStatuses, }: GetDefaultAttackDiscoveryGraphParams) => { try { const graphState = getDefaultGraphAnnotation({ end, filter, prompts, start }); @@ -77,8 +80,16 @@ export const getDefaultAttackDiscoveryGraph = ({ onNewReplacements, replacements, size, + allowAllWorkflowStatuses, }); + logger?.error('index pattern: ' + JSON.stringify(alertsIndexPattern)); + + logger?.error('anonymizationFields: ' + JSON.stringify(anonymizationFields)); + + logger?.error('filter: ' + JSON.stringify(filter)); + + const generationSchema = getAttackDiscoveriesGenerationSchema(prompts); const generateNode = getGenerateNode({ diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts index 95dc6800eaa53..195673512dad9 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/anonymized_alerts_retriever/index.ts @@ -29,6 +29,7 @@ export class AnonymizedAlertsRetriever extends BaseRetriever { #replacements?: Replacements; #size?: number; #start?: DateMath | null; + #allowAllWorkflowStatuses?: boolean; constructor({ alertsIndexPattern, @@ -41,6 +42,7 @@ export class AnonymizedAlertsRetriever extends BaseRetriever { replacements, size, start, + allowAllWorkflowStatuses, }: { alertsIndexPattern?: string; anonymizationFields?: AnonymizationFieldResponse[]; @@ -52,6 +54,8 @@ export class AnonymizedAlertsRetriever extends BaseRetriever { replacements?: Replacements; size?: number; start?: DateMath | null; + /** If true, skips the workflow status filter (open/acknowledged) allowing alerts of any status */ + allowAllWorkflowStatuses?: boolean; }) { super(fields); @@ -64,6 +68,7 @@ export class AnonymizedAlertsRetriever extends BaseRetriever { this.#replacements = replacements; this.#size = size; this.#start = start; + this.#allowAllWorkflowStatuses = allowAllWorkflowStatuses; } async _getRelevantDocuments( @@ -80,6 +85,7 @@ export class AnonymizedAlertsRetriever extends BaseRetriever { replacements: this.#replacements, size: this.#size, start: this.#start, + allowAllWorkflowStatuses: this.#allowAllWorkflowStatuses, }); return anonymizedAlerts.map((alert) => ({ diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts index a3efd351e6b5e..ad591c5a42069 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.test.ts @@ -5,12 +5,13 @@ * 2.0. */ +import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { getOpenAndAcknowledgedAlertsQuery } from '@kbn/elastic-assistant-common'; const MIN_SIZE = 10; -import { getAnonymizedAlerts } from '.'; +import { getAnonymizedAlerts, getAnonymizedAlertsWithDeduplication } from '.'; import { mockOpenAndAcknowledgedAlertsQueryResults } from '../../../../mock/mock_open_and_acknowledged_alerts_query_results'; jest.mock('@kbn/elastic-assistant-common', () => { @@ -213,3 +214,276 @@ describe('getAnonymizedAlerts', () => { expect(onNewReplacements).toHaveBeenCalledTimes(20); // 20 alerts in mockOpenAndAcknowledgedAlertsQueryResults }); }); + +describe('getAnonymizedAlertsWithDeduplication', () => { + const alertsIndexPattern = '.alerts-security.alerts-default'; + const mockAnonymizationFields = [ + { + id: '9f95b649-f20e-4edf-bd76-1d21ab6f8e2e', + timestamp: '2024-05-06T22:16:48.489Z', + field: '_id', + allowed: true, + anonymized: false, + createdAt: '2024-05-06T22:16:48.489Z', + namespace: 'default', + }, + { + id: '22f23471-4f6a-4cec-9b2a-cf270ffb53d5', + timestamp: '2024-05-06T22:16:48.489Z', + field: 'host.name', + allowed: true, + anonymized: true, + createdAt: '2024-05-06T22:16:48.489Z', + namespace: 'default', + }, + ]; + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + const mockReplacements = { + replacement1: 'SRVMAC08', + replacement2: 'SRVWIN01', + replacement3: 'SRVWIN02', + }; + const size = 10; + + const mockDeduplicationAggResponse = { + took: 10, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { total: { value: 0 }, hits: [] }, + aggregations: { + total_alerts: { value: 20 }, + alert_groups: { + buckets: [ + { + key: 'hash1|Malware Detection Alert|SRVMAC08', + doc_count: 8, + max_risk_score: { value: 99 }, + top_alert: { + hits: { + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', + fields: { + _id: ['b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560'], + 'host.name': ['SRVMAC08'], + }, + }, + ], + }, + }, + alert_ids: { + buckets: [ + { + key: 'b6e883c29b32571aaa667fa13e65bbb4f95172a2b84bdfb85d6f16c72b2d2560', + doc_count: 1, + }, + { + key: '0215a6c5cc9499dd0290cd69a4947efb87d3ddd8b6385a766d122c2475be7367', + doc_count: 1, + }, + ], + }, + field_file_hash_sha256: { buckets: [{ key: 'hash1', doc_count: 8 }] }, + field_kibana_alert_rule_name: { + buckets: [{ key: 'Malware Detection Alert', doc_count: 8 }], + }, + field_host_name: { buckets: [{ key: 'SRVMAC08', doc_count: 8 }] }, + }, + { + key: 'hash2|Malware Detection Alert|SRVWIN02', + doc_count: 10, + max_risk_score: { value: 99 }, + top_alert: { + hits: { + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014', + fields: { + _id: ['f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014'], + 'host.name': ['SRVWIN02'], + }, + }, + ], + }, + }, + alert_ids: { + buckets: [ + { + key: 'f465ca9fbfc8bc3b1871e965c9e111cac76ff3f4076fed6bc9da88d49fb43014', + doc_count: 1, + }, + { + key: 'aa283e6a13be77b533eceffb09e48254c8f91feeccc39f7eed80fd3881d053f4', + doc_count: 1, + }, + ], + }, + field_file_hash_sha256: { buckets: [{ key: 'hash2', doc_count: 10 }] }, + field_kibana_alert_rule_name: { + buckets: [{ key: 'Malware Detection Alert', doc_count: 10 }], + }, + field_host_name: { buckets: [{ key: 'SRVWIN02', doc_count: 10 }] }, + }, + { + key: 'hash3|Malware Detection Alert|SRVWIN01', + doc_count: 2, + max_risk_score: { value: 99 }, + top_alert: { + hits: { + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b', + fields: { + _id: ['cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b'], + 'host.name': ['SRVWIN01'], + }, + }, + ], + }, + }, + alert_ids: { + buckets: [ + { + key: 'cdf3b5510bb5ed622e8cefd1ce6bedc52bdd99a4c1ead537af0603469e713c8b', + doc_count: 1, + }, + { + key: '6abe81eb6350fb08031761be029e7ab19f7e577a7c17a9c5ea1ed010ba1620e3', + doc_count: 1, + }, + ], + }, + field_file_hash_sha256: { buckets: [{ key: 'hash3', doc_count: 2 }] }, + field_kibana_alert_rule_name: { + buckets: [{ key: 'Malware Detection Alert', doc_count: 2 }], + }, + field_host_name: { buckets: [{ key: 'SRVWIN01', doc_count: 2 }] }, + }, + ], + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (mockEsClient.search as unknown as jest.Mock).mockResolvedValue( + mockDeduplicationAggResponse as unknown as SearchResponse + ); + }); + + it('returns empty result when alertsIndexPattern is not provided', async () => { + const result = await getAnonymizedAlertsWithDeduplication({ + esClient: mockEsClient, + size, + }); + + expect(result.anonymizedAlerts).toEqual([]); + expect(result.totalOriginalAlerts).toBe(0); + expect(result.deduplicationStats.duplicatesRemoved).toBe(0); + }); + + it('returns empty result when size is not provided', async () => { + const result = await getAnonymizedAlertsWithDeduplication({ + alertsIndexPattern, + esClient: mockEsClient, + }); + + expect(result.anonymizedAlerts).toEqual([]); + expect(result.totalOriginalAlerts).toBe(0); + }); + + it('returns empty result when size is out of range', async () => { + const outOfRange = MIN_SIZE - 1; + + const result = await getAnonymizedAlertsWithDeduplication({ + alertsIndexPattern, + esClient: mockEsClient, + size: outOfRange, + }); + + expect(result.anonymizedAlerts).toEqual([]); + expect(result.totalOriginalAlerts).toBe(0); + }); + + it('returns deduplicated alerts with statistics', async () => { + const result = await getAnonymizedAlertsWithDeduplication({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + esClient: mockEsClient, + replacements: mockReplacements, + size, + }); + + // Should return 3 representative alerts instead of 20 + expect(result.anonymizedAlerts).toHaveLength(3); + expect(result.totalOriginalAlerts).toBe(20); + expect(result.deduplicationStats.duplicatesRemoved).toBe(17); // 20 - 3 + expect(result.deduplicationStats.reductionPercentage).toBe(85); // 17/20 * 100 + }); + + it('returns alert ID correlation map', async () => { + const result = await getAnonymizedAlertsWithDeduplication({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + esClient: mockEsClient, + replacements: mockReplacements, + size, + }); + + expect(result.alertIdCorrelationMap).toBeInstanceOf(Map); + expect(result.alertIdCorrelationMap.size).toBe(3); + }); + + it('transforms deduplicated alerts correctly', async () => { + const result = await getAnonymizedAlertsWithDeduplication({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + esClient: mockEsClient, + replacements: mockReplacements, + size, + }); + + // Check that anonymization worked + expect(result.anonymizedAlerts[0]).toContain('_id,'); + expect(result.anonymizedAlerts[0]).toContain('host.name,'); + }); + + it('calls onNewReplacements for each deduplicated alert', async () => { + const onNewReplacements = jest.fn(); + + await getAnonymizedAlertsWithDeduplication({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + esClient: mockEsClient, + onNewReplacements, + replacements: mockReplacements, + size, + }); + + // Should be called 3 times (once per deduplicated group) + expect(onNewReplacements).toHaveBeenCalledTimes(3); + }); + + it('uses custom deduplication config when provided', async () => { + const customConfig = { + correlationFields: ['kibana.alert.rule.name', 'host.name'] as const, + maxGroups: 100, + maxAlertsPerGroup: 50, + }; + + await getAnonymizedAlertsWithDeduplication({ + alertsIndexPattern, + anonymizationFields: mockAnonymizationFields, + deduplicationConfig: customConfig, + esClient: mockEsClient, + replacements: mockReplacements, + size, + }); + + // Verify ES search was called (deduplication module was invoked) + expect(mockEsClient.search).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts index d7076c2cc2737..a2298e2422040 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/helpers/get_anonymized_alerts/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { DateMath, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { DateMath, SearchHit, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { ElasticsearchClient } from '@kbn/core/server'; import type { Replacements } from '@kbn/elastic-assistant-common'; import { @@ -18,17 +18,10 @@ import { import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas'; -export const getAnonymizedAlerts = async ({ - alertsIndexPattern, - anonymizationFields, - end, - esClient, - filter, - onNewReplacements, - replacements, - size, - start, -}: { +import type { DeduplicationConfig, DeduplicationResult } from '../deduplicate_alerts'; +import { getDeduplicatedAlertHits, DEFAULT_DEDUPLICATION_CONFIG } from '../deduplicate_alerts'; + +export interface GetAnonymizedAlertsParams { alertsIndexPattern?: string; anonymizationFields?: AnonymizationFieldResponse[]; end?: DateMath | null; @@ -38,32 +31,45 @@ export const getAnonymizedAlerts = async ({ replacements?: Replacements; size?: number; start?: DateMath | null; -}): Promise => { - if (alertsIndexPattern == null || size == null || sizeIsOutOfRange(size)) { - return []; - } + /** If true, skips the workflow status filter (open/acknowledged) allowing alerts of any status */ + allowAllWorkflowStatuses?: boolean; +} - const query = getOpenAndAcknowledgedAlertsQuery({ - alertsIndexPattern, - anonymizationFields: anonymizationFields ?? [], - end, - filter, - size, - start, - }); +export interface GetAnonymizedAlertsWithDeduplicationParams extends GetAnonymizedAlertsParams { + /** Configuration for alert deduplication. If not provided, default config is used. */ + deduplicationConfig?: DeduplicationConfig; +} - const result = await esClient.search(query); +export interface GetAnonymizedAlertsWithDeduplicationResult { + /** Anonymized alert strings for LLM consumption */ + anonymizedAlerts: string[]; + /** Statistics about the deduplication process */ + deduplicationStats: DeduplicationResult['stats']; + /** Total number of alerts before deduplication */ + totalOriginalAlerts: number; + /** Map from representative alert ID to all correlated alert IDs */ + alertIdCorrelationMap: Map; +} +/** + * Transforms search hits to anonymized alert strings. + * This is the core transformation logic used by both regular and deduplicated flows. + */ +const transformHitsToAnonymizedAlerts = ( + hits: SearchHit[], + anonymizationFields: AnonymizationFieldResponse[] | undefined, + replacements: Replacements | undefined, + onNewReplacements?: (replacements: Replacements) => void +): string[] => { // Accumulate replacements locally so we can, for example use the same // replacement for a hostname when we see it in multiple alerts: let localReplacements = { ...(replacements ?? {}) }; const localOnNewReplacements = (newReplacements: Replacements) => { localReplacements = { ...localReplacements, ...newReplacements }; - onNewReplacements?.(localReplacements); // invoke the callback with the latest replacements }; - return result.hits?.hits?.map((x) => + return hits.map((x) => transformRawData({ anonymizationFields, currentReplacements: localReplacements, // <-- the latest local replacements @@ -73,3 +79,115 @@ export const getAnonymizedAlerts = async ({ }) ); }; + +/** + * Gets anonymized alerts without deduplication. + * This is the original function preserved for backward compatibility. + */ +export const getAnonymizedAlerts = async ({ + alertsIndexPattern, + anonymizationFields, + end, + esClient, + filter, + onNewReplacements, + replacements, + size, + start, + allowAllWorkflowStatuses, +}: GetAnonymizedAlertsParams): Promise => { + if (alertsIndexPattern == null || size == null || sizeIsOutOfRange(size)) { + return []; + } + + const query = getOpenAndAcknowledgedAlertsQuery({ + alertsIndexPattern, + anonymizationFields: anonymizationFields ?? [], + end, + filter, + size, + start, + allowAllWorkflowStatuses, + }); + + const result = await esClient.search(query); + + return transformHitsToAnonymizedAlerts( + result.hits?.hits ?? [], + anonymizationFields, + replacements, + onNewReplacements + ); +}; + +/** + * Gets anonymized alerts with deduplication enabled. + * Groups correlated alerts and returns representative alerts for each group, + * reducing token consumption while preserving all alert references. + * + * @param params - Parameters including deduplication configuration + * @returns Anonymized alerts with deduplication statistics and correlation map + */ +export const getAnonymizedAlertsWithDeduplication = async ({ + alertsIndexPattern, + anonymizationFields, + deduplicationConfig = DEFAULT_DEDUPLICATION_CONFIG, + end, + esClient, + filter, + onNewReplacements, + replacements, + size, + start, +}: GetAnonymizedAlertsWithDeduplicationParams): Promise => { + if (alertsIndexPattern == null || size == null || sizeIsOutOfRange(size)) { + return { + anonymizedAlerts: [], + deduplicationStats: { + duplicatesRemoved: 0, + reductionPercentage: 0, + avgDuplicatesPerGroup: 0, + }, + totalOriginalAlerts: 0, + alertIdCorrelationMap: new Map(), + }; + } + + // Get deduplicated alert hits using ES aggregations + const { hits, deduplicationStats, totalOriginalAlerts } = await getDeduplicatedAlertHits({ + alertsIndexPattern, + anonymizationFields, + config: deduplicationConfig, + end, + esClient, + filter, + size, + start, + }); + + // Build correlation map from representative alerts + const alertIdCorrelationMap = new Map(); + // Note: The actual correlation mapping is done in the deduplication module, + // but for the simplified flow we just map each alert to itself + // Full correlation is available via getAlertIdCorrelationMap if needed + hits.forEach((hit) => { + if (hit._id) { + alertIdCorrelationMap.set(hit._id, [hit._id]); + } + }); + + // Transform hits to anonymized alerts + const anonymizedAlerts = transformHitsToAnonymizedAlerts( + hits, + anonymizationFields, + replacements, + onNewReplacements + ); + + return { + anonymizedAlerts, + deduplicationStats, + totalOriginalAlerts, + alertIdCorrelationMap, + }; +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts index e96766f966ec6..24584dddd1cb2 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/graphs/default_attack_discovery_graph/nodes/retriever/index.ts @@ -20,6 +20,7 @@ export const getRetrieveAnonymizedAlertsNode = ({ onNewReplacements, replacements, size, + allowAllWorkflowStatuses, }: { alertsIndexPattern?: string; anonymizationFields?: AnonymizationFieldResponse[]; @@ -28,6 +29,8 @@ export const getRetrieveAnonymizedAlertsNode = ({ onNewReplacements?: (replacements: Replacements) => void; replacements?: Replacements; size?: number; + /** If true, skips the workflow status filter (open/acknowledged) allowing alerts of any status */ + allowAllWorkflowStatuses?: boolean; }): ((state: AttackDiscoveryGraphState) => Promise) => { let localReplacements = { ...(replacements ?? {}) }; const localOnNewReplacements = (newReplacements: Replacements) => { @@ -53,6 +56,7 @@ export const getRetrieveAnonymizedAlertsNode = ({ replacements, size, start, + allowAllWorkflowStatuses, }); const documents = await retriever diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/plugin.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/plugin.ts index e492adf3ea86e..5fd6b95de4b7d 100755 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/plugin.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/plugin.ts @@ -48,6 +48,12 @@ import type { ConfigSchema } from './config_schema'; import { attackDiscoveryAlertFieldMap } from './lib/attack_discovery/schedules/fields'; import { ATTACK_DISCOVERY_ALERTS_CONTEXT } from './lib/attack_discovery/schedules/constants'; import { getAttackDiscoveryDataGeneratorRuleType } from './lib/attack_discovery/data_generator_rule/definition'; +import { alertGroupingSavedObjectTypes } from './lib/alert_grouping/persistence'; +import { getAlertGroupingTask, type AlertGroupingTask } from './lib/alert_grouping'; +import { + getDeduplicateAlertsStepDefinition, + getVectorizeAlertsStepDefinition, +} from './lib/alert_grouping/workflow_steps'; interface FeatureFlagDefinition { featureFlagName: string; @@ -62,15 +68,16 @@ interface FeatureFlagDefinition { export class ElasticAssistantPlugin implements - Plugin< - ElasticAssistantPluginSetup, - ElasticAssistantPluginStart, - ElasticAssistantPluginSetupDependencies, - ElasticAssistantPluginStartDependencies - > -{ + Plugin< + ElasticAssistantPluginSetup, + ElasticAssistantPluginStart, + ElasticAssistantPluginSetupDependencies, + ElasticAssistantPluginStartDependencies + > { private readonly logger: Logger; private assistantService: AIAssistantService | undefined; + private adhocAttackDiscoveryDataClient: IRuleDataClient | undefined; + private alertGroupingTask: AlertGroupingTask | undefined; private pluginStop$: Subject; private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; private readonly config: ConfigSchema; @@ -111,18 +118,34 @@ export class ElasticAssistantPlugin pluginStop$: this.pluginStop$, }); - const adhocAttackDiscoveryDataClient = this.initializeAttackDiscovery({ + this.adhocAttackDiscoveryDataClient = this.initializeAttackDiscovery({ core, plugins, }); + // Register alert grouping task with Task Manager (must be before request context factory) + this.alertGroupingTask = getAlertGroupingTask(this.logger); + this.alertGroupingTask.setup({ + taskManager: plugins.taskManager, + logger: this.logger, + }); + + // Register Attack Discovery attachment type with Cases plugin (server-side) + if (plugins.cases) { + const { ATTACK_DISCOVERY_ATTACHMENT_TYPE } = require('@kbn/cases-plugin/common'); + plugins.cases.attachmentFramework.registerExternalReference({ + id: ATTACK_DISCOVERY_ATTACHMENT_TYPE, + }); + } + const requestContextFactory = new RequestContextFactory({ logger: this.logger, core, plugins, kibanaVersion: this.kibanaVersion, assistantService: this.assistantService, - adhocAttackDiscoveryDataClient, + adhocAttackDiscoveryDataClient: this.adhocAttackDiscoveryDataClient, + alertGroupingTask: this.alertGroupingTask, }); const router = core.http.createRouter(); @@ -151,6 +174,18 @@ export class ElasticAssistantPlugin registerRoutes(router, this.logger, this.config, enableDataGeneratorRoutes); + // Register alert grouping saved object types + alertGroupingSavedObjectTypes.forEach((soType) => core.savedObjects.registerType(soType)); + + // Register workflow steps for alert deduplication (Elastic Workflows integration) + if (plugins.workflowsExtensions) { + plugins.workflowsExtensions.registerStepDefinition( + getDeduplicateAlertsStepDefinition(core) + ); + plugins.workflowsExtensions.registerStepDefinition(getVectorizeAlertsStepDefinition()); + this.logger.info('Registered alert deduplication workflow steps'); + } + // The featureFlags service is not available in the core setup, so we need // to wait for the start services to be available to read the feature flags. // This can take a while, but the plugin setup phase cannot run for a long time. @@ -196,11 +231,25 @@ export class ElasticAssistantPlugin if (res?.total) this.logger.info(`Removed ${res.total} legacy quick prompts from AI Assistant`); }) - .catch(() => {}); + .catch(() => { }); + + // Start alert grouping task with dependencies + if (this.alertGroupingTask && plugins.taskManager) { + this.alertGroupingTask.start({ + taskManager: plugins.taskManager, + getStartServices: async () => [core, plugins, {}] as any, + assistantService: this.assistantService!, + cases: plugins.cases, + }); + } return { actions: plugins.actions, inference: plugins.inference, + // Expose assistantService for use by other plugins + assistantService: this.assistantService, + // Expose adhocAttackDiscoveryDataClient for use by other plugins + adhocAttackDiscoveryDataClient: this.adhocAttackDiscoveryDataClient, getRegisteredFeatures: (pluginName: string) => { return appContextService.getRegisteredFeatures(pluginName); }, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/helpers/generate_and_update_discoveries.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/helpers/generate_and_update_discoveries.ts index 8ca75caaefa4c..82c6286edf12c 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/helpers/generate_and_update_discoveries.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/helpers/generate_and_update_discoveries.ts @@ -85,6 +85,17 @@ export const generateAndUpdateAttackDiscoveries = async ({ }); latestReplacements = generatedReplacements; + const generatedDiscoveriesCount = attackDiscoveries?.length ?? 0; + logger.error( + `Attack discovery graph generated ${generatedDiscoveriesCount} discovery/discoveries from ${anonymizedAlerts.length} anonymized alert(s) for execution UUID ${executionUuid}` + ); + + if (generatedDiscoveriesCount === 0) { + logger.error( + `No attack discoveries were generated by the LLM graph for execution UUID ${executionUuid}. This could indicate: 1) The alerts do not contain attack patterns, 2) The LLM did not detect any patterns, or 3) An issue with the graph execution.` + ); + } + reportAttackDiscoverySuccessTelemetry({ anonymizedAlerts, apiConfig, @@ -135,6 +146,21 @@ export const generateAndUpdateAttackDiscoveries = async ({ spaceId: dataClient.spaceId, }); + const dedupedCount = dedupedDiscoveries.length; + if (generatedDiscoveriesCount > 0 && dedupedCount === 0) { + logger.error( + `All ${generatedDiscoveriesCount} attack discovery/discoveries were filtered out during deduplication for execution UUID ${executionUuid}. This means all generated discoveries already exist in the index.` + ); + } else if (generatedDiscoveriesCount > dedupedCount) { + logger.error( + `Deduplication filtered out ${generatedDiscoveriesCount - dedupedCount} of ${generatedDiscoveriesCount} attack discovery/discoveries for execution UUID ${executionUuid}, ${dedupedCount} new discovery/discoveries remain` + ); + } else if (generatedDiscoveriesCount > 0) { + logger.error( + `No deduplication needed: all ${generatedDiscoveriesCount} attack discovery/discoveries are new for execution UUID ${executionUuid}` + ); + } + const createAttackDiscoveryAlertsParams: CreateAttackDiscoveryAlertsParams = { alertsContextCount, anonymizedAlerts, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/helpers/generate_discoveries.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/helpers/generate_discoveries.ts index 81c91f64aa9c0..64be5a0b96bf7 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/helpers/generate_discoveries.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/helpers/generate_discoveries.ts @@ -17,9 +17,14 @@ 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 interface GenerateAttackDiscoveriesConfig extends AttackDiscoveryGenerationConfig { + /** If true, skips the workflow status filter (open/acknowledged) allowing alerts of any status */ + allowAllWorkflowStatuses?: boolean; +} + export interface GenerateAttackDiscoveriesParams { actionsClient: PublicMethodsOf; - config: AttackDiscoveryGenerationConfig; + config: GenerateAttackDiscoveriesConfig; esClient: ElasticsearchClient; logger: Logger; savedObjectsClient: SavedObjectsClientContract; @@ -44,6 +49,7 @@ export const generateAttackDiscoveries = async ({ replacements, size, start, + allowAllWorkflowStatuses, } = config; // callback to accumulate the latest replacements: @@ -69,6 +75,7 @@ export const generateAttackDiscoveries = async ({ savedObjectsClient, size, start, + allowAllWorkflowStatuses, }); return { anonymizedAlerts, attackDiscoveries, replacements: latestReplacements }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/helpers/invoke_attack_discovery_graph/index.tsx b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/helpers/invoke_attack_discovery_graph/index.tsx index 51b06d88bc85d..ea666ea4d30f5 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/helpers/invoke_attack_discovery_graph/index.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/public/post/helpers/invoke_attack_discovery_graph/index.tsx @@ -43,6 +43,7 @@ export const invokeAttackDiscoveryGraph = async ({ savedObjectsClient, size, start, + allowAllWorkflowStatuses, }: { actionsClient: PublicMethodsOf; alertsIndexPattern: string; @@ -60,6 +61,8 @@ export const invokeAttackDiscoveryGraph = async ({ savedObjectsClient: SavedObjectsClientContract; start?: string; size: number; + /** If true, skips the workflow status filter (open/acknowledged) allowing alerts of any status */ + allowAllWorkflowStatuses?: boolean; }): Promise<{ anonymizedAlerts: Document[]; attackDiscoveries: AttackDiscovery[] | null; @@ -121,6 +124,7 @@ export const invokeAttackDiscoveryGraph = async ({ replacements: latestReplacements, size, start, + allowAllWorkflowStatuses, }); logger?.debug(() => 'invokeAttackDiscoveryGraph: invoking the Attack discovery graph'); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/register_routes.ts index 18b16820d1d25..9d7db8d7113c6 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/register_routes.ts @@ -59,6 +59,7 @@ import { suggestUsersRoute } from './users/suggest'; import { updateAnonymizationFieldsRoute } from './test_internal/update_anonymization_fields_route'; import { getMissingIndexPrivilegesInternalRoute } from './attack_discovery/privileges/get_missing_privileges'; import { createAttackDiscoveryAlertsRoute } from './test_internal/create_attack_discovery_alerts_route'; +import { registerAlertGroupingRoutes } from './alert_grouping'; export const registerRoutes = ( router: ElasticAssistantPluginRouter, @@ -164,4 +165,7 @@ export const registerRoutes = ( if (enableDataGeneratorRoutes) { createAttackDiscoveryAlertsRoute(router); } + + // Alert Grouping + registerAlertGroupingRoutes(router, logger); }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/request_context_factory.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/request_context_factory.ts index 1ff15e1a53860..d13549ca9c195 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/request_context_factory.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/request_context_factory.ts @@ -19,6 +19,7 @@ import type { } from '../types'; import type { AIAssistantService } from '../ai_assistant_service'; import { appContextService } from '../services/app_context'; +import type { AlertGroupingTask } from '../lib/alert_grouping'; let hasLoggedProfileUidError = false; @@ -38,17 +39,20 @@ interface ConstructorOptions { kibanaVersion: string; assistantService: AIAssistantService; adhocAttackDiscoveryDataClient: IRuleDataClient; + alertGroupingTask?: AlertGroupingTask; } export class RequestContextFactory implements IRequestContextFactory { private readonly logger: Logger; private readonly assistantService: AIAssistantService; private adhocAttackDiscoveryDataClient: IRuleDataClient; + private alertGroupingTask?: AlertGroupingTask; constructor(private readonly options: ConstructorOptions) { this.logger = options.logger; this.assistantService = options.assistantService; this.adhocAttackDiscoveryDataClient = options.adhocAttackDiscoveryDataClient; + this.alertGroupingTask = options.alertGroupingTask; } public async create( @@ -255,6 +259,10 @@ export class RequestContextFactory implements IRequestContextFactory { currentUser, }); }), + + getCases: () => startPlugins.cases, + + getAlertGroupingTask: () => this.alertGroupingTask, }; } } diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/types.ts index 1647379d52f9d..1b5611b22210c 100755 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/types.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/types.ts @@ -27,7 +27,7 @@ import type { LlmTasksPluginStart } from '@kbn/llm-tasks-plugin/server'; import { type MlPluginSetup } from '@kbn/ml-plugin/server'; import type { StructuredToolInterface } from '@langchain/core/tools'; import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server'; -import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; +import type { TaskManagerSetupContract, TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import type { PostAttackDiscoveryGenerateRequestBody, DefendInsightsPostRequestBody, @@ -49,6 +49,7 @@ import type { } from '@kbn/langchain/server'; import type { InferenceServerStart } from '@kbn/inference-plugin/server'; import type { IEventLogger, IEventLogService } from '@kbn/event-log-plugin/server'; +import type { CasesServerStart, CasesServerSetup } from '@kbn/cases-plugin/server'; import type { ProductDocBaseStartContract } from '@kbn/product-doc-base-plugin/server'; import type { AlertingServerSetup, @@ -57,9 +58,10 @@ import type { PublicFrameworkAlertsService, } from '@kbn/alerting-plugin/server'; import type { InferenceChatModel } from '@kbn/inference-langchain'; -import type { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server'; +import type { IRuleDataClient, RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server'; import type { CheckPrivileges, SecurityPluginStart } from '@kbn/security-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; +import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; import type { BaseCheckpointSaver } from '@langchain/langgraph-checkpoint'; import type { GetAIAssistantKnowledgeBaseDataClientParams, @@ -75,6 +77,7 @@ import { CallbackIds } from './services/app_context'; import type { AIAssistantDataClient } from './ai_assistant_data_clients'; import type { DefendInsightsDataClient } from './lib/defend_insights/persistence'; import type { AttackDiscoveryScheduleDataClient } from './lib/attack_discovery/schedules/data_client'; +import { AIAssistantService } from './ai_assistant_service'; export const PLUGIN_ID = 'elasticAssistant' as const; export { CallbackIds }; @@ -94,6 +97,16 @@ export interface ElasticAssistantPluginStart { * Inference plugin start contract. */ inference: InferenceServerStart; + /** + * Assistant service for creating data clients and managing AI Assistant functionality. + * @internal + */ + assistantService?: AIAssistantService; + /** + * Adhoc attack discovery data client for attack discovery operations. + * @internal + */ + adhocAttackDiscoveryDataClient?: IRuleDataClient; /** * Register features to be used by the elastic assistant. * @@ -135,22 +148,26 @@ export interface ElasticAssistantPluginStart { export interface ElasticAssistantPluginSetupDependencies { actions: ActionsPluginSetup; alerting: AlertingServerSetup; + cases?: CasesServerSetup; cloud?: CloudSetup; eventLog: IEventLogService; // for writing to the event log ml: MlPluginSetup; ruleRegistry: RuleRegistryPluginSetupContract; taskManager: TaskManagerSetupContract; spaces?: SpacesPluginSetup; + workflowsExtensions?: WorkflowsExtensionsServerPluginSetup; } export interface ElasticAssistantPluginStartDependencies { actions: ActionsPluginStart; alerting: AlertingServerStart; + cases?: CasesServerStart; llmTasks: LlmTasksPluginStart; inference: InferenceServerStart; spaces?: SpacesPluginStart; licensing: LicensingPluginStart; productDocBase: ProductDocBaseStartContract; security: SecurityPluginStart; + taskManager: TaskManagerStartContract; } export interface ElasticAssistantApiRequestHandlerContext { @@ -190,6 +207,16 @@ export interface ElasticAssistantApiRequestHandlerContext { */ updateAnonymizationFields: () => Promise; userProfile: UserProfileServiceStart; + /** + * Get the Cases plugin start contract, if available. + * Returns undefined if Cases plugin is not installed. + */ + getCases: () => CasesServerStart | undefined; + /** + * Get the Alert Grouping Task for scheduling/unscheduling workflows. + * Returns undefined if not initialized. + */ + getAlertGroupingTask: () => import('./lib/alert_grouping').AlertGroupingTask | undefined; } /** * @internal diff --git a/x-pack/solutions/security/plugins/elastic_assistant/tsconfig.json b/x-pack/solutions/security/plugins/elastic_assistant/tsconfig.json index 1e580ffac7c2b..3f4cc2081c00d 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/tsconfig.json +++ b/x-pack/solutions/security/plugins/elastic_assistant/tsconfig.json @@ -102,9 +102,12 @@ "@kbn/core-ui-settings-browser-mocks", "@kbn/management-settings-ids", "@kbn/agent-builder-plugin", + "@kbn/cases-plugin", "@kbn/cloud-plugin", + "@kbn/workflows-extensions", + "@kbn/workflows", ], "exclude": [ "target/**/*", ] -} +} \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index dff54c88aca92..fc06a8b080bfc 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -106,6 +106,11 @@ export const allowedExperimentalValues = Object.freeze({ */ storeGapsInEventLogEnabled: true, + /** + * Enables scheduling gap fills for rules + */ + bulkFillRuleGapsEnabled: true, + /** * Adds a new option to filter descendants of a process for Management / Trusted Apps */ @@ -209,27 +214,18 @@ export const allowedExperimentalValues = Object.freeze({ /** * Enables the Automatic Migration of Splunk dashboards in Security Solution */ - splunkV2DashboardsEnabled: true, - - /** - * Enables Detection Engine Health UI - */ - deHealthUIEnabled: false, - - /** - * Enables Rule Health UI - */ - ruleHealthUIEnabled: false, - - /** - * Enables the Automatic Troubleshooting Agent Builder skill - */ - automaticTroubleshootingSkill: false, + splunkV2DashboardsEnabled: false, /** - * Enables the new flyout using the EUI flyout system + * Enables the Alert Grouping feature for automatically grouping related alerts into cases. + * When enabled, allows configuration of alert grouping workflows that: + * - Extract entities (IPs, hostnames, users, etc.) from alerts + * - Match alerts to existing cases based on shared observables + * - Create new cases for unmatched alert groups + * - Trigger Attack Discovery generation for case alerts + * Release: 9.x */ - newFlyoutSystemEnabled: false, + alertGroupingEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx index 1334faaae46f8..c66e983078f6c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx @@ -178,6 +178,7 @@ const TakeActionComponent: React.FC = ({ alertIds, markdownComments: [markdown], replacements, + attackDiscoveries, }); await refetchFindAttackDiscoveries?.(); @@ -187,6 +188,7 @@ const TakeActionComponent: React.FC = ({ alertIds, markdown, replacements, + attackDiscoveries, refetchFindAttackDiscoveries, ]); @@ -197,8 +199,16 @@ const TakeActionComponent: React.FC = ({ alertIds, markdownComments: [markdown], replacements, + attackDiscoveries, }); - }, [closePopover, onAddToExistingCase, alertIds, markdown, replacements]); + }, [ + closePopover, + onAddToExistingCase, + alertIds, + markdown, + replacements, + attackDiscoveries, + ]); const { showAssistantOverlay, disabled: viewInAiAssistantDisabled } = useViewInAiAssistant({ attackDiscovery: attackDiscoveries[0], @@ -292,15 +302,15 @@ const TakeActionComponent: React.FC = ({ ? [viewInAgentBuilderItem] : [] : [ - - {i18n.VIEW_IN_AI_ASSISTANT} - , - ] + + {i18n.VIEW_IN_AI_ASSISTANT} + , + ] : [], ].flat(), [ @@ -327,38 +337,38 @@ const TakeActionComponent: React.FC = ({ const markAsOpenItem = !isOpen ? [ - onUpdateWorkflowStatus('open')} - > - {i18n.MARK_AS_OPEN} - , - ] + onUpdateWorkflowStatus('open')} + > + {i18n.MARK_AS_OPEN} + , + ] : []; const markAsAcknowledgedItem = !isAcknowledged ? [ - onUpdateWorkflowStatus('acknowledged')} - > - {i18n.MARK_AS_ACKNOWLEDGED} - , - ] + onUpdateWorkflowStatus('acknowledged')} + > + {i18n.MARK_AS_ACKNOWLEDGED} + , + ] : []; const markAsClosedItem = !isClosed ? [ - onUpdateWorkflowStatus('closed')} - > - {i18n.MARK_AS_CLOSED} - , - ] + onUpdateWorkflowStatus('closed')} + > + {i18n.MARK_AS_CLOSED} + , + ] : []; return [...markAsOpenItem, ...markAsAcknowledgedItem, ...markAsClosedItem, ...items].flat(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_add_to_case/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_add_to_case/index.tsx index f93a1a6a09465..c38862c99d4d4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_add_to_case/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_add_to_case/index.tsx @@ -5,13 +5,24 @@ * 2.0. */ -import { AttachmentType } from '@kbn/cases-plugin/common'; +import { + AttachmentType, + ExternalReferenceStorageType, + ATTACK_DISCOVERY_ATTACHMENT_TYPE, +} from '@kbn/cases-plugin/common'; import type { CaseAttachmentWithoutOwner } from '@kbn/cases-plugin/public/types'; import { useAssistantContext } from '@kbn/elastic-assistant'; -import { getOriginalAlertIds, type Replacements } from '@kbn/elastic-assistant-common'; +import { + ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX, + getOriginalAlertIds, + type Replacements, + type AttackDiscovery, + type AttackDiscoveryAlert, +} from '@kbn/elastic-assistant-common'; import React, { useCallback, useMemo } from 'react'; import { useKibana } from '../../../../../common/lib/kibana'; +import { useSpaceId } from '../../../../../common/hooks/use_space_id'; import * as i18n from './translations'; interface Props { @@ -30,14 +41,17 @@ export const useAddToNewCase = ({ alertIds, markdownComments, replacements, + attackDiscoveries, }: { alertIds: string[]; markdownComments: string[]; replacements?: Replacements; + attackDiscoveries?: Array; }) => void; } => { const { cases } = useKibana().services; const { alertsIndexPattern } = useAssistantContext(); + const spaceId = useSpaceId(); const createCaseFlyout = cases.hooks.useCasesAddToNewCaseFlyout({ initialValue: { @@ -52,11 +66,13 @@ export const useAddToNewCase = ({ headerContent, markdownComments, replacements, + attackDiscoveries, }: { alertIds: string[]; headerContent?: React.ReactNode; markdownComments: string[]; replacements?: Replacements; + attackDiscoveries?: Array; }) => { const userCommentAttachments = markdownComments.map((x) => ({ comment: x, @@ -74,14 +90,44 @@ export const useAddToNewCase = ({ type: AttachmentType.alert, })); - const attachments = [...userCommentAttachments, ...alertAttachments]; + // Attach attack discoveries as external reference attachments + // Only attach AttackDiscoveryAlert types (which have an id and are persisted as alerts) + const attackDiscoveryAttachments: CaseAttachmentWithoutOwner[] = + attackDiscoveries && spaceId + ? attackDiscoveries + .filter((ad) => ad.id != null && 'generationUuid' in ad) + .map((attackDiscovery) => { + const alert = attackDiscovery as AttackDiscoveryAlert; + return { + type: AttachmentType.externalReference, + externalReferenceId: alert.id, + externalReferenceStorage: { + type: ExternalReferenceStorageType.elasticSearchDoc, + }, + externalReferenceAttachmentTypeId: ATTACK_DISCOVERY_ATTACHMENT_TYPE, + externalReferenceMetadata: { + attackDiscoveryAlertId: alert.id, + index: `${ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX}-${spaceId}`, + generationUuid: alert.generationUuid, + title: alert.title, + timestamp: alert.timestamp, + }, + }; + }) + : []; + + const attachments = [ + ...userCommentAttachments, + ...alertAttachments, + ...attackDiscoveryAttachments, + ]; createCaseFlyout.open({ attachments, headerContent, }); }, - [alertsIndexPattern, createCaseFlyout] + [alertsIndexPattern, createCaseFlyout, spaceId] ); const headerContent = useMemo( @@ -94,16 +140,24 @@ export const useAddToNewCase = ({ alertIds, markdownComments, replacements, + attackDiscoveries, }: { alertIds: string[]; markdownComments: string[]; replacements?: Replacements; + attackDiscoveries?: Array; }) => { if (onClick) { onClick(); } - openCreateCaseFlyout({ alertIds, headerContent, markdownComments, replacements }); + openCreateCaseFlyout({ + alertIds, + headerContent, + markdownComments, + replacements, + attackDiscoveries, + }); }, [headerContent, onClick, openCreateCaseFlyout] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_add_to_existing_case/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_add_to_existing_case/index.tsx index c1e618d1f95ea..09a76aecca44f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_add_to_existing_case/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_add_to_existing_case/index.tsx @@ -5,13 +5,24 @@ * 2.0. */ -import { AttachmentType } from '@kbn/cases-plugin/common'; +import { + AttachmentType, + ExternalReferenceStorageType, + ATTACK_DISCOVERY_ATTACHMENT_TYPE, +} from '@kbn/cases-plugin/common'; import type { CaseAttachmentWithoutOwner } from '@kbn/cases-plugin/public/types'; import { useAssistantContext } from '@kbn/elastic-assistant'; -import { getOriginalAlertIds, type Replacements } from '@kbn/elastic-assistant-common'; +import { + ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX, + getOriginalAlertIds, + type Replacements, + type AttackDiscovery, + type AttackDiscoveryAlert, +} from '@kbn/elastic-assistant-common'; import { useCallback } from 'react'; import { useKibana } from '../../../../../common/lib/kibana'; +import { useSpaceId } from '../../../../../common/hooks/use_space_id'; import * as i18n from './translations'; interface Props { @@ -28,14 +39,17 @@ export const useAddToExistingCase = ({ alertIds, markdownComments, replacements, + attackDiscoveries, }: { alertIds: string[]; markdownComments: string[]; replacements?: Replacements; + attackDiscoveries?: Array; }) => void; } => { const { cases } = useKibana().services; const { alertsIndexPattern } = useAssistantContext(); + const spaceId = useSpaceId(); const { open: openSelectCaseModal } = cases.hooks.useCasesAddToExistingCaseModal({ onClose: onClick, @@ -49,10 +63,12 @@ export const useAddToExistingCase = ({ alertIds, markdownComments, replacements, + attackDiscoveries, }: { alertIds: string[]; markdownComments: string[]; replacements?: Replacements; + attackDiscoveries?: Array; }) => { const userCommentAttachments = markdownComments.map((x) => ({ comment: x, @@ -70,11 +86,41 @@ export const useAddToExistingCase = ({ type: AttachmentType.alert, })); - const attachments = [...userCommentAttachments, ...alertAttachments]; + // Attach attack discoveries as external reference attachments + // Only attach AttackDiscoveryAlert types (which have an id and are persisted as alerts) + const attackDiscoveryAttachments: CaseAttachmentWithoutOwner[] = + attackDiscoveries && spaceId + ? attackDiscoveries + .filter((ad) => ad.id != null && 'generationUuid' in ad) + .map((attackDiscovery) => { + const alert = attackDiscovery as AttackDiscoveryAlert; + return { + type: AttachmentType.externalReference, + externalReferenceId: alert.id, + externalReferenceStorage: { + type: ExternalReferenceStorageType.elasticSearchDoc, + }, + externalReferenceAttachmentTypeId: ATTACK_DISCOVERY_ATTACHMENT_TYPE, + externalReferenceMetadata: { + attackDiscoveryAlertId: alert.id, + index: `${ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX}-${spaceId}`, + generationUuid: alert.generationUuid, + title: alert.title, + timestamp: alert.timestamp, + }, + }; + }) + : []; + + const attachments = [ + ...userCommentAttachments, + ...alertAttachments, + ...attackDiscoveryAttachments, + ]; openSelectCaseModal({ getAttachments: () => attachments }); }, - [alertsIndexPattern, openSelectCaseModal] + [alertsIndexPattern, openSelectCaseModal, spaceId] ); return { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/details_flyout/definition/filters.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/details_flyout/definition/filters.test.tsx index cdae5883f727e..861a612c5558e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/details_flyout/definition/filters.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/details_flyout/definition/filters.test.tsx @@ -37,9 +37,7 @@ const renderComponent = async () => { }); }; -// FLAKY: https://github.com/elastic/kibana/issues/238898 -// FLAKY: https://github.com/elastic/kibana/issues/238897 -describe.skip('Filters', () => { +describe('Filters', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_attachment_type.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_attachment_type.tsx new file mode 100644 index 0000000000000..b4c0214f11ade --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_attachment_type.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 React from 'react'; +import { EuiAvatar } from '@elastic/eui'; +import type { ExternalReferenceAttachmentType } from '@kbn/cases-plugin/public/client/attachment_framework/types'; +import { ATTACK_DISCOVERY_ATTACHMENT_TYPE } from '@kbn/cases-plugin/common'; +import { getLazyAttackDiscoveryContent } from './lazy_attack_discovery_content'; +import { getLazyAttackDiscoveryEvent } from './lazy_attack_discovery_event'; +import type { IAttackDiscoveryAttachmentProps } from './types'; + +export const getAttackDiscoveryAttachmentType = (): ExternalReferenceAttachmentType => ({ + id: ATTACK_DISCOVERY_ATTACHMENT_TYPE, + displayName: 'Attack Discovery', + icon: 'bug', + // @ts-expect-error: TS2322 figure out types for children lazyExotic + getAttachmentViewObject: (props: IAttackDiscoveryAttachmentProps) => { + return { + event: getLazyAttackDiscoveryEvent(props), + timelineAvatar: ( + + ), + children: getLazyAttackDiscoveryContent, + }; + }, +}); + diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_content.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_content.tsx new file mode 100644 index 0000000000000..cbd16886e8a99 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_content.tsx @@ -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 { EuiLoadingSpinner, EuiText } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import { useAssistantContext } from '@kbn/elastic-assistant'; +import { useFindAttackDiscoveries } from '../../../attack_discovery/pages/use_find_attack_discoveries'; +import { AttackDiscoveryTab } from '../../../attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab'; +import type { IAttackDiscoveryAttachmentProps } from './types'; + +const containerCss = css` + width: 100%; + max-width: 100%; + overflow: hidden; + word-wrap: break-word; + overflow-wrap: break-word; +`; + +const AttackDiscoveryContent = ({ externalReferenceMetadata }: IAttackDiscoveryAttachmentProps) => { + const metadata = externalReferenceMetadata; + const { assistantAvailability, http } = useAssistantContext(); + + // When querying by specific ID, don't use time range filter - the attack discovery + // may have been created outside the current global time range + const { isLoading, data } = useFindAttackDiscoveries({ + ids: metadata?.attackDiscoveryAlertId ? [metadata.attackDiscoveryAlertId] : undefined, + http, + isAssistantEnabled: assistantAvailability.isAssistantEnabled, + }); + + const attackDiscovery = useMemo(() => { + if (!data?.data || data.data.length === 0) { + return null; + } + return data.data[0]; + }, [data]); + + if (!metadata) { + return ( + + Attack discovery information not available + + ); + } + + if (isLoading) { + return ; + } + + if (!attackDiscovery) { + return ( + + Attack discovery not found + + ); + } + + return ( +
+ +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export { AttackDiscoveryContent as default }; + diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_event.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_event.tsx new file mode 100644 index 0000000000000..6c12d43398bec --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/attack_discovery_event.tsx @@ -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 { EuiLink, EuiText } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useNavigation } from '@kbn/security-solution-navigation/src/navigation'; +import type { IAttackDiscoveryAttachmentProps } from './types'; + +const AttackDiscoveryEvent = ({ + externalReferenceMetadata, + externalReferenceId, +}: IAttackDiscoveryAttachmentProps) => { + const { getAppUrl, navigateTo } = useNavigation(); + + const attackDiscoveryTitle = useMemo(() => { + return externalReferenceMetadata?.title || `Attack Discovery ${externalReferenceId}`; + }, [externalReferenceMetadata?.title, externalReferenceId]); + + // TODO: Add navigation to attack discovery details page when available + const attackDiscoveryHref = useMemo(() => { + // For now, link to the alert details page + return getAppUrl({ + path: `/app/security/alerts/${externalReferenceId}`, + }); + }, [getAppUrl, externalReferenceId]); + + const onLinkClick = useCallback( + (ev: React.MouseEvent) => { + ev.preventDefault(); + return navigateTo({ url: attackDiscoveryHref }); + }, + [navigateTo, attackDiscoveryHref] + ); + + return ( + + Added attack discovery: + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {attackDiscoveryTitle} + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { AttackDiscoveryEvent as default }; + + + + diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/lazy_attack_discovery_content.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/lazy_attack_discovery_content.tsx new file mode 100644 index 0000000000000..394fd62904a12 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/lazy_attack_discovery_content.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import type { IAttackDiscoveryAttachmentProps } from './types'; + +const AttackDiscoveryContent = lazy(() => import('./attack_discovery_content')); + +export const getLazyAttackDiscoveryContent = (props: IAttackDiscoveryAttachmentProps) => { + return ( + + + + ); +}; + + + + diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/lazy_attack_discovery_event.tsx b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/lazy_attack_discovery_event.tsx new file mode 100644 index 0000000000000..c967699197251 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/lazy_attack_discovery_event.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; +import type { IAttackDiscoveryAttachmentProps } from './types'; + +const AttackDiscoveryEvent = lazy(() => import('./attack_discovery_event')); + +export const getLazyAttackDiscoveryEvent = (props: IAttackDiscoveryAttachmentProps) => { + return ( + + + + ); +}; + + + + diff --git a/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/types.ts b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/types.ts new file mode 100644 index 0000000000000..b4b1bb47e5ab9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cases/attachments/attack_discovery/types.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. + */ + +import type { ExternalReferenceAttachmentViewProps } from '@kbn/cases-plugin/public/client/attachment_framework/types'; + +export interface AttackDiscoveryAttachmentMetadata { + attackDiscoveryAlertId: string; + index: string; + generationUuid: string; + title: string; + timestamp: string; +} + +export interface IAttackDiscoveryAttachmentProps extends ExternalReferenceAttachmentViewProps { + externalReferenceMetadata: AttackDiscoveryAttachmentMetadata | null; +} + + + + diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/filters/page_filters.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/filters/page_filters.test.tsx index 9cf5087f642bb..d73c89fe60cdb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/filters/page_filters.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/filters/page_filters.test.tsx @@ -118,11 +118,11 @@ describe('PageFilters', () => { const controlsConfig = [ { title: 'Status', - field_name: 'kibana.alert.workflow_status', - selected_options: ['open'], - hide_action_bar: true, + fieldName: 'kibana.alert.workflow_status', + selectedOptions: ['open'], + hideActionBar: true, persist: true, - hide_exists: true, + hideExists: true, }, ]; jest diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/filters/page_filters.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/filters/page_filters.tsx index 7de2e8ce58812..0cb791d0d2b04 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/filters/page_filters.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts/filters/page_filters.tsx @@ -13,7 +13,6 @@ import { AlertFilterControls } from '@kbn/alerts-ui-shared/src/alert_filter_cont import { useHistory } from 'react-router-dom'; import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; import type { DataView, DataViewSpec } from '@kbn/data-plugin/common'; -import { convertCamelCasedKeysToSnakeCase } from '@kbn/presentation-publishing'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useKibana } from '../../../../common/lib/kibana'; import { DEFAULT_ALERTS_INDEX } from '../../../../../common/constants'; @@ -24,30 +23,30 @@ import { SECURITY_ALERT_DATA_VIEW } from '../../../constants'; export const DEFAULT_DETECTION_PAGE_FILTERS: FilterControlConfig[] = [ { title: 'Status', - field_name: 'kibana.alert.workflow_status', - selected_options: ['open'], - display_settings: { - hide_action_bar: true, - hide_exists: true, + fieldName: 'kibana.alert.workflow_status', + selectedOptions: ['open'], + displaySettings: { + hideActionBar: true, + hideExists: true, }, persist: true, }, { title: 'Severity', - field_name: 'kibana.alert.severity', - selected_options: [], - display_settings: { - hide_action_bar: true, - hide_exists: true, + fieldName: 'kibana.alert.severity', + selectedOptions: [], + displaySettings: { + hideActionBar: true, + hideExists: true, }, }, { title: 'User', - field_name: 'user.name', + fieldName: 'user.name', }, { title: 'Host', - field_name: 'host.name', + fieldName: 'host.name', }, ]; @@ -82,10 +81,10 @@ export const PageFilters = memo(({ dataView, ...props }: PageFiltersProps) => { }), [history] ); - const filterControlsUrlState = useMemo(() => { - const pageFilters = urlStorage.get(URL_PARAM_KEY.pageFilter); - return pageFilters ? pageFilters.map(convertCamelCasedKeysToSnakeCase) : undefined; - }, [urlStorage]); + const filterControlsUrlState = useMemo( + () => urlStorage.get(URL_PARAM_KEY.pageFilter) ?? undefined, + [urlStorage] + ); const setFilterControlsUrlState = useCallback( (newFilterControls: FilterControlConfig[]) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/columns.tsx index 5f80b0f558c68..7b683149b56b3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/columns.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_rule_panel/columns.tsx @@ -10,12 +10,12 @@ import type { EuiBasicTableColumn } from '@elastic/eui'; import { EuiText } from '@elastic/eui'; import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; import { TableId } from '@kbn/securitysolution-data-table'; -import { SECURITY_CELL_ACTIONS_DEFAULT } from '@kbn/ui-actions-plugin/common/trigger_ids'; import { FormattedCount } from '../../../../common/components/formatted_number'; import { COUNT_TABLE_TITLE } from '../alerts_count_panel/translations'; import { CellActionsMode, SecurityCellActions, + SecurityCellActionsTrigger, SecurityCellActionType, } from '../../../../common/components/cell_actions'; import { getSourcererScopeId } from '../../../../helpers'; @@ -58,7 +58,7 @@ const CELL_ACTIONS_COLUMN: EuiBasicTableColumn = { = ({ ( () => ({ - _id: (ecsAlert as Ecs)._id, - _index: (ecsAlert as Ecs)._index, - ecs: ecsAlert as Ecs, + _id: (alert as Ecs)._id, + _index: (alert as Ecs)._index, + ecs: alert as Ecs, data: legacyAlert as TimelineItem['data'], }), - [ecsAlert, legacyAlert] - ); - - // We are creating this object here so we can pass it to the cell action, which will then pass it to the flyout. - // This way we can use the same flyout content code between Security Solution and Discover. - const esHitRecord: EsHitRecord = useMemo( - () => ({ - _id: ecsAlert._id, - _index: ecsAlert._index, - _source: alert, - }), - [alert, ecsAlert] + [alert, legacyAlert] ); const setEventsLoading = useCallback( @@ -86,7 +73,6 @@ export const ActionsCellComponent: GetSecurityAlertsTableProp<'renderActionsCell columnId={`actions-${rowIndex}`} columnHeaders={columnHeaders} controlColumn={leadingControlColumn} - esHitRecord={esHitRecord} data={timelineItem} disabled={false} index={rowIndex} diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/filters/page_filters.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/filters/page_filters.test.tsx index 9cf5087f642bb..d73c89fe60cdb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/filters/page_filters.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/filters/page_filters.test.tsx @@ -118,11 +118,11 @@ describe('PageFilters', () => { const controlsConfig = [ { title: 'Status', - field_name: 'kibana.alert.workflow_status', - selected_options: ['open'], - hide_action_bar: true, + fieldName: 'kibana.alert.workflow_status', + selectedOptions: ['open'], + hideActionBar: true, persist: true, - hide_exists: true, + hideExists: true, }, ]; jest diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/filters/page_filters.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/filters/page_filters.tsx index 5fa0569a77597..5ef7b55e6e373 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/filters/page_filters.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/filters/page_filters.tsx @@ -21,12 +21,12 @@ import { useSpaceId } from '../../../../common/hooks/use_space_id'; const DEFAULT_ATTACKS_PAGE_FILTERS: FilterControlConfig[] = [ { title: 'Status', - field_name: 'kibana.alert.workflow_status', - selected_options: ['open'], + fieldName: 'kibana.alert.workflow_status', + selectedOptions: ['open'], persist: true, - display_settings: { - hide_action_bar: true, - hide_exists: true, + displaySettings: { + hideActionBar: true, + hideExists: true, }, }, ]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx index 526f52f3ca4d6..1b6ceef43c41a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.test.tsx @@ -10,18 +10,10 @@ import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; import { AttacksGroupTakeActionItems } from './attacks_group_take_action_items'; import { getMockAttackDiscoveryAlerts } from '../../../../attack_discovery/pages/mock/mock_attack_discovery_alerts'; -import { useViewInAiAssistant } from '../../../../attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant'; import { useAttacksPrivileges } from '../../../hooks/attacks/bulk_actions/use_attacks_privileges'; -import { useAttackViewInAiAssistantContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items'; import type { AttackDiscoveryAlert } from '@kbn/elastic-assistant-common'; -jest.mock( - '../../../../attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant' -); jest.mock('../../../hooks/attacks/bulk_actions/use_attacks_privileges'); -jest.mock( - '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items' -); jest.mock('../../../../common/components/user_privileges', () => ({ useUserPrivileges: () => ({ timelinePrivileges: { read: true }, @@ -37,13 +29,6 @@ jest.mock('../../../../common/hooks/use_license', () => ({ const mockUseAttacksPrivileges = useAttacksPrivileges as jest.MockedFunction< typeof useAttacksPrivileges >; -const mockUseViewInAiAssistant = useViewInAiAssistant as jest.MockedFunction< - typeof useViewInAiAssistant ->; -const mockUseAttackViewInAiAssistantContextMenuItems = - useAttackViewInAiAssistantContextMenuItems as jest.MockedFunction< - typeof useAttackViewInAiAssistantContextMenuItems - >; const mockAttack = getMockAttackDiscoveryAlerts()[0]; function renderAttack(attack: AttackDiscoveryAlert) { @@ -62,20 +47,6 @@ describe('AttacksGroupTakeActionItems', () => { hasAttackIndexWrite: true, loading: false, }); - mockUseViewInAiAssistant.mockReturnValue({ - showAssistantOverlay: jest.fn(), - disabled: false, - promptContextId: 'prompt-context-id', - }); - mockUseAttackViewInAiAssistantContextMenuItems.mockReturnValue({ - items: [ - { - name: 'View in AI Assistant', - key: 'viewInAiAssistant', - 'data-test-subj': 'viewInAiAssistant', - }, - ], - }); }); describe('workflow items', () => { @@ -153,20 +124,4 @@ describe('AttacksGroupTakeActionItems', () => { expect(await findByText('Investigate in timeline')).toBeInTheDocument(); }); }); - - describe('view in ai assistant', () => { - it('should render the `View in AI Assistant` action item', async () => { - const { findByText } = renderAttack(mockAttack); - expect(await findByText('View in AI Assistant')).toBeInTheDocument(); - }); - - it('should not render the action item when hook returns no items', async () => { - mockUseAttackViewInAiAssistantContextMenuItems.mockReturnValue({ - items: [], - }); - - const { queryByText } = renderAttack(mockAttack); - expect(queryByText('View in AI Assistant')).not.toBeInTheDocument(); - }); - }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx index 4a7de4abd2ab4..9ee5a993036c2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/attacks_group_take_action_items.tsx @@ -7,11 +7,7 @@ import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiContextMenu } from '@elastic/eui'; -import { - getAttackDiscoveryMarkdown, - getOriginalAlertIds, - type AttackDiscoveryAlert, -} from '@kbn/elastic-assistant-common'; +import type { AttackDiscoveryAlert } from '@kbn/elastic-assistant-common'; import React, { useCallback, useMemo } from 'react'; import { useInvalidateFindAttackDiscoveries } from '../../../../attack_discovery/pages/use_find_attack_discoveries'; import type { inputsModel } from '../../../../common/store'; @@ -22,8 +18,6 @@ import { useAttackWorkflowStatusContextMenuItems } from '../../../hooks/attacks/ import type { AttackWithWorkflowStatus } from '../../../hooks/attacks/bulk_actions/types'; import { useAttackTagsContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_tags_context_menu_items'; import { useAttackInvestigateInTimelineContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items'; -import { useAttackCaseContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items'; -import { useAttackViewInAiAssistantContextMenuItems } from '../../../hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items'; interface AttacksGroupTakeActionItemsProps { attack: AttackDiscoveryAlert; @@ -42,14 +36,9 @@ export function AttacksGroupTakeActionItems({ globalQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); }, [globalQueries]); - const originalAlertIds = useMemo( - () => getOriginalAlertIds({ alertIds: attack.alertIds, replacements: attack.replacements }), - [attack.alertIds, attack.replacements] - ); - const baseAttackProps = useMemo(() => { - return { attackId: attack.id, relatedAlertIds: originalAlertIds }; - }, [attack.id, originalAlertIds]); + return { attackId: attack.id, relatedAlertIds: attack.alertIds }; + }, [attack.alertIds, attack.id]); const attacksWithAssignees = useMemo(() => { return [{ ...baseAttackProps, assignees: attack.assignees }]; @@ -88,32 +77,7 @@ export function AttacksGroupTakeActionItems({ closePopover, }); - const attacksWithTimelineAlerts = useMemo(() => [{ ...baseAttackProps }], [baseAttackProps]); - const { items: investigateInTimelineItems } = useAttackInvestigateInTimelineContextMenuItems({ - attacksWithTimelineAlerts, - closePopover, - }); - - const attacksWithCase = useMemo( - () => [ - { - ...baseAttackProps, - markdownComment: getAttackDiscoveryMarkdown({ - attackDiscovery: attack, - replacements: attack.replacements, - }), - }, - ], - [attack, baseAttackProps] - ); - - const { items: casesItems } = useAttackCaseContextMenuItems({ - closePopover, - title: attack.title, - attacksWithCase, - }); - const { items: viewInAiAssistantItems } = useAttackViewInAiAssistantContextMenuItems({ attack, closePopover, }); @@ -121,23 +85,9 @@ export function AttacksGroupTakeActionItems({ const defaultPanel: EuiContextMenuPanelDescriptor = useMemo( () => ({ id: 0, - items: [ - ...workflowItems, - ...assignItems, - ...tagsItems, - ...investigateInTimelineItems, - ...casesItems, - ...viewInAiAssistantItems, - ], + items: [...workflowItems, ...assignItems, ...tagsItems, ...investigateInTimelineItems], }), - [ - workflowItems, - assignItems, - tagsItems, - investigateInTimelineItems, - casesItems, - viewInAiAssistantItems, - ] + [workflowItems, assignItems, tagsItems, investigateInTimelineItems] ); const panels: EuiContextMenuPanelDescriptor[] = useMemo( diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx index d527f0c16b17a..86002643b4fae 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/table/table_section.tsx @@ -237,7 +237,7 @@ export const TableSection = React.memo( (props) => { const attack = getAttack(props.selectedGroup, props.groupBucket); if (!attack) return ; - return ; + return ; }, [getAttack, statusFilter] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.test.tsx deleted file mode 100644 index 62be6545d430b..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.test.tsx +++ /dev/null @@ -1,196 +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 { renderHook } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@kbn/react-query'; -import React from 'react'; -import { useBulkAttackCaseItems } from './use_bulk_attack_case_items'; -import { - ALERT_ATTACK_DISCOVERY_ALERT_IDS, - ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, -} from '../constants'; - -jest.mock('../../../../../common/lib/kibana', () => ({ - useKibana: jest.fn(), -})); -jest.mock( - '../../../../../attack_discovery/pages/results/take_action/use_add_to_existing_case', - () => ({ - useAddToExistingCase: jest.fn(), - }) -); -jest.mock('../../../../../attack_discovery/pages/results/take_action/use_add_to_case', () => ({ - useAddToNewCase: jest.fn(), -})); - -const { useKibana } = jest.requireMock('../../../../../common/lib/kibana') as { - useKibana: jest.Mock; -}; -const { useAddToExistingCase } = jest.requireMock( - '../../../../../attack_discovery/pages/results/take_action/use_add_to_existing_case' -) as { useAddToExistingCase: jest.Mock }; -const { useAddToNewCase } = jest.requireMock( - '../../../../../attack_discovery/pages/results/take_action/use_add_to_case' -) as { useAddToNewCase: jest.Mock }; - -let queryClient: QueryClient; - -function wrapper(props: { children: React.ReactNode }) { - return React.createElement(QueryClientProvider, { client: queryClient }, props.children); -} - -describe('useBulkAttackCaseItems', () => { - const onAddToNewCase = jest.fn(); - const onAddToExistingCase = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - queryClient = new QueryClient(); - - useKibana.mockReturnValue({ - services: { - cases: { - helpers: { - canUseCases: jest.fn().mockReturnValue({ - createComment: true, - read: true, - }), - }, - }, - }, - }); - - useAddToNewCase.mockReturnValue({ - disabled: false, - onAddToNewCase, - }); - - useAddToExistingCase.mockReturnValue({ - disabled: false, - onAddToExistingCase, - }); - }); - - it('should return two case items when user has permissions', () => { - const { result } = renderHook(() => useBulkAttackCaseItems({ title: 'attack title' }), { - wrapper, - }); - - expect(result.current.items).toHaveLength(2); - }); - - it('should return empty items when user lacks cases permissions', () => { - useKibana.mockReturnValue({ - services: { - cases: { - helpers: { - canUseCases: jest.fn().mockReturnValue({ - createComment: false, - read: true, - }), - }, - }, - }, - }); - - const { result } = renderHook(() => useBulkAttackCaseItems({ title: 'attack title' }), { - wrapper, - }); - - expect(result.current.items).toEqual([]); - }); - - it('should pass unique alert ids and markdown comments to onAddToNewCase', async () => { - const closePopover = jest.fn(); - const { result } = renderHook( - () => useBulkAttackCaseItems({ title: 'attack title', closePopover }), - { - wrapper, - } - ); - - await result.current.items[0]?.onClick?.( - [ - { - _id: 'attack-1', - data: [ - { field: ALERT_ATTACK_DISCOVERY_ALERT_IDS, value: ['alert-1', 'alert-2'] }, - { field: ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, value: ['markdown 1'] }, - ], - ecs: { _id: 'attack-1' }, - }, - { - _id: 'attack-2', - data: [ - { field: ALERT_ATTACK_DISCOVERY_ALERT_IDS, value: ['alert-2', 'alert-3'] }, - { field: ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, value: ['markdown 2'] }, - ], - ecs: { _id: 'attack-2' }, - }, - ], - false, - jest.fn(), - jest.fn(), - jest.fn() - ); - - expect(onAddToNewCase).toHaveBeenCalledWith({ - alertIds: ['alert-1', 'alert-2', 'alert-3'], - markdownComments: ['markdown 1', 'markdown 2'], - }); - expect(closePopover).toHaveBeenCalledTimes(1); - }); - - it('should pass unique alert ids and markdown comments to onAddToExistingCase', async () => { - const closePopover = jest.fn(); - const { result } = renderHook( - () => useBulkAttackCaseItems({ title: 'attack title', closePopover }), - { - wrapper, - } - ); - - await result.current.items[1]?.onClick?.( - [ - { - _id: 'attack-1', - data: [ - { field: ALERT_ATTACK_DISCOVERY_ALERT_IDS, value: ['alert-1'] }, - { field: ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, value: ['markdown 1'] }, - ], - ecs: { _id: 'attack-1' }, - }, - { - _id: 'attack-2', - data: [ - { field: ALERT_ATTACK_DISCOVERY_ALERT_IDS, value: ['alert-2'] }, - { field: ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, value: ['markdown 2'] }, - ], - ecs: { _id: 'attack-2' }, - }, - ], - false, - jest.fn(), - jest.fn(), - jest.fn() - ); - - expect(onAddToExistingCase).toHaveBeenCalledWith({ - alertIds: ['alert-1', 'alert-2'], - markdownComments: ['markdown 1', 'markdown 2'], - }); - expect(closePopover).toHaveBeenCalledTimes(1); - }); - - it('should return empty panels', () => { - const { result } = renderHook(() => useBulkAttackCaseItems({ title: 'attack title' }), { - wrapper, - }); - - expect(result.current.panels).toEqual([]); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.tsx deleted file mode 100644 index c942593e5b64e..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_case_items.tsx +++ /dev/null @@ -1,139 +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 { useCallback, useMemo } from 'react'; -import type { BulkActionsConfig } from '@kbn/response-ops-alerts-table/types'; -import { useAddToExistingCase } from '../../../../../attack_discovery/pages/results/take_action/use_add_to_existing_case'; -import { useAddToNewCase } from '../../../../../attack_discovery/pages/results/take_action/use_add_to_case'; -import { APP_ID } from '../../../../../../common'; -import { useKibana } from '../../../../../common/lib/kibana'; -import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from '../translations'; -import { ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT } from '../constants'; -import type { BulkAttackActionItems } from '../types'; -import { extractRelatedDetectionAlertIds } from '../utils/extract_related_detection_alert_ids'; - -export interface UseBulkAttackCaseItemsProps { - /** Title used to initialize "create case" flyout */ - title: string; - /** Optional callback when add-to-case action is triggered */ - onCasesAdd?: () => void; - /** Optional callback to close the popover after triggering action */ - closePopover?: () => void; -} - -/** - * Hook that provides bulk action items for adding attacks to a new or existing case. - */ -export const useBulkAttackCaseItems = ({ - title, - onCasesAdd, - closePopover, -}: UseBulkAttackCaseItemsProps): BulkAttackActionItems => { - const { - services: { cases }, - } = useKibana(); - const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); - const canCreateAndReadCases = userCasesPermissions.createComment && userCasesPermissions.read; - const canUserCreateAndReadCases = useCallback( - () => canCreateAndReadCases, - [canCreateAndReadCases] - ); - - const { onAddToNewCase, disabled: isAddToNewCaseDisabled } = useAddToNewCase({ - canUserCreateAndReadCases, - title, - onClick: onCasesAdd, - }); - - const { onAddToExistingCase, disabled: isAddToExistingCaseDisabled } = useAddToExistingCase({ - canUserCreateAndReadCases, - onClick: onCasesAdd, - }); - - const onAddToNewCaseClick = useCallback['onClick']>( - async (alertItems) => { - const alertIds = extractRelatedDetectionAlertIds(alertItems); - const markdownComments = alertItems - .map((item) => { - const value = item.data.find( - (data) => data.field === ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT - )?.value; - if (!Array.isArray(value)) { - return undefined; - } - return typeof value[0] === 'string' ? value[0] : undefined; - }) - .filter((comment): comment is string => comment != null); - - onAddToNewCase({ alertIds, markdownComments }); - closePopover?.(); - }, - [closePopover, onAddToNewCase] - ); - - const onAddToExistingCaseClick = useCallback['onClick']>( - async (alertItems) => { - const alertIds = extractRelatedDetectionAlertIds(alertItems); - const markdownComments = alertItems - .map((item) => { - const value = item.data.find( - (data) => data.field === ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT - )?.value; - if (!Array.isArray(value)) { - return undefined; - } - return typeof value[0] === 'string' ? value[0] : undefined; - }) - .filter((comment): comment is string => comment != null); - - onAddToExistingCase({ alertIds, markdownComments }); - closePopover?.(); - }, - [closePopover, onAddToExistingCase] - ); - - const items = useMemo( - () => - canCreateAndReadCases - ? [ - { - name: ADD_TO_NEW_CASE, - label: ADD_TO_NEW_CASE, - key: 'attack-add-to-new-case', - 'data-test-subj': 'attack-add-to-new-case', - disableOnQuery: true, - disable: isAddToNewCaseDisabled, - onClick: onAddToNewCaseClick, - }, - { - name: ADD_TO_EXISTING_CASE, - label: ADD_TO_EXISTING_CASE, - key: 'attack-add-to-existing-case', - 'data-test-subj': 'attack-add-to-existing-case', - disableOnQuery: true, - disable: isAddToExistingCaseDisabled, - onClick: onAddToExistingCaseClick, - }, - ] - : [], - [ - canCreateAndReadCases, - isAddToExistingCaseDisabled, - isAddToNewCaseDisabled, - onAddToExistingCaseClick, - onAddToNewCaseClick, - ] - ); - - return useMemo( - () => ({ - items, - panels: [], - }), - [items] - ); -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.test.tsx deleted file mode 100644 index 9ea36b533e9fa..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.test.tsx +++ /dev/null @@ -1,93 +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 { renderHook } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@kbn/react-query'; -import React from 'react'; -import { useBulkAttackInvestigateInTimelineItems } from './use_bulk_attack_investigate_in_timeline_items'; -import { useUserPrivileges } from '../../../../../common/components/user_privileges'; -import { useInvestigateInTimeline } from '../../../../../common/hooks/timeline/use_investigate_in_timeline'; -import { ALERT_ATTACK_DISCOVERY_ALERT_IDS } from '../constants'; - -jest.mock('../../../../../common/components/user_privileges'); -jest.mock('../../../../../common/hooks/timeline/use_investigate_in_timeline'); -jest.mock('../../../../components/alerts_table/actions', () => ({ - buildAlertsKqlFilter: jest.fn().mockReturnValue([]), -})); - -const mockUseUserPrivileges = useUserPrivileges as jest.MockedFunction; -const mockUseInvestigateInTimeline = useInvestigateInTimeline as jest.MockedFunction< - typeof useInvestigateInTimeline ->; - -let queryClient: QueryClient; - -function wrapper(props: { children: React.ReactNode }) { - return React.createElement(QueryClientProvider, { client: queryClient }, props.children); -} - -describe('useBulkAttackInvestigateInTimelineItems', () => { - beforeEach(() => { - jest.clearAllMocks(); - queryClient = new QueryClient(); - - mockUseInvestigateInTimeline.mockReturnValue({ - investigateInTimeline: jest.fn(), - } as unknown as ReturnType); - - mockUseUserPrivileges.mockReturnValue({ - timelinePrivileges: { read: true }, - } as unknown as ReturnType); - }); - - it('should return empty items if timeline read privileges are missing', () => { - mockUseUserPrivileges.mockReturnValue({ - timelinePrivileges: { read: false }, - } as unknown as ReturnType); - - const { result } = renderHook(() => useBulkAttackInvestigateInTimelineItems(), { wrapper }); - expect(result.current.items).toEqual([]); - }); - - it('should return one investigate item when user has privileges', () => { - const { result } = renderHook(() => useBulkAttackInvestigateInTimelineItems(), { wrapper }); - expect(result.current.items).toHaveLength(1); - }); - - it('should call investigateInTimeline on click', async () => { - const investigateInTimeline = jest.fn(); - const closePopover = jest.fn(); - mockUseInvestigateInTimeline.mockReturnValue({ - investigateInTimeline, - } as unknown as ReturnType); - - const { result } = renderHook(() => useBulkAttackInvestigateInTimelineItems({ closePopover }), { - wrapper, - }); - await result.current.items[0]?.onClick?.( - [ - { - _id: 'attack-1', - data: [{ field: ALERT_ATTACK_DISCOVERY_ALERT_IDS, value: ['alert-1'] }], - ecs: { _id: 'attack-1' }, - }, - ], - false, - jest.fn(), - jest.fn(), - jest.fn() - ); - - expect(investigateInTimeline).toHaveBeenCalledTimes(1); - expect(closePopover).toHaveBeenCalledTimes(1); - }); - - it('should return empty panels', () => { - const { result } = renderHook(() => useBulkAttackInvestigateInTimelineItems(), { wrapper }); - expect(result.current.panels).toEqual([]); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.tsx deleted file mode 100644 index bcabc0b8a77a3..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_investigate_in_timeline_items.tsx +++ /dev/null @@ -1,70 +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 { useCallback, useMemo } from 'react'; -import type { BulkActionsConfig } from '@kbn/response-ops-alerts-table/types'; -import { useUserPrivileges } from '../../../../../common/components/user_privileges'; -import { useInvestigateInTimeline } from '../../../../../common/hooks/timeline/use_investigate_in_timeline'; -import { buildAlertsKqlFilter } from '../../../../components/alerts_table/actions'; -import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../components/alerts_table/translations'; -import type { BulkAttackActionItems } from '../types'; -import { extractRelatedDetectionAlertIds } from '../utils/extract_related_detection_alert_ids'; - -export interface UseBulkAttackInvestigateInTimelineItemsProps { - /** Optional callback to close the popover after triggering action */ - closePopover?: () => void; -} - -/** - * Hook that provides bulk action items for investigating attacks in Timeline. - */ -export const useBulkAttackInvestigateInTimelineItems = ({ - closePopover, -}: UseBulkAttackInvestigateInTimelineItemsProps = {}): BulkAttackActionItems => { - const { investigateInTimeline } = useInvestigateInTimeline(); - const { - timelinePrivileges: { read: canUseTimeline }, - } = useUserPrivileges(); - - const onInvestigateInTimelineClick = useCallback['onClick']>( - async (alertItems) => { - const alertIds = extractRelatedDetectionAlertIds(alertItems); - const alertIdFilters = buildAlertsKqlFilter('_id', alertIds); - - investigateInTimeline({ - filters: alertIdFilters, - }); - closePopover?.(); - }, - [closePopover, investigateInTimeline] - ); - - const items = useMemo( - () => - canUseTimeline - ? [ - { - name: ACTION_INVESTIGATE_IN_TIMELINE, - label: ACTION_INVESTIGATE_IN_TIMELINE, - key: 'attack-investigate-in-timeline-action-item', - 'data-test-subj': 'attack-investigate-in-timeline-action-item', - disableOnQuery: true, - onClick: onInvestigateInTimelineClick, - }, - ] - : [], - [canUseTimeline, onInvestigateInTimelineClick] - ); - - return useMemo( - () => ({ - items, - panels: [], - }), - [items] - ); -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/constants.ts index 7d3190c6cdc01..90de9e3766bc3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/constants.ts @@ -10,10 +10,3 @@ * This field contains an array of alert IDs that are associated with the attack. */ export const ALERT_ATTACK_DISCOVERY_ALERT_IDS = 'kibana.alert.attack_discovery.alert_ids' as const; - -/** - * Field name for markdown comment generated from an attack. - * This field is used to pass the precomputed markdown payload into bulk case actions. - */ -export const ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT = - 'kibana.alert.attack_discovery.markdown_comment' as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.test.tsx deleted file mode 100644 index 424b54b79304c..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.test.tsx +++ /dev/null @@ -1,130 +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 { renderHook } from '@testing-library/react'; -import { useAttackCaseContextMenuItems } from './use_attack_case_context_menu_items'; -import { useBulkAttackCaseItems } from '../bulk_action_items/use_bulk_attack_case_items'; - -jest.mock('../bulk_action_items/use_bulk_attack_case_items'); - -const mockUseBulkAttackCaseItems = useBulkAttackCaseItems as jest.MockedFunction< - typeof useBulkAttackCaseItems ->; - -describe('useAttackCaseContextMenuItems', () => { - const closePopover = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - mockUseBulkAttackCaseItems.mockReturnValue({ - items: [ - { - label: 'Add to new case', - key: 'attack-add-to-new-case', - 'data-test-subj': 'attack-add-to-new-case', - disableOnQuery: true, - onClick: jest.fn(), - }, - { - label: 'Add to existing case', - key: 'attack-add-to-existing-case', - 'data-test-subj': 'attack-add-to-existing-case', - disableOnQuery: true, - onClick: jest.fn(), - }, - ], - panels: [], - }); - }); - - it('should return items from transformed bulk hook', () => { - const { result } = renderHook(() => - useAttackCaseContextMenuItems({ - attacksWithCase: [ - { - attackId: 'attack-1', - relatedAlertIds: ['alert-1'], - markdownComment: 'markdown', - }, - ], - title: 'Attack title', - closePopover, - }) - ); - - expect(result.current.items).toMatchInlineSnapshot(` - Array [ - Object { - "data-test-subj": "attack-add-to-new-case", - "key": "attack-add-to-new-case", - "name": "Add to new case", - "onClick": [Function], - "panel": undefined, - }, - Object { - "data-test-subj": "attack-add-to-existing-case", - "key": "attack-add-to-existing-case", - "name": "Add to existing case", - "onClick": [Function], - "panel": undefined, - }, - ] - `); - }); - - it('should call useBulkAttackCaseItems with expected props', () => { - renderHook(() => - useAttackCaseContextMenuItems({ - attacksWithCase: [ - { - attackId: 'attack-1', - relatedAlertIds: ['alert-1'], - markdownComment: 'markdown', - }, - ], - title: 'Attack title', - }) - ); - - expect(mockUseBulkAttackCaseItems).toHaveBeenCalledWith({ - closePopover: undefined, - title: 'Attack title', - }); - }); - - it('should pass closePopover to useBulkAttackCaseItems', () => { - renderHook(() => - useAttackCaseContextMenuItems({ - attacksWithCase: [ - { - attackId: 'attack-1', - relatedAlertIds: ['alert-1'], - markdownComment: 'markdown', - }, - ], - title: 'Attack title', - closePopover, - }) - ); - - expect(mockUseBulkAttackCaseItems).toHaveBeenCalledWith({ - closePopover, - title: 'Attack title', - }); - }); - - it('should return empty panels', () => { - const { result } = renderHook(() => - useAttackCaseContextMenuItems({ - attacksWithCase: [], - title: 'Attack title', - }) - ); - - expect(result.current.panels).toEqual([]); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.tsx deleted file mode 100644 index e0fbdfe72fa6d..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_case_context_menu_items.tsx +++ /dev/null @@ -1,68 +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 { useMemo } from 'react'; -import { useBulkAttackCaseItems } from '../bulk_action_items/use_bulk_attack_case_items'; -import { - ALERT_ATTACK_DISCOVERY_ALERT_IDS, - ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, -} from '../constants'; -import { transformBulkActionsToContextMenuItems } from '../utils/transform_bulk_actions_to_context_menu_items'; -import type { - AttackWithCase, - BaseAttackContextMenuItemsProps, - BulkAttackContextMenuItems, -} from '../types'; - -export interface UseAttackCaseContextMenuItemsProps extends BaseAttackContextMenuItemsProps { - /** Array of attacks with alert ids and markdown comments */ - attacksWithCase: AttackWithCase[]; - /** Title used to initialize "create case" flyout */ - title: string; -} - -export const useAttackCaseContextMenuItems = ({ - attacksWithCase, - title, - closePopover, - clearSelection, - setIsLoading, - refresh, -}: UseAttackCaseContextMenuItemsProps): BulkAttackContextMenuItems => { - const bulkActionItems = useBulkAttackCaseItems({ - title, - closePopover, - }); - - const alertItems = useMemo( - () => - attacksWithCase.map((attack) => ({ - _id: attack.attackId, - data: [ - { field: ALERT_ATTACK_DISCOVERY_ALERT_IDS, value: attack.relatedAlertIds }, - { field: ALERT_ATTACK_DISCOVERY_MARKDOWN_COMMENT, value: [attack.markdownComment] }, - ], - ecs: { _id: attack.attackId }, - })), - [attacksWithCase] - ); - - const contextMenuItems = useMemo( - () => - transformBulkActionsToContextMenuItems({ - bulkActionItems, - alertItems, - closePopover, - clearSelection, - setIsLoading, - refresh, - }), - [bulkActionItems, alertItems, closePopover, clearSelection, setIsLoading, refresh] - ); - - return contextMenuItems; -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.test.tsx index 18c79837f3ed9..463af89c023ad 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.test.tsx @@ -7,37 +7,54 @@ import { renderHook } from '@testing-library/react'; import { useAttackInvestigateInTimelineContextMenuItems } from './use_attack_investigate_in_timeline_context_menu_items'; -import { useBulkAttackInvestigateInTimelineItems } from '../bulk_action_items/use_bulk_attack_investigate_in_timeline_items'; +import { getMockAttackDiscoveryAlerts } from '../../../../../attack_discovery/pages/mock/mock_attack_discovery_alerts'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { useInvestigateInTimeline } from '../../../../../common/hooks/timeline/use_investigate_in_timeline'; -jest.mock('../bulk_action_items/use_bulk_attack_investigate_in_timeline_items'); +jest.mock('../../../../../common/components/user_privileges'); +jest.mock('../../../../../common/hooks/timeline/use_investigate_in_timeline'); +jest.mock('../../../../components/alerts_table/actions', () => ({ + buildAlertsKqlFilter: jest.fn().mockReturnValue([]), +})); -const mockUseBulkAttackInvestigateInTimelineItems = - useBulkAttackInvestigateInTimelineItems as jest.MockedFunction< - typeof useBulkAttackInvestigateInTimelineItems - >; +const mockUseUserPrivileges = useUserPrivileges as jest.MockedFunction; +const mockUseInvestigateInTimeline = useInvestigateInTimeline as jest.MockedFunction< + typeof useInvestigateInTimeline +>; + +const mockAttack = getMockAttackDiscoveryAlerts()[0]; describe('useAttackInvestigateInTimelineContextMenuItems', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseBulkAttackInvestigateInTimelineItems.mockReturnValue({ - items: [ - { - label: 'Investigate in timeline', - key: 'attack-investigate-in-timeline-action-item', - 'data-test-subj': 'attack-investigate-in-timeline-action-item', - disableOnQuery: true, - onClick: jest.fn(), - }, - ], - panels: [], - }); + mockUseInvestigateInTimeline.mockReturnValue({ + investigateInTimeline: jest.fn(), + } as unknown as ReturnType); + + mockUseUserPrivileges.mockReturnValue({ + timelinePrivileges: { read: true }, + } as unknown as ReturnType); + }); + + it('should NOT return items if timeline read privileges are missing', () => { + mockUseUserPrivileges.mockReturnValue({ + timelinePrivileges: { read: false }, + } as unknown as ReturnType); + + const { result } = renderHook(() => + useAttackInvestigateInTimelineContextMenuItems({ + attack: mockAttack, + }) + ); + + expect(result.current.items).toEqual([]); }); it('should return one item matching the snapshot', () => { const { result } = renderHook(() => useAttackInvestigateInTimelineContextMenuItems({ - attacksWithTimelineAlerts: [{ attackId: 'attack-1', relatedAlertIds: ['alert-1'] }], + attack: mockAttack, }) ); @@ -48,46 +65,36 @@ describe('useAttackInvestigateInTimelineContextMenuItems', () => { "key": "attack-investigate-in-timeline-action-item", "name": "Investigate in timeline", "onClick": [Function], - "panel": undefined, }, ] `); }); - it('should call useBulkAttackInvestigateInTimelineItems', () => { - renderHook(() => - useAttackInvestigateInTimelineContextMenuItems({ - attacksWithTimelineAlerts: [{ attackId: 'attack-1', relatedAlertIds: ['alert-1'] }], - }) - ); - - expect(mockUseBulkAttackInvestigateInTimelineItems).toHaveBeenCalledWith({ - closePopover: undefined, - }); - }); - - it('should pass closePopover to useBulkAttackInvestigateInTimelineItems', () => { + it('should call `closePopover` on click', () => { const closePopover = jest.fn(); - - renderHook(() => + const { result } = renderHook(() => useAttackInvestigateInTimelineContextMenuItems({ - attacksWithTimelineAlerts: [{ attackId: 'attack-1', relatedAlertIds: ['alert-1'] }], + attack: mockAttack, closePopover, }) ); - expect(mockUseBulkAttackInvestigateInTimelineItems).toHaveBeenCalledWith({ - closePopover, - }); + result.current.items[0]?.onClick?.({} as React.MouseEvent); + expect(closePopover).toHaveBeenCalledTimes(1); }); - it('should return empty panels', () => { + it('should call `investigateInTimeline` on click', () => { + const investigateInTimeline = jest.fn(); + mockUseInvestigateInTimeline.mockReturnValue({ + investigateInTimeline, + } as unknown as ReturnType); const { result } = renderHook(() => useAttackInvestigateInTimelineContextMenuItems({ - attacksWithTimelineAlerts: [{ attackId: 'attack-1', relatedAlertIds: ['alert-1'] }], + attack: mockAttack, }) ); - expect(result.current.panels).toEqual([]); + result.current.items[0]?.onClick?.({} as React.MouseEvent); + expect(investigateInTimeline).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.tsx index 115dd37e92d8d..4659ae589627f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_investigate_in_timeline_context_menu_items.tsx @@ -5,53 +5,67 @@ * 2.0. */ -import { useMemo } from 'react'; -import { useBulkAttackInvestigateInTimelineItems } from '../bulk_action_items/use_bulk_attack_investigate_in_timeline_items'; -import { ALERT_ATTACK_DISCOVERY_ALERT_IDS } from '../constants'; -import { transformBulkActionsToContextMenuItems } from '../utils/transform_bulk_actions_to_context_menu_items'; -import type { - AttackWithTimelineAlerts, - BaseAttackContextMenuItemsProps, - BulkAttackContextMenuItems, -} from '../types'; - -export interface UseAttackInvestigateInTimelineContextMenuItemsProps - extends BaseAttackContextMenuItemsProps { - /** Array of attacks with alert ids used to investigate in Timeline */ - attacksWithTimelineAlerts: AttackWithTimelineAlerts[]; +import { useCallback, useMemo } from 'react'; +import type { AttackDiscoveryAlert } from '@kbn/elastic-assistant-common'; +import type { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { useInvestigateInTimeline } from '../../../../../common/hooks/timeline/use_investigate_in_timeline'; +import { buildAlertsKqlFilter } from '../../../../components/alerts_table/actions'; +import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../components/alerts_table/translations'; + +export interface UseAttackInvestigateInTimelineContextMenuItemsProps { + /** + * The attack discovery object + */ + attack: AttackDiscoveryAlert; + /** + * Optional callback to close the containing popover menu + */ + closePopover?: () => void; } export const useAttackInvestigateInTimelineContextMenuItems = ({ - attacksWithTimelineAlerts, + attack, closePopover, - clearSelection, - setIsLoading, - refresh, -}: UseAttackInvestigateInTimelineContextMenuItemsProps): BulkAttackContextMenuItems => { - const bulkActionItems = useBulkAttackInvestigateInTimelineItems({ closePopover }); +}: UseAttackInvestigateInTimelineContextMenuItemsProps): { + items: EuiContextMenuPanelItemDescriptorEntry[]; +} => { + const { investigateInTimeline } = useInvestigateInTimeline(); + const { + timelinePrivileges: { read: canUseTimeline }, + } = useUserPrivileges(); - const alertItems = useMemo( - () => - attacksWithTimelineAlerts.map((attack) => ({ - _id: attack.attackId, - data: [{ field: ALERT_ATTACK_DISCOVERY_ALERT_IDS, value: attack.relatedAlertIds }], - ecs: { _id: attack.attackId }, - })), - [attacksWithTimelineAlerts] + const originalAlertIds = useMemo( + () => attack.alertIds.map((id) => attack.replacements?.[id] ?? id), + [attack.alertIds, attack.replacements] ); - const contextMenuItems = useMemo( + const attackAlertIdFilters = useMemo( + () => buildAlertsKqlFilter('_id', originalAlertIds), + [originalAlertIds] + ); + + const onInvestigateInTimelineClick = useCallback(() => { + investigateInTimeline({ + filters: attackAlertIdFilters, + }); + closePopover?.(); + }, [attackAlertIdFilters, closePopover, investigateInTimeline]); + + const items = useMemo( () => - transformBulkActionsToContextMenuItems({ - bulkActionItems, - alertItems, - closePopover, - clearSelection, - setIsLoading, - refresh, - }), - [bulkActionItems, alertItems, closePopover, clearSelection, setIsLoading, refresh] + canUseTimeline + ? [ + { + name: ACTION_INVESTIGATE_IN_TIMELINE, + key: 'attack-investigate-in-timeline-action-item', + 'data-test-subj': 'attack-investigate-in-timeline-action-item', + onClick: onInvestigateInTimelineClick, + }, + ] + : [], + [canUseTimeline, onInvestigateInTimelineClick] ); - return contextMenuItems; + return { items }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx deleted file mode 100644 index 8d02c9893f366..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.test.tsx +++ /dev/null @@ -1,267 +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 { renderHook } from '@testing-library/react'; -import { useAssistantContext } from '@kbn/elastic-assistant'; -import { getAttackDiscoveryMarkdown } from '@kbn/elastic-assistant-common'; -import { getMockAttackDiscoveryAlerts } from '../../../../../attack_discovery/pages/mock/mock_attack_discovery_alerts'; -import { useAttackDiscoveryAttachment } from '../../../../../attack_discovery/pages/results/use_attack_discovery_attachment'; -import { useAgentBuilderAvailability } from '../../../../../agent_builder/hooks/use_agent_builder_availability'; -import { useReportAddToChat } from '../../../../../agent_builder/hooks/use_report_add_to_chat'; -import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; -import { useAttackViewInAiAssistantContextMenuItems } from './use_attack_view_in_ai_assistant_context_menu_items'; - -jest.mock('@kbn/elastic-assistant'); -jest.mock('@kbn/elastic-assistant-common', () => { - const actual = jest.requireActual('@kbn/elastic-assistant-common'); - - return { - ...actual, - getAttackDiscoveryMarkdown: jest.fn(), - }; -}); -jest.mock('../../../../../attack_discovery/pages/results/use_attack_discovery_attachment'); -jest.mock('../../../../../agent_builder/hooks/use_agent_builder_availability'); -jest.mock('../../../../../agent_builder/hooks/use_report_add_to_chat'); -jest.mock('../../../../../assistant/use_assistant_availability'); - -const mockUseAttackDiscoveryAttachment = useAttackDiscoveryAttachment as jest.MockedFunction< - typeof useAttackDiscoveryAttachment ->; -const mockUseAgentBuilderAvailability = useAgentBuilderAvailability as jest.MockedFunction< - typeof useAgentBuilderAvailability ->; -const mockUseReportAddToChat = useReportAddToChat as jest.MockedFunction; -const mockUseAssistantAvailability = useAssistantAvailability as jest.MockedFunction< - typeof useAssistantAvailability ->; -const mockUseAssistantContext = useAssistantContext as jest.MockedFunction< - typeof useAssistantContext ->; -const mockGetAttackDiscoveryMarkdown = getAttackDiscoveryMarkdown as jest.MockedFunction< - typeof getAttackDiscoveryMarkdown ->; - -const mockAttack = getMockAttackDiscoveryAlerts()[0]; -const mockRegisterPromptContext = jest.fn(); -const mockUnRegisterPromptContext = jest.fn(); -const mockShowAssistantOverlay = jest.fn(); - -describe('useAttackViewInAiAssistantContextMenuItems', () => { - beforeEach(() => { - jest.clearAllMocks(); - - mockUseAttackDiscoveryAttachment.mockReturnValue(jest.fn()); - mockUseAgentBuilderAvailability.mockReturnValue({ - hasAgentBuilderPrivilege: false, - isAgentChatExperienceEnabled: false, - hasValidAgentBuilderLicense: true, - isAgentBuilderEnabled: false, - }); - mockUseReportAddToChat.mockReturnValue(jest.fn()); - mockUseAssistantAvailability.mockReturnValue({ - hasAssistantPrivilege: true, - hasConnectorsAllPrivilege: true, - hasConnectorsReadPrivilege: true, - hasManageGlobalKnowledgeBase: true, - hasSearchAILakeConfigurations: true, - hasUpdateAIAssistantAnonymization: true, - isAssistantEnabled: true, - isAssistantVisible: true, - }); - mockUseAssistantContext.mockReturnValue({ - registerPromptContext: mockRegisterPromptContext, - unRegisterPromptContext: mockUnRegisterPromptContext, - showAssistantOverlay: mockShowAssistantOverlay, - } as unknown as ReturnType); - mockGetAttackDiscoveryMarkdown.mockReturnValue('test-markdown'); - }); - - it('should return "View in AI Assistant" when Agent Builder chat experience is disabled', () => { - const { result } = renderHook(() => - useAttackViewInAiAssistantContextMenuItems({ - attack: mockAttack, - }) - ); - - expect(result.current.items[0]?.name).toBe('View in AI Assistant'); - expect(result.current.items[0]?.key).toBe('viewInAiAssistant'); - expect(result.current.items[0]?.['data-test-subj']).toBe('viewInAiAssistant'); - }); - - it('should call closePopover and showAssistantOverlay on "View in AI Assistant" click', () => { - const closePopover = jest.fn(); - - const { result } = renderHook(() => - useAttackViewInAiAssistantContextMenuItems({ - attack: mockAttack, - closePopover, - }) - ); - - result.current.items[0]?.onClick?.({} as React.MouseEvent); - - expect(closePopover).toHaveBeenCalledTimes(1); - expect(mockUnRegisterPromptContext).toHaveBeenCalledWith(mockAttack.id); - expect(mockRegisterPromptContext).toHaveBeenCalledTimes(1); - expect(mockShowAssistantOverlay).toHaveBeenCalledWith({ - showOverlay: true, - promptContextId: mockAttack.id, - selectedConversation: { title: `${mockAttack.title} - ${mockAttack.id.slice(-5)}` }, - }); - expect(mockShowAssistantOverlay.mock.invocationCallOrder[0]).toBeLessThan( - closePopover.mock.invocationCallOrder[0] - ); - expect(mockUnRegisterPromptContext.mock.invocationCallOrder[0]).toBeLessThan( - mockRegisterPromptContext.mock.invocationCallOrder[0] - ); - expect(mockRegisterPromptContext.mock.invocationCallOrder[0]).toBeLessThan( - mockShowAssistantOverlay.mock.invocationCallOrder[0] - ); - }); - - it('should return "Add to chat" when Agent Builder chat experience is enabled and user has privilege', () => { - mockUseAgentBuilderAvailability.mockReturnValue({ - hasAgentBuilderPrivilege: true, - isAgentChatExperienceEnabled: true, - hasValidAgentBuilderLicense: true, - isAgentBuilderEnabled: true, - }); - - const { result } = renderHook(() => - useAttackViewInAiAssistantContextMenuItems({ - attack: mockAttack, - }) - ); - - expect(result.current.items).toMatchInlineSnapshot(` - Array [ - Object { - "data-test-subj": "viewInAgentBuilder", - "disabled": false, - "key": "viewInAgentBuilder", - "name": "Add to chat", - "onClick": [Function], - }, - ] - `); - }); - - it('should disable "Add to chat" when license is invalid', () => { - mockUseAgentBuilderAvailability.mockReturnValue({ - hasAgentBuilderPrivilege: true, - isAgentChatExperienceEnabled: true, - hasValidAgentBuilderLicense: false, - isAgentBuilderEnabled: true, - }); - - const { result } = renderHook(() => - useAttackViewInAiAssistantContextMenuItems({ - attack: mockAttack, - }) - ); - - expect(result.current.items[0]?.disabled).toBe(true); - }); - - it('should return empty items when Agent Builder chat experience is enabled and user has no privilege', () => { - mockUseAgentBuilderAvailability.mockReturnValue({ - hasAgentBuilderPrivilege: false, - isAgentChatExperienceEnabled: true, - hasValidAgentBuilderLicense: true, - isAgentBuilderEnabled: false, - }); - - const { result } = renderHook(() => - useAttackViewInAiAssistantContextMenuItems({ - attack: mockAttack, - }) - ); - - expect(result.current.items).toEqual([]); - }); - - it('should call closePopover, reportAddToChat and openAgentBuilderFlyout on "Add to chat" click', () => { - const closePopover = jest.fn(); - const openAgentBuilderFlyout = jest.fn(); - const reportAddToChatClick = jest.fn(); - - mockUseAttackDiscoveryAttachment.mockReturnValue(openAgentBuilderFlyout); - mockUseReportAddToChat.mockReturnValue(reportAddToChatClick); - mockUseAgentBuilderAvailability.mockReturnValue({ - hasAgentBuilderPrivilege: true, - isAgentChatExperienceEnabled: true, - hasValidAgentBuilderLicense: true, - isAgentBuilderEnabled: true, - }); - - const { result } = renderHook(() => - useAttackViewInAiAssistantContextMenuItems({ - attack: mockAttack, - closePopover, - }) - ); - - result.current.items[0]?.onClick?.({} as React.MouseEvent); - - expect(closePopover).toHaveBeenCalledTimes(1); - expect(reportAddToChatClick).toHaveBeenCalledWith({ - pathway: 'attack_discovery_take_action', - attachments: ['alert'], - }); - expect(openAgentBuilderFlyout).toHaveBeenCalledTimes(1); - expect(reportAddToChatClick.mock.invocationCallOrder[0]).toBeLessThan( - closePopover.mock.invocationCallOrder[0] - ); - }); - - it('should disable "View in AI Assistant" when user has no assistant privilege', () => { - mockUseAssistantAvailability.mockReturnValue({ - hasAssistantPrivilege: false, - hasConnectorsAllPrivilege: true, - hasConnectorsReadPrivilege: true, - hasManageGlobalKnowledgeBase: true, - hasSearchAILakeConfigurations: true, - hasUpdateAIAssistantAnonymization: true, - isAssistantEnabled: true, - isAssistantVisible: true, - }); - - const { result } = renderHook(() => - useAttackViewInAiAssistantContextMenuItems({ - attack: mockAttack, - }) - ); - - expect(result.current.items[0]?.disabled).toBe(true); - }); - - it('registers prompt context with markdown getter and replacements', async () => { - const { result } = renderHook(() => - useAttackViewInAiAssistantContextMenuItems({ - attack: mockAttack, - }) - ); - - result.current.items[0]?.onClick?.({} as React.MouseEvent); - - const registeredContext = mockRegisterPromptContext.mock.calls[0][0]; - const promptContext = await registeredContext.getPromptContext(); - - expect(registeredContext).toMatchObject({ - id: mockAttack.id, - category: 'insight', - description: mockAttack.title, - replacements: mockAttack.replacements, - tooltip: null, - }); - expect(promptContext).toBe('test-markdown'); - expect(mockGetAttackDiscoveryMarkdown).toHaveBeenCalledWith({ - attackDiscovery: mockAttack, - }); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx deleted file mode 100644 index 131bacc5047b6..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/context_menu_items/use_attack_view_in_ai_assistant_context_menu_items.tsx +++ /dev/null @@ -1,138 +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 { useCallback, useMemo } from 'react'; -import { useAssistantContext } from '@kbn/elastic-assistant'; -import { - getAttackDiscoveryMarkdown, - type AttackDiscoveryAlert, -} from '@kbn/elastic-assistant-common'; -import type { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; -import { useReportAddToChat } from '../../../../../agent_builder/hooks/use_report_add_to_chat'; -import { useAgentBuilderAvailability } from '../../../../../agent_builder/hooks/use_agent_builder_availability'; -import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; -import { useAttackDiscoveryAttachment } from '../../../../../attack_discovery/pages/results/use_attack_discovery_attachment'; -import * as i18n from '../../../../../attack_discovery/pages/results/take_action/translations'; - -export interface UseAttackViewInAiAssistantContextMenuItemsProps { - /** - * The attack discovery object - */ - attack: AttackDiscoveryAlert; - /** - * Optional callback to close the containing popover menu - */ - closePopover?: () => void; -} - -export const useAttackViewInAiAssistantContextMenuItems = ({ - attack, - closePopover, -}: UseAttackViewInAiAssistantContextMenuItemsProps): { - items: EuiContextMenuPanelItemDescriptorEntry[]; -} => { - const { hasAssistantPrivilege } = useAssistantAvailability(); - const { registerPromptContext, showAssistantOverlay, unRegisterPromptContext } = - useAssistantContext(); - - const promptContextId = attack.id ?? null; - const viewInAiAssistantDisabled = !hasAssistantPrivilege || promptContextId == null; - - const onViewInAiAssistant = useCallback(() => { - if (promptContextId == null) { - return; - } - - const lastFive = attack.id ? ` - ${attack.id.slice(-5)}` : ''; - const conversationTitle = `${attack.title ?? ''}${lastFive}`; - - unRegisterPromptContext(promptContextId); - registerPromptContext({ - category: 'insight', - description: attack.title ?? '', - getPromptContext: async () => - getAttackDiscoveryMarkdown({ - attackDiscovery: attack, - // note: we do NOT want to replace the replacements here - }), - id: promptContextId, - replacements: attack.replacements, - tooltip: null, - }); - - showAssistantOverlay({ - showOverlay: true, - promptContextId, - selectedConversation: { title: conversationTitle }, - }); - }, [ - attack, - promptContextId, - registerPromptContext, - showAssistantOverlay, - unRegisterPromptContext, - ]); - - const { hasAgentBuilderPrivilege, isAgentChatExperienceEnabled, hasValidAgentBuilderLicense } = - useAgentBuilderAvailability(); - const openAgentBuilderFlyout = useAttackDiscoveryAttachment(attack, attack.replacements); - const reportAddToChatClick = useReportAddToChat(); - - const onViewInAgentBuilder = useCallback(() => { - reportAddToChatClick({ - pathway: 'attack_discovery_take_action', - attachments: ['alert'], - }); - openAgentBuilderFlyout(); - }, [openAgentBuilderFlyout, reportAddToChatClick]); - - const isAddToChatDisabled = !hasValidAgentBuilderLicense; - - const items = useMemo(() => { - if (isAgentChatExperienceEnabled) { - if (!hasAgentBuilderPrivilege) { - return []; - } - - return [ - { - name: i18n.ADD_TO_CHAT, - key: 'viewInAgentBuilder', - 'data-test-subj': 'viewInAgentBuilder', - disabled: isAddToChatDisabled, - onClick: () => { - onViewInAgentBuilder(); - closePopover?.(); - }, - }, - ]; - } - - return [ - { - name: i18n.VIEW_IN_AI_ASSISTANT, - key: 'viewInAiAssistant', - 'data-test-subj': 'viewInAiAssistant', - disabled: viewInAiAssistantDisabled, - onClick: () => { - onViewInAiAssistant(); - closePopover?.(); - }, - }, - ]; - }, [ - closePopover, - hasAgentBuilderPrivilege, - isAddToChatDisabled, - isAgentChatExperienceEnabled, - onViewInAgentBuilder, - onViewInAiAssistant, - viewInAiAssistantDisabled, - ]); - - return { items }; -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/translations.ts index dfa402b2afc4f..e70c0a2281661 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/translations.ts @@ -101,20 +101,6 @@ export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( } ); -export const ADD_TO_NEW_CASE = i18n.translate( - 'xpack.securitySolution.visualizationActions.addToNewCase', - { - defaultMessage: 'Add to new case', - } -); - -export const ADD_TO_EXISTING_CASE = i18n.translate( - 'xpack.securitySolution.visualizationActions.addToExistingCase', - { - defaultMessage: 'Add to existing case', - } -); - export const ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE = i18n.translate( 'xpack.securitySolution.detections.hooks.attacks.bulkActions.alertTagsContextMenuItemTitle', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/types.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/types.ts index 03666dd8da328..0ded66a5e9037 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/types.ts @@ -80,20 +80,6 @@ export interface AttackWithTags extends BaseAttackProps { tags?: string[]; } -/** - * Represents an attack with markdown used for case attachments. - * The related alert IDs are used to attach alerts to a case. - */ -export interface AttackWithCase extends BaseAttackProps { - /** Markdown comment describing the attack */ - markdownComment: string; -} - -/** - * Represents an attack with alert IDs that should be investigated in Timeline. - */ -export type AttackWithTimelineAlerts = BaseAttackProps; - /** * Extended content panel configuration for attack bulk actions. * Adds optional width property to support custom panel sizing (e.g., for assignees panel). diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_cell_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_cell_actions.tsx index 2d49ee1c27e56..2215a9119fe54 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_cell_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_cell_actions.tsx @@ -9,12 +9,11 @@ import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; import { useCallback, useMemo } from 'react'; import { TableId } from '@kbn/securitysolution-data-table'; import type { RenderContext } from '@kbn/response-ops-alerts-table/types'; -import { SECURITY_CELL_ACTIONS_DEFAULT } from '@kbn/ui-actions-plugin/common/trigger_ids'; import { PageScope } from '../../../data_view_manager/constants'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import type { UseDataGridColumnsSecurityCellActionsProps } from '../../../common/components/cell_actions'; import { useDataGridColumnsSecurityCellActions } from '../../../common/components/cell_actions'; -import { SecurityCellActionType } from '../../../app/actions/constants'; +import { SecurityCellActionsTrigger, SecurityCellActionType } from '../../../app/actions/constants'; import { useGetFieldSpec } from '../../../common/hooks/use_get_field_spec'; import { useDataViewId } from '../../../common/hooks/use_data_view_id'; import type { @@ -104,7 +103,7 @@ export const useCellActionsOptions = ( tableId === TableId.alertsOnCasePage ? [SecurityCellActionType.FILTER] : undefined; const cellActions = useDataGridColumnsSecurityCellActions({ - triggerId: SECURITY_CELL_ACTIONS_DEFAULT, + triggerId: SecurityCellActionsTrigger.DEFAULT, fields: cellActionsFields, getCellValue, metadata: cellActionsMetadata, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.test.tsx index af9ff1a4a72f4..df9d101a3b23e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.test.tsx @@ -57,7 +57,7 @@ describe('AlertDetailsRedirect', () => { timerange: "(global:(linkTo:!(timeline,socTrends),timerange:(from:'2023-04-20T12:00:00.000Z',kind:absolute,to:'2023-04-20T12:05:00.000Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))", pageFilters: - '!((display_settings:(hide_action_bar:!f),exclude:!f,exists_selected:!f,field_name:kibana.alert.workflow_status,selected_options:!(),title:Status))', + '!((displaySettings:(hideActionBar:!f),exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,selectedOptions:!(),title:Status))', flyout: '(preview:!(),right:(id:document-details-right,params:(id:test-alert-id,indexName:.someTestIndex,scopeId:alerts-page)))', }); @@ -96,7 +96,7 @@ describe('AlertDetailsRedirect', () => { timerange: "(global:(linkTo:!(timeline,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',kind:absolute,to:'2020-07-08T08:25:18.966Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))", pageFilters: - '!((display_settings:(hide_action_bar:!f),exclude:!f,exists_selected:!f,field_name:kibana.alert.workflow_status,selected_options:!(),title:Status))', + '!((displaySettings:(hideActionBar:!f),exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,selectedOptions:!(),title:Status))', flyout: '(preview:!(),right:(id:document-details-right,params:(id:test-alert-id,indexName:.someTestIndex,scopeId:alerts-page)))', }); @@ -134,7 +134,7 @@ describe('AlertDetailsRedirect', () => { timerange: "(global:(linkTo:!(timeline,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',kind:absolute,to:'2020-07-08T08:25:18.966Z')),timeline:(linkTo:!(global,socTrends),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now/d,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now/d)))", pageFilters: - '!((display_settings:(hide_action_bar:!f),exclude:!f,exists_selected:!f,field_name:kibana.alert.workflow_status,selected_options:!(),title:Status))', + '!((displaySettings:(hideActionBar:!f),exclude:!f,existsSelected:!f,fieldName:kibana.alert.workflow_status,selectedOptions:!(),title:Status))', flyout: '(preview:!(),right:(id:document-details-right,params:(id:test-alert-id,indexName:.internal.alerts-security.alerts-default,scopeId:alerts-page)))', }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx index 51eb8bec06305..b8dd2a4cdcdb6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/pages/alerts/alert_details_redirect.tsx @@ -58,10 +58,10 @@ export const AlertDetailsRedirect = () => { const kqlAppQuery = encode({ language: 'kuery', query: `_id: ${alertId}` }); const statusPageFilter: FilterControlConfig = { - field_name: ALERT_WORKFLOW_STATUS, + fieldName: ALERT_WORKFLOW_STATUS, title: 'Status', - selected_options: [], - exists_selected: false, + selectedOptions: [], + existsSelected: false, }; const pageFiltersQuery = encode(formatPageFilterSearchParam([statusPageFilter])); diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/alert.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/alert.ts index 079c955319faf..7b2c0629268b3 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/alert.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/alert.ts @@ -72,20 +72,73 @@ export const createAlertAttachmentType = (): AttachmentTypeDefinition => { platformCoreTools.productDocumentation, ], getAgentDescription: () => { - const description = `You have access to security alert data. To provide a comprehensive analysis, you MUST gather enriched context by querying for related information. + return `A security alert is attached to this conversation. The alert data is included in the XML element within the user's message. -SECURITY ALERT DATA: -{alertData} +**How to access the alert data:** +The alert JSON is in the attachment content above. Parse it to extract: +- Alert ID: \`_id\` field +- Rule name: \`kibana.alert.rule.name\` +- Entities: \`host.name\`, \`user.name\`, \`service.name\` +- MITRE ATT&CK: \`kibana.alert.rule.threat.tactic.id\`, \`kibana.alert.rule.threat.technique.id\` ---- -Complete in order: +**Required investigation workflow:** +1. Parse the alert data from the attachment +2. Use the available tools to gather enriched context: + - Entity risk scores for hosts/users + - Attack discoveries that include this alert + - Related security cases + - Security Labs articles for the MITRE techniques +3. Execute ES|QL queries and osquery live queries to investigate +4. Provide a comprehensive analysis based on your findings`; + }, + + // Skills to reference when this attachment is present + skills: [ + 'security.alert_triage', + 'security.detection_rules', + 'security.cases', + 'security.get_alerts', + ], + + // LLM guidance for security alert investigation + skillContent: `# Security Alert Investigation + +A security alert is attached to this conversation. Follow this investigation workflow: + +## Investigation Steps +1. **Triage**: Determine alert severity and potential impact +2. **Context Gathering**: + - Check entity risk scores for involved hosts/users + - Look for related alerts in the same timeframe + - Review the detection rule that triggered this alert +3. **Threat Intel**: Search Elastic Security Labs for related threat information +4. **Analysis**: Correlate findings and determine if this is a true positive +5. **Response**: Recommend appropriate actions based on findings + +## Available Security Skills +- **alert_triage**: Analyze alerts and determine severity +- **detection_rules**: Review and understand the triggering rule +- **cases**: Add to a case for tracking if confirmed +- **get_alerts**: Query for related alerts + +## MITRE ATT&CK +If the alert includes MITRE ATT&CK information: +- Review the tactics and techniques +- Understand the attack chain +- Check for indicators of related activity`, -1. Extract alert id(s): _id -2. Extract rule name: kibana.alert.rule.name -3. Extract entities: host.name, user.name, service.name -4. Extract MITRE fields: kibana.alert.rule.threat.tactic.id, kibana.alert.rule.threat.technique.id, threat.tactic.id -5. Use the available tools to gather context about the alert and provide a response.`; - return description; + // Entity recognition patterns for auto-attachment + entityRecognition: { + patterns: [ + /alert\s+["']?([a-zA-Z0-9_-]+)["']?/i, + /investigate\s+alert\s+["']?([a-zA-Z0-9_-]+)["']?/i, + /security\s+alert\s+["']?([a-zA-Z0-9_-]+)["']?/i, + ], + extractId: (match) => match[1], + resolve: async (entityId, context) => { + // TODO: Implement resolution from alerts index + return null; + }, }, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/attack_discovery.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/attack_discovery.ts new file mode 100644 index 0000000000000..3b056bc22f4fe --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/attack_discovery.ts @@ -0,0 +1,205 @@ +/* + * 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'; +import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; +import type { Attachment } from '@kbn/agent-builder-common/attachments'; +import { platformCoreTools } from '@kbn/agent-builder-common'; +import { AttackDiscovery } from '@kbn/elastic-assistant-common'; +import { SecurityAgentBuilderAttachments } from '../../../common/constants'; +import { + SECURITY_ENTITY_RISK_SCORE_TOOL_ID, + SECURITY_ATTACK_DISCOVERY_SEARCH_TOOL_ID, + SECURITY_LABS_SEARCH_TOOL_ID, + SECURITY_ALERTS_TOOL_ID, +} from '../tools'; +import { securityAttachmentDataSchema } from './security_attachment_data_schema'; + +/** + * Schema for attack_discovery attachment data. + * Extends security attachment base with the AttackDiscovery type. + */ +export const attackDiscoveryAttachmentDataSchema = securityAttachmentDataSchema.extend({ + /** + * The attack discovery data. + */ + attackDiscovery: AttackDiscovery, +}); + +/** + * Data for an attack_discovery attachment. + */ +export type AttackDiscoveryAttachmentData = z.infer; + +/** + * Type guard to narrow attachment data to AttackDiscoveryAttachmentData. + */ +const isAttackDiscoveryAttachmentData = (data: unknown): data is AttackDiscoveryAttachmentData => { + return attackDiscoveryAttachmentDataSchema.safeParse(data).success; +}; + +/** + * Creates the definition for the `attack_discovery` attachment type. + * + * This attachment type is used to display attack discoveries with: + * - MITRE ATT&CK tactics chain visualization + * - Related alerts summary + * - Entity summary (hosts, users affected) + * - Actions: add to case, investigate alerts + */ +export const createAttackDiscoveryAttachmentType = (): AttachmentTypeDefinition => { + return { + id: SecurityAgentBuilderAttachments.attackDiscovery, + + validate: (input) => { + const parseResult = attackDiscoveryAttachmentDataSchema.safeParse(input); + if (parseResult.success) { + return { valid: true, data: parseResult.data }; + } else { + return { valid: false, error: parseResult.error.message }; + } + }, + + format: (attachment: Attachment) => { + const data = attachment.data; + if (!isAttackDiscoveryAttachmentData(data)) { + throw new Error(`Invalid attack_discovery attachment data for attachment ${attachment.id}`); + } + return { + getRepresentation: () => { + return { type: 'text', value: formatAttackDiscoveryData(data) }; + }, + }; + }, + + getTools: () => [ + SECURITY_ENTITY_RISK_SCORE_TOOL_ID, + SECURITY_ATTACK_DISCOVERY_SEARCH_TOOL_ID, + SECURITY_LABS_SEARCH_TOOL_ID, + SECURITY_ALERTS_TOOL_ID, + platformCoreTools.cases, + platformCoreTools.generateEsql, + platformCoreTools.productDocumentation, + ], + + getAgentDescription: () => { + return `An attack discovery is attached to this conversation. The attack discovery data is included in the XML element within the user's message. + +**How to access the attack discovery data:** +The attack discovery JSON is in the attachment content above. Parse it to extract: +- Attack title and summary +- MITRE ATT&CK tactics identified +- Related alert IDs +- Entity summary (affected hosts and users) + +**Investigation steps:** +1. Review the attack summary and MITRE ATT&CK tactics +2. Examine the entity summary to identify affected hosts and users +3. Use the alerts tool to investigate related alerts +4. Check entity risk scores for affected entities +5. Search Elastic Security Labs for related threat intelligence +6. If this is a confirmed threat, consider adding to a case for tracking`; + }, + + // Skills to reference when this attachment is present + skills: ['security.alerts', 'security.cases', 'security.detection_rules'], + + // LLM guidance for handling attack discoveries + skillContent: `# Attack Discovery Investigation + +An attack discovery is attached showing a potential attack chain detected from security alerts. + +## Key Information +- **Title**: A descriptive name for the identified attack +- **Summary**: Overview of what the attack discovery found +- **MITRE ATT&CK Tactics**: The attack techniques identified +- **Related Alerts**: Alert IDs that contributed to this discovery +- **Entity Summary**: Hosts and users involved + +## Investigation Workflow +1. **Triage**: Review the summary and MITRE tactics to understand the attack type +2. **Scope**: Check entity summary to see which hosts/users are affected +3. **Deep Dive**: Investigate individual alerts for more context +4. **Risk Assessment**: Check risk scores for affected entities +5. **Research**: Search Elastic Security Labs for related threat intel +6. **Action**: If confirmed, add to a case for tracking and response + +## Available Actions +- Query related alerts using the alerts tool +- Check entity risk scores +- Add to a security case for tracking +- Search for related threat intelligence`, + + // React component for visual preview (lazy-loaded) + component: { + render: () => + import('../public/components/attack_discovery_viewer').then( + (m) => m.AttackDiscoveryViewer + ), + displayMode: 'expanded', + minHeight: 400, + }, + + // Entity recognition patterns for auto-attachment + entityRecognition: { + patterns: [ + /attack\s+discovery\s+["']?([a-zA-Z0-9-]+)["']?/i, + /investigate\s+attack\s+["']?([a-zA-Z0-9-]+)["']?/i, + /attack\s+["']?([a-zA-Z0-9-]+)["']?/i, + ], + extractId: (match) => match[1], + resolve: async (entityId, context) => { + // TODO: Implement resolution from Elasticsearch + // This would query the attack discovery index by ID + return null; + }, + }, + }; +}; + +/** + * Formats attack discovery data for LLM representation. + */ +const formatAttackDiscoveryData = (data: AttackDiscoveryAttachmentData): string => { + const { attackDiscovery } = data; + const parts: string[] = []; + + parts.push(`## Attack Discovery: ${attackDiscovery.title}`); + parts.push(''); + + if (attackDiscovery.summaryMarkdown) { + parts.push('### Summary'); + parts.push(attackDiscovery.summaryMarkdown); + parts.push(''); + } + + if (attackDiscovery.mitreAttackTactics && attackDiscovery.mitreAttackTactics.length > 0) { + parts.push('### MITRE ATT&CK Tactics'); + parts.push(attackDiscovery.mitreAttackTactics.join(', ')); + parts.push(''); + } + + if (attackDiscovery.entitySummaryMarkdown) { + parts.push('### Entity Summary'); + parts.push(attackDiscovery.entitySummaryMarkdown); + parts.push(''); + } + + if (attackDiscovery.alertIds && attackDiscovery.alertIds.length > 0) { + parts.push('### Related Alerts'); + parts.push(`${attackDiscovery.alertIds.length} alerts associated with this discovery`); + parts.push(`Alert IDs: ${attackDiscovery.alertIds.slice(0, 5).join(', ')}${attackDiscovery.alertIds.length > 5 ? '...' : ''}`); + parts.push(''); + } + + if (attackDiscovery.detailsMarkdown) { + parts.push('### Details'); + parts.push(attackDiscovery.detailsMarkdown); + } + + return parts.join('\n'); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/case.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/case.ts new file mode 100644 index 0000000000000..87a0fcbefc353 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/case.ts @@ -0,0 +1,240 @@ +/* + * 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'; +import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; +import type { Attachment } from '@kbn/agent-builder-common/attachments'; +import { platformCoreTools } from '@kbn/agent-builder-common'; +import { SecurityAgentBuilderAttachments } from '../../../common/constants'; +import { securityAttachmentDataSchema } from './security_attachment_data_schema'; +import { SECURITY_ALERTS_TOOL_ID } from '../tools'; + +/** + * Schema for case attachment data. + */ +export const caseAttachmentDataSchema = securityAttachmentDataSchema.extend({ + /** + * The case ID. + */ + caseId: z.string(), + /** + * The case title. + */ + title: z.string(), + /** + * The case description. + */ + description: z.string().optional(), + /** + * The case status. + */ + status: z.enum(['open', 'in-progress', 'closed']).optional(), + /** + * The case severity. + */ + severity: z.enum(['low', 'medium', 'high', 'critical']).optional(), + /** + * The case owner (application that created it). + */ + owner: z.string().optional(), + /** + * Tags associated with the case. + */ + tags: z.array(z.string()).optional(), + /** + * Number of comments on the case. + */ + totalComment: z.number().optional(), + /** + * Number of alerts attached to the case. + */ + totalAlerts: z.number().optional(), + /** + * Assignees. + */ + assignees: z.array(z.string()).optional(), + /** + * Created date. + */ + createdAt: z.string().optional(), + /** + * Last updated date. + */ + updatedAt: z.string().optional(), +}); + +/** + * Data for a case attachment. + */ +export type CaseAttachmentData = z.infer; + +/** + * Type guard to narrow attachment data to CaseAttachmentData. + */ +const isCaseAttachmentData = (data: unknown): data is CaseAttachmentData => { + return caseAttachmentDataSchema.safeParse(data).success; +}; + +/** + * Creates the definition for the `case` attachment type. + * + * This attachment type is used for security cases with capabilities to: + * - Add comments to the case + * - Update case status + * - View case details and related alerts + */ +export const createCaseAttachmentType = (): AttachmentTypeDefinition => { + return { + id: SecurityAgentBuilderAttachments.case, + validate: (input) => { + const parseResult = caseAttachmentDataSchema.safeParse(input); + if (parseResult.success) { + return { valid: true, data: parseResult.data }; + } else { + return { valid: false, error: parseResult.error.message }; + } + }, + format: (attachment: Attachment) => { + const data = attachment.data; + if (!isCaseAttachmentData(data)) { + throw new Error(`Invalid case attachment data for attachment ${attachment.id}`); + } + return { + getRepresentation: () => { + return { type: 'text', value: formatCaseData(data) }; + }, + }; + }, + getTools: () => [ + platformCoreTools.cases, + SECURITY_ALERTS_TOOL_ID, + platformCoreTools.productDocumentation, + ], + getAgentDescription: () => { + return `You have access to a security case for incident tracking and collaboration. + +CASE DATA: +{caseData} + +## Available Actions +1. Add comments to document findings +2. Update case status as investigation progresses +3. Review attached alerts and evidence +4. Coordinate with assignees`; + }, + + // Skills to reference when this attachment is present + skills: ['security.cases', 'security.alerts'], + + // LLM guidance for case operations + skillContent: `# Security Case Management + +A security case is attached to this conversation for incident tracking. + +## Case Lifecycle +1. **Open**: Initial state when a case is created +2. **In Progress**: Investigation is actively ongoing +3. **Closed**: Investigation is complete + +## Available Actions +- **Add Comment**: Document findings, analysis, or coordination notes +- **Update Status**: Change the case status as investigation progresses +- **Review Alerts**: Check alerts attached to this case +- **Assign**: Update case assignees + +## Best Practices +- Document all significant findings as comments +- Update status promptly when investigation state changes +- Link related alerts and evidence to the case +- Use tags for categorization and searchability +- Keep stakeholders informed through comments + +## Comment Guidelines +When adding comments: +- Be specific about what was investigated +- Include timestamps for activities +- Reference specific alerts or evidence +- Note any actions taken or recommended`, + + // Entity recognition patterns for auto-attachment + entityRecognition: { + patterns: [ + /case\s+["']?([a-zA-Z0-9_-]+)["']?/i, + /security\s+case\s+["']?([a-zA-Z0-9_-]+)["']?/i, + /incident\s+["']?([a-zA-Z0-9_-]+)["']?/i, + ], + extractId: (match) => match[1], + resolve: async (entityId, context) => { + // TODO: Implement resolution from cases API + return null; + }, + }, + }; +}; + +/** + * Formats case data for LLM representation. + */ +const formatCaseData = (data: CaseAttachmentData): string => { + const parts: string[] = []; + + parts.push(`## Case: ${data.title}`); + parts.push(`**Case ID**: ${data.caseId}`); + + if (data.status) { + const statusEmoji = { + open: '🔵', + 'in-progress': '🟡', + closed: '✅', + }; + parts.push(`**Status**: ${statusEmoji[data.status] || ''} ${data.status}`); + } + + if (data.severity) { + const severityEmoji = { + low: '🟢', + medium: '🟡', + high: '🟠', + critical: '🔴', + }; + parts.push(`**Severity**: ${severityEmoji[data.severity] || ''} ${data.severity}`); + } + + if (data.owner) { + parts.push(`**Owner**: ${data.owner}`); + } + + if (data.assignees && data.assignees.length > 0) { + parts.push(`**Assignees**: ${data.assignees.join(', ')}`); + } + + if (data.tags && data.tags.length > 0) { + parts.push(`**Tags**: ${data.tags.join(', ')}`); + } + + if (data.totalAlerts !== undefined) { + parts.push(`**Attached Alerts**: ${data.totalAlerts}`); + } + + if (data.totalComment !== undefined) { + parts.push(`**Comments**: ${data.totalComment}`); + } + + if (data.createdAt) { + parts.push(`**Created**: ${data.createdAt}`); + } + + if (data.updatedAt) { + parts.push(`**Last Updated**: ${data.updatedAt}`); + } + + if (data.description) { + parts.push(`\n**Description**:\n${data.description}`); + } + + return parts.join('\n'); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/register_attachments.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/register_attachments.ts index 4212868afc839..d895a06c37e5a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/register_attachments.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/register_attachments.ts @@ -9,6 +9,9 @@ import type { AgentBuilderPluginSetup } from '@kbn/agent-builder-plugin/server'; import { createRuleAttachmentType } from './rule'; import { createAlertAttachmentType } from './alert'; import { createEntityAttachmentType } from './entity'; +import { createAttackDiscoveryAttachmentType } from './attack_discovery'; +import { createCaseAttachmentType } from './case'; +import { createTimelineAttachmentType } from './timeline'; /** * Registers all security agent builder attachments with the agentBuilder plugin @@ -17,4 +20,7 @@ export const registerAttachments = async (agentBuilder: AgentBuilderPluginSetup) agentBuilder.attachments.registerType(createAlertAttachmentType()); agentBuilder.attachments.registerType(createEntityAttachmentType()); agentBuilder.attachments.registerType(createRuleAttachmentType()); + agentBuilder.attachments.registerType(createAttackDiscoveryAttachmentType()); + agentBuilder.attachments.registerType(createCaseAttachmentType()); + agentBuilder.attachments.registerType(createTimelineAttachmentType()); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/rule.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/rule.ts index 56873e11f774d..cf8ae9cf08e59 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/rule.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/rule.ts @@ -15,7 +15,38 @@ import { SECURITY_CREATE_DETECTION_RULE_TOOL_ID } from '../tools'; import { securityAttachmentDataSchema } from './security_attachment_data_schema'; export const ruleAttachmentDataSchema = securityAttachmentDataSchema.extend({ - text: z.string(), + /** + * Rule text representation (legacy format). + */ + text: z.string().optional(), + /** + * Rule ID. + */ + ruleId: z.string().optional(), + /** + * Rule name. + */ + ruleName: z.string().optional(), + /** + * Rule description. + */ + ruleDescription: z.string().optional(), + /** + * Whether the rule is enabled. + */ + enabled: z.boolean().optional(), + /** + * Rule severity. + */ + severity: z.string().optional(), + /** + * Rule query (KQL or EQL). + */ + query: z.string().optional(), + /** + * Rule type. + */ + ruleType: z.string().optional(), }); type RuleAttachmentData = z.infer; @@ -26,6 +57,15 @@ type RuleAttachmentData = z.infer; const isRuleAttachmentData = (data: unknown): data is RuleAttachmentData => { return ruleAttachmentDataSchema.safeParse(data).success; }; + +/** + * Creates the definition for the `rule` (detection_rule) attachment type. + * + * This attachment type is used for security detection rules with capabilities to: + * - Enable/disable the rule + * - Preview and add exceptions + * - View rule details and query + */ export const createRuleAttachmentType = (): AttachmentTypeDefinition => { return { id: SecurityAgentBuilderAttachments.rule, @@ -61,15 +101,109 @@ export const createRuleAttachmentType = (): AttachmentTypeDefinition => { If this is a migration rule, it includes both the old rule and the new rule. -{ruleData} +**How to access the rule data:** +The rule JSON is in the attachment content above. Parse it to extract: +- Rule ID and name +- Rule query (KQL/EQL) +- Severity and risk score +- MITRE ATT&CK mappings -1. Extract the query or topic from the rule attachment. -2. Use the appropriate tools to provide a response`; - return description; +**Investigation steps:** +1. Review the rule query and detection logic +2. Check if the rule is currently enabled +3. Review any existing exceptions +4. Analyze the rule's detection capabilities`; + }, + + // Skills to reference when this attachment is present + skills: ['security.detection_rules', 'security.exception_lists'], + + // LLM guidance for detection rule operations + skillContent: `# Detection Rule Operations + +A security detection rule is attached to this conversation. + +## Available Actions +- **Review**: Understand what the rule detects and how +- **Enable/Disable**: Change the rule's active status +- **Exceptions**: Preview or add exceptions to reduce false positives +- **Analyze Query**: Understand the rule's KQL/EQL query + +## Rule Analysis +When analyzing a rule: +1. Understand the detection logic (query syntax) +2. Review severity and risk score +3. Check for MITRE ATT&CK mappings +4. Identify potential false positive sources +5. Suggest exception patterns if needed + +## Exception Management +Before adding exceptions: +1. Confirm the pattern matches legitimate activity +2. Use preview to verify exception impact +3. Make exceptions specific to avoid over-suppression +4. Document the reason for the exception + +## Rule Tuning Tips +- Consider time-based patterns +- Look for specific user/host patterns +- Check for process/command line variations +- Review network indicators`, + + // Entity recognition patterns for auto-attachment + entityRecognition: { + patterns: [ + /rule\s+["']?([a-zA-Z0-9_-]+)["']?/i, + /detection\s+rule\s+["']?([a-zA-Z0-9_-]+)["']?/i, + /security\s+rule\s+["']?([a-zA-Z0-9_-]+)["']?/i, + ], + extractId: (match) => match[1], + resolve: async (entityId, context) => { + // TODO: Implement resolution from detection rules API + return null; + }, }, }; }; const formatRuleData = (data: RuleAttachmentData): string => { - return data.text; + // Support both legacy text format and new structured format + if (data.text) { + return data.text; + } + + const parts: string[] = []; + + if (data.ruleName) { + parts.push(`## Detection Rule: ${data.ruleName}`); + } + + if (data.ruleId) { + parts.push(`**Rule ID**: ${data.ruleId}`); + } + + if (data.enabled !== undefined) { + parts.push(`**Status**: ${data.enabled ? 'Enabled' : 'Disabled'}`); + } + + if (data.severity) { + parts.push(`**Severity**: ${data.severity}`); + } + + if (data.ruleType) { + parts.push(`**Type**: ${data.ruleType}`); + } + + if (data.ruleDescription) { + parts.push(`\n**Description**: ${data.ruleDescription}`); + } + + if (data.query) { + parts.push('\n**Query**:'); + parts.push('```'); + parts.push(data.query); + parts.push('```'); + } + + return parts.join('\n'); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/timeline.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/timeline.ts new file mode 100644 index 0000000000000..3c871d70afb75 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/attachments/timeline.ts @@ -0,0 +1,235 @@ +/* + * 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'; +import type { AttachmentTypeDefinition } from '@kbn/agent-builder-server/attachments'; +import type { Attachment } from '@kbn/agent-builder-common/attachments'; +import { platformCoreTools } from '@kbn/agent-builder-common'; +import { SecurityAgentBuilderAttachments } from '../../../common/constants'; +import { securityAttachmentDataSchema } from './security_attachment_data_schema'; +import { SECURITY_ALERTS_TOOL_ID } from '../tools'; + +/** + * Schema for timeline attachment data. + */ +export const timelineAttachmentDataSchema = securityAttachmentDataSchema.extend({ + /** + * The timeline ID. + */ + timelineId: z.string(), + /** + * The timeline title. + */ + title: z.string(), + /** + * The timeline description. + */ + description: z.string().optional(), + /** + * The timeline type. + */ + timelineType: z.enum(['default', 'template']).optional(), + /** + * Whether the timeline is a favorite. + */ + favorite: z.boolean().optional(), + /** + * The status of the timeline. + */ + status: z.enum(['active', 'draft', 'immutable']).optional(), + /** + * Number of events in the timeline. + */ + eventCount: z.number().optional(), + /** + * Number of pinned events. + */ + pinnedEventCount: z.number().optional(), + /** + * Timeline notes. + */ + notes: z.string().optional(), + /** + * Created date. + */ + createdAt: z.string().optional(), + /** + * Last updated date. + */ + updatedAt: z.string().optional(), + /** + * KQL query filter. + */ + kqlQuery: z.string().optional(), +}); + +/** + * Data for a timeline attachment. + */ +export type TimelineAttachmentData = z.infer; + +/** + * Type guard to narrow attachment data to TimelineAttachmentData. + */ +const isTimelineAttachmentData = (data: unknown): data is TimelineAttachmentData => { + return timelineAttachmentDataSchema.safeParse(data).success; +}; + +/** + * Creates the definition for the `timeline` attachment type. + * + * This attachment type is used for security timelines with capabilities to: + * - Add events to the timeline + * - Update notes + * - View timeline events and analysis + */ +export const createTimelineAttachmentType = (): AttachmentTypeDefinition => { + return { + id: SecurityAgentBuilderAttachments.timeline, + validate: (input) => { + const parseResult = timelineAttachmentDataSchema.safeParse(input); + if (parseResult.success) { + return { valid: true, data: parseResult.data }; + } else { + return { valid: false, error: parseResult.error.message }; + } + }, + format: (attachment: Attachment) => { + const data = attachment.data; + if (!isTimelineAttachmentData(data)) { + throw new Error(`Invalid timeline attachment data for attachment ${attachment.id}`); + } + return { + getRepresentation: () => { + return { type: 'text', value: formatTimelineData(data) }; + }, + }; + }, + getTools: () => [ + SECURITY_ALERTS_TOOL_ID, + platformCoreTools.generateEsql, + platformCoreTools.productDocumentation, + ], + getAgentDescription: () => { + return `You have access to a security timeline for threat investigation and analysis. + +TIMELINE DATA: +{timelineData} + +## Available Actions +1. Review events in the timeline +2. Add notes to document analysis +3. Pin important events +4. Analyze the attack chain`; + }, + + // Skills to reference when this attachment is present + skills: ['security.timelines', 'security.alerts'], + + // LLM guidance for timeline operations + skillContent: `# Security Timeline Investigation + +A security timeline is attached for threat analysis and investigation. + +## Timeline Purpose +Timelines are used to: +- Correlate events across time +- Build an attack narrative +- Document investigation findings +- Share analysis with team members + +## Investigation Workflow +1. **Review Events**: Examine the events collected in the timeline +2. **Identify Patterns**: Look for attack patterns and sequences +3. **Pin Key Events**: Mark important events for reference +4. **Add Notes**: Document findings and analysis +5. **Build Narrative**: Construct the attack story + +## Analysis Tips +- Look for temporal patterns (events clustered in time) +- Identify the attack chain (initial access → execution → persistence) +- Note affected entities (hosts, users, processes) +- Check for lateral movement indicators +- Document evidence for each stage + +## Notes Best Practices +- Be specific about what each event represents +- Link related events in your analysis +- Include timestamps and entity names +- Note any gaps or uncertainties +- Suggest follow-up investigation steps`, + + // Entity recognition patterns for auto-attachment + entityRecognition: { + patterns: [ + /timeline\s+["']?([a-zA-Z0-9_-]+)["']?/i, + /security\s+timeline\s+["']?([a-zA-Z0-9_-]+)["']?/i, + /investigation\s+timeline\s+["']?([a-zA-Z0-9_-]+)["']?/i, + ], + extractId: (match) => match[1], + resolve: async (entityId, context) => { + // TODO: Implement resolution from timelines API + return null; + }, + }, + }; +}; + +/** + * Formats timeline data for LLM representation. + */ +const formatTimelineData = (data: TimelineAttachmentData): string => { + const parts: string[] = []; + + parts.push(`## Timeline: ${data.title}`); + parts.push(`**Timeline ID**: ${data.timelineId}`); + + if (data.timelineType) { + parts.push(`**Type**: ${data.timelineType}`); + } + + if (data.status) { + parts.push(`**Status**: ${data.status}`); + } + + if (data.eventCount !== undefined) { + parts.push(`**Events**: ${data.eventCount}`); + } + + if (data.pinnedEventCount !== undefined) { + parts.push(`**Pinned Events**: ${data.pinnedEventCount}`); + } + + if (data.favorite) { + parts.push(`**Favorited**: Yes`); + } + + if (data.createdAt) { + parts.push(`**Created**: ${data.createdAt}`); + } + + if (data.updatedAt) { + parts.push(`**Last Updated**: ${data.updatedAt}`); + } + + if (data.description) { + parts.push(`\n**Description**:\n${data.description}`); + } + + if (data.kqlQuery) { + parts.push(`\n**Query Filter**:`); + parts.push('```'); + parts.push(data.kqlQuery); + parts.push('```'); + } + + if (data.notes) { + parts.push(`\n**Notes**:\n${data.notes}`); + } + + return parts.join('\n'); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/security_attack_discovery_skill.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/security_attack_discovery_skill.ts new file mode 100644 index 0000000000000..8ff00174caa03 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/skills/security_attack_discovery_skill.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 { defineSkillType } from '@kbn/agent-builder-server/skills/type_definition'; + +export const SECURITY_ATTACK_DISCOVERY_SKILL = defineSkillType({ + id: 'security.attack_discovery', + name: 'attack-discovery', + basePath: 'skills/security/alerts', + description: 'Search and summarize attack discovery results', + content: `# Security Attack Discovery + +## What this skill does +Helps you run/search Attack Discovery and produce a concise triage summary with recommended next steps. + +## When to use +- The user asks for "what looks suspicious?" across a time range. +- You need high-level narratives and pivot points for investigation. + +## Inputs to ask the user for +- Time range +- Environment/data source constraints (if available) + +## Safe workflow +1) Run a targeted search. +2) Summarize findings: key entities, tactics/techniques, timelines. +3) Provide pivots (queries/filters) rather than destructive actions. +`, + getAllowedTools: () => ['security.attack_discovery_search'], +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/attack_discovery_search_tool.test.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/attack_discovery_search_tool.test.ts index 075998e590552..c0a5c3be3d116 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/attack_discovery_search_tool.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/attack_discovery_search_tool.test.ts @@ -119,12 +119,14 @@ describe('attackDiscoverySearchTool', () => { createToolHandlerContext(mockRequest, mockEsClient, mockLogger) )) as ToolHandlerStandardReturn; - expect(result.results).toHaveLength(2); + // query + tabular results + a small "other" metadata envelope + expect(result.results).toHaveLength(3); expect(result.results[0].type).toBe(ToolResultType.query); const esqlResult = result.results[1] as EsqlResults; expect(esqlResult.type).toBe(ToolResultType.esqlResults); expect(esqlResult.data.columns).toEqual(mockEsqlResponse.columns); expect(esqlResult.data.values).toEqual(mockEsqlResponse.values); + expect(result.results[2].type).toBe(ToolResultType.other); }); it('limits results appropriately', async () => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/attack_discovery_search_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/attack_discovery_search_tool.ts index 67809c6eef114..b0cfc1a01f2b8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/attack_discovery_search_tool.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/attack_discovery_search_tool.ts @@ -59,9 +59,8 @@ export const attackDiscoverySearchTool = ( } catch (error) { return { status: 'unavailable', - reason: `Failed to check attack discovery index availability: ${ - error instanceof Error ? error.message : 'Unknown error' - }`, + reason: `Failed to check attack discovery index availability: ${error instanceof Error ? error.message : 'Unknown error' + }`, }; } }, @@ -114,6 +113,14 @@ export const attackDiscoverySearchTool = ( values: esqlResponse.values, }, }, + { + type: ToolResultType.other, + data: { + operation: 'search', + index: `.alerts-security.attack.discovery.alerts-${spaceId}*,.adhoc.alerts-security.attack.discovery.alerts-${spaceId}*`, + alertIds, + }, + }, ]; return { results }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/cases/attack_discovery_integration.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/cases/attack_discovery_integration.ts new file mode 100644 index 0000000000000..bc385a935c6a4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/cases/attack_discovery_integration.ts @@ -0,0 +1,316 @@ +/* + * 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, Logger, CoreStart } from '@kbn/core/server'; +import type { InferenceServerStart } from '@kbn/inference-plugin/server'; +import { v4 as uuidv4 } from 'uuid'; +import { GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR } from '@kbn/management-settings-ids'; + +import { generateAndUpdateAttackDiscoveries } from '@kbn/elastic-assistant-plugin/server/routes/attack_discovery/helpers/generate_and_update_discoveries'; +import type { + TriggerAttackDiscoveryFn, + AttackDiscoveryTriggerResult, + AttackDiscoveryAlertInfo, +} from '@kbn/cases-plugin/server/services/attack_discovery_integration'; +import { ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX } from '@kbn/elastic-assistant-common'; +import type { ElasticAssistantRequestHandlerContext } from '@kbn/elastic-assistant-plugin/server/types'; +import type { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas'; +import { transformESSearchToAnonymizationFields } from '@kbn/elastic-assistant-plugin/server/ai_assistant_data_clients/anonymization_fields/helpers'; +import type { EsAnonymizationFieldsSchema } from '@kbn/elastic-assistant-plugin/server/ai_assistant_data_clients/anonymization_fields/types'; + +/** + * Builds an Elasticsearch filter query for the given alert IDs + * Uses the 'ids' query which is the recommended way to query documents by their _id field + */ +function buildAlertIdsFilter(alertIds: string[]): any { + return { + ids: { + values: alertIds, + }, + }; +} + +/** + * Gets the default connector ID for attack discovery + */ +async function getDefaultConnectorId( + core: CoreStart, + inference: InferenceServerStart, + request: KibanaRequest, + logger: Logger +): Promise { + try { + const soClient = core.savedObjects.getScopedClient(request); + const uiSettingsClient = core.uiSettings.asScopedToClient(soClient); + + const defaultConnectorSetting = await uiSettingsClient.get( + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR + ); + + const NO_DEFAULT_CONNECTOR = 'NO_DEFAULT_CONNECTOR'; + const hasValidDefaultConnector = + defaultConnectorSetting && defaultConnectorSetting !== NO_DEFAULT_CONNECTOR; + + if (hasValidDefaultConnector) { + logger.debug(`Using default AI connector from UI setting: ${defaultConnectorSetting}`); + return defaultConnectorSetting; + } + + // Fallback to inference plugin default + const defaultConnector = await inference.getDefaultConnector(request); + if (defaultConnector?.connectorId) { + logger.debug(`Using default connector from inference plugin: ${defaultConnector.connectorId}`); + return defaultConnector.connectorId; + } + + logger.warn('No default AI connector configured for attack discovery'); + return undefined; + } catch (error) { + logger.error( + `Failed to get default connector: ${error instanceof Error ? error.message : String(error)}` + ); + return undefined; + } +} + +/** + * Creates a trigger function that uses the Elastic Assistant request context + * This version requires access to the request context which has all the necessary services + */ +export const createCaseAttackDiscoveryTrigger = ({ + core, + getElasticAssistantContext, + getSpaceId, + logger, + inference, + getAlertsIndexPattern, +}: { + core: CoreStart; + getElasticAssistantContext: ( + request: KibanaRequest + ) => Promise; + getSpaceId: (request: KibanaRequest) => string; + logger: Logger; + inference: InferenceServerStart; + getAlertsIndexPattern?: (request: KibanaRequest) => Promise; +}): TriggerAttackDiscoveryFn => { + return async (params: { + alertIds: string[]; + caseId: string; + alertsIndexPattern: string; + request: KibanaRequest; + }): Promise => { + const { alertIds, caseId, alertsIndexPattern: providedAlertsIndexPattern, request } = params; + const executionUuid = uuidv4(); + + try { + logger.debug( + `Triggering attack discovery for case ${caseId} with ${alertIds.length} alert(s)` + ); + + // Get the Elastic Assistant context using the provided request + const assistantContext = await getElasticAssistantContext(request); + + if (!assistantContext) { + logger.warn(`Elastic Assistant context not available for case ${caseId}`); + return { + executionUuid, + success: false, + error: 'Elastic Assistant context not available', + }; + } + + // Get required services from context + const actionsClient = await assistantContext.actions.getActionsClientWithRequest(request); + const dataClient = await assistantContext.getAttackDiscoveryDataClient(); + const esClient = core.elasticsearch.client.asScoped(request).asCurrentUser; + const savedObjectsClient = core.savedObjects.getScopedClient(request, { + includedHiddenTypes: [], + }); + const authenticatedUser = await assistantContext.getCurrentUser(); + const telemetry = assistantContext.telemetry; + + if (!dataClient) { + logger.warn(`Attack discovery data client not available for case ${caseId}`); + return { + executionUuid, + success: false, + error: 'Attack discovery data client not available', + }; + } + + if (!authenticatedUser) { + logger.warn(`Authenticated user not available for case ${caseId}`); + return { + executionUuid, + success: false, + error: 'Authenticated user not available', + }; + } + + // Get default connector from UI settings or inference plugin + const defaultConnectorId = await getDefaultConnectorId(core, inference, request, logger); + + if (!defaultConnectorId) { + logger.warn( + `No default connector configured for attack discovery, skipping for case ${caseId}` + ); + return { + executionUuid, + success: false, + error: 'No default connector configured', + }; + } + + // Get connector details + const connector = await actionsClient.get({ id: defaultConnectorId }); + if (!connector) { + logger.warn(`Connector ${defaultConnectorId} not found for case ${caseId}`); + return { + executionUuid, + success: false, + error: `Connector ${defaultConnectorId} not found`, + }; + } + + + logger.debug(`[Attack Discovery] Alert IDs to query: ${JSON.stringify(alertIds)}`); + + // Build filter for the alert IDs using 'ids' query + const filter = buildAlertIdsFilter(alertIds); + logger.debug(`[Attack Discovery] Built filter: ${JSON.stringify(filter)}`); + + // Get alerts index pattern + const effectiveAlertsIndexPattern = + // providedAlertsIndexPattern || + // (await getAlertsIndexPattern?.(request)) || + '.alerts-security.alerts-default'; + + // Get anonymization fields from the data client + const anonymizationFieldsDataClient = + await assistantContext.getAIAssistantAnonymizationFieldsDataClient(); + let anonymizationFields: AnonymizationFieldResponse[] = []; + + if (anonymizationFieldsDataClient) { + try { + const anonymizationFieldsResult = + await anonymizationFieldsDataClient.findDocuments({ + perPage: 1000, + page: 1, + }); + + if (anonymizationFieldsResult?.data) { + anonymizationFields = transformESSearchToAnonymizationFields( + anonymizationFieldsResult.data + ); + } + } catch (error) { + logger.warn( + `Failed to retrieve anonymization fields for case ${caseId}: ${error instanceof Error ? error.message : String(error)}` + ); + // Continue with empty array if retrieval fails + } + } + + // Build attack discovery config + const config = { + alertsIndexPattern: encodeURIComponent(effectiveAlertsIndexPattern), + apiConfig: { + connectorId: defaultConnectorId, + actionTypeId: connector.actionTypeId, + name: connector.name, + }, + anonymizationFields, + end: 'now', + filter, + size: alertIds.length, + start: 'now-30d', + replacements: {}, + subAction: 'invokeAI' as const, + }; + + logger.error( + `Attack discovery config: alertsIndexPattern=${effectiveAlertsIndexPattern}, connectorId=${defaultConnectorId}, connectorName=${connector.name}, actionTypeId=${connector.actionTypeId}, size=${alertIds.length}, anonymizationFieldsCount=${anonymizationFields.length}, filter=${JSON.stringify(filter)}` + ); + + // Trigger attack discovery generation + logger.error( + `Starting attack discovery generation for case ${caseId} with ${alertIds.length} alert(s), execution UUID: ${executionUuid}` + ); + + const result = await generateAndUpdateAttackDiscoveries({ + actionsClient, + authenticatedUser, + config, + dataClient, + enableFieldRendering: true, + esClient, + executionUuid, + logger: assistantContext.logger, + savedObjectsClient, + telemetry, + withReplacements: false, + }); + + if (result.error) { + logger.error( + `Attack discovery generation failed for case ${caseId}: ${result.error.message || 'Unknown error'}` + ); + return { + executionUuid, + success: false, + error: result.error.message || 'Unknown error', + }; + } + + // Log generation results for debugging + const generatedCount = result.attackDiscoveries?.length ?? 0; + logger.debug( + `Attack discovery generation completed for case ${caseId}, execution UUID: ${executionUuid}, generated ${generatedCount} attack discovery alert(s)` + ); + + if (generatedCount === 0) { + logger.warn( + `No attack discoveries were generated for case ${caseId} with ${alertIds.length} alert(s). This may be due to: 1) No attack patterns detected by the LLM, 2) All discoveries were deduplicated, or 3) An issue during transformation.` + ); + } + + // Extract attack discovery alert IDs and indices with metadata + const spaceId = getSpaceId(request); + const attackDiscoveryAlerts: AttackDiscoveryAlertInfo[] = + result.attackDiscoveries?.map((discovery) => ({ + alertId: discovery.id, + index: `${ATTACK_DISCOVERY_ALERTS_COMMON_INDEX_PREFIX}-${spaceId}`, + // Include metadata for external reference attachment + title: discovery.title, + timestamp: discovery.timestamp, + generationUuid: executionUuid, + })) || []; + + logger.info( + `Successfully generated attack discovery for case ${caseId}, execution UUID: ${executionUuid}, created ${attackDiscoveryAlerts.length} attack discovery alert(s)` + ); + + return { + executionUuid, + success: true, + attackDiscoveryAlerts, + }; + } catch (error) { + logger.error( + `Failed to trigger attack discovery for case ${caseId}: ${error instanceof Error ? error.message : String(error)}` + ); + return { + executionUuid, + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }; +}; + +