diff --git a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/workflow_insights.ts b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/workflow_insights.ts index 3212193c981cb..0fc2ec83139dd 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/workflow_insights.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/endpoint/types/workflow_insights.ts @@ -61,6 +61,7 @@ export interface SecurityWorkflowInsight { metadata: { notes?: Record; message_variables?: string[]; + display_name?: string; }; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/insights/workflow_insights_results.tsx b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/insights/workflow_insights_results.tsx index 887792b0b17fd..764b482a66826 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/insights/workflow_insights_results.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/insights/workflow_insights_results.tsx @@ -122,13 +122,15 @@ export const WorkflowInsightsResults = ({ - {insight.value} + {insight.metadata.display_name || insight.value} {insight.message} - {item.entries[0].type === 'match' && item.entries[0].value} + {item.entries[0].type === 'match' && + item.entries[0].field === 'process.executable.caseless' && + item.entries[0].value} diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/incompatible_antivirus.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/incompatible_antivirus.test.ts index 41b833be05fc0..08074b6cc91b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/incompatible_antivirus.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/incompatible_antivirus.test.ts @@ -7,7 +7,7 @@ import moment from 'moment'; -import type { KibanaRequest } from '@kbn/core/server'; +import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; import type { DefendInsightsPostRequestBody } from '@kbn/elastic-assistant-common'; import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; @@ -30,101 +30,189 @@ jest.mock('../helpers', () => ({ })); describe('buildIncompatibleAntivirusWorkflowInsights', () => { - let params: BuildWorkflowInsightParams; + const mockEndpointAppContextService = createMockEndpointAppContext().service; + mockEndpointAppContextService.getEndpointMetadataService = jest.fn().mockReturnValue({ + getMetadataForEndpoints: jest.fn(), + }); + const endpointMetadataService = + mockEndpointAppContextService.getEndpointMetadataService() as jest.Mocked; - beforeEach(() => { - const mockEndpointAppContextService = createMockEndpointAppContext().service; - mockEndpointAppContextService.getEndpointMetadataService = jest.fn().mockReturnValue({ - getMetadataForEndpoints: jest.fn(), - }); - const endpointMetadataService = - mockEndpointAppContextService.getEndpointMetadataService() as jest.Mocked; - - params = { - defendInsights: [ - { - group: 'AVGAntivirus', - events: [ - { - id: 'lqw5opMB9Ke6SNgnxRSZ', - endpointId: 'f6e2f338-6fb7-4c85-9c23-d20e9f96a051', - value: '/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity', - }, - ], + const generateParams = (signerId?: string): BuildWorkflowInsightParams => ({ + defendInsights: [ + { + group: 'AVGAntivirus', + events: [ + { + id: 'lqw5opMB9Ke6SNgnxRSZ', + endpointId: 'f6e2f338-6fb7-4c85-9c23-d20e9f96a051', + value: '/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity', + ...(signerId ? { signerId } : {}), + }, + ], + }, + ], + request: { + body: { + insightType: 'incompatible_antivirus', + endpointIds: ['endpoint-1'], + apiConfig: { + connectorId: 'connector-id-1', + actionTypeId: 'action-type-id-1', + model: 'model-1', + }, + anonymizationFields: [], + subAction: 'invokeAI', + }, + } as unknown as KibanaRequest, + endpointMetadataService, + esClient: { + search: jest.fn().mockResolvedValue({ + hits: { + hits: [], }, - ], - request: { - body: { - insightType: 'incompatible_antivirus', - endpointIds: ['endpoint-1'], - apiConfig: { - connectorId: 'connector-id-1', - actionTypeId: 'action-type-id-1', - model: 'model-1', + }), + } as unknown as ElasticsearchClient, + }); + + const buildExpectedInsight = (os: string, signerField?: string, signerValue?: string) => + expect.objectContaining({ + '@timestamp': expect.any(moment), + message: 'Incompatible antiviruses detected', + category: Category.Endpoint, + type: 'incompatible_antivirus', + source: { + type: SourceType.LlmConnector, + id: 'connector-id-1', + data_range_start: expect.any(moment), + data_range_end: expect.any(moment), + }, + target: { + type: TargetType.Endpoint, + ids: ['endpoint-1'], + }, + action: { + type: ActionType.Refreshed, + timestamp: expect.any(moment), + }, + value: `AVGAntivirus /Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity${ + signerValue ? ` ${signerValue}` : '' + }`, + remediation: { + exception_list_items: [ + { + list_id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id, + name: 'AVGAntivirus', + description: 'Suggested by Security Workflow Insights', + entries: [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: '/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity', + }, + ...(signerField && signerValue + ? [ + { + field: signerField, + operator: 'included', + type: 'match', + value: signerValue, + }, + ] + : []), + ], + tags: ['policy:all'], + os_types: [os], }, - anonymizationFields: [], - subAction: 'invokeAI', + ], + }, + metadata: { + notes: { + llm_model: 'model-1', }, - } as unknown as KibanaRequest, - endpointMetadataService, - }; + display_name: 'AVGAntivirus', + }, + }); + it('should correctly build workflow insights', async () => { (groupEndpointIdsByOS as jest.Mock).mockResolvedValue({ windows: ['endpoint-1'], }); + const params = generateParams(); + const result = await buildIncompatibleAntivirusWorkflowInsights(params); + + expect(result).toEqual([buildExpectedInsight('windows')]); + expect(groupEndpointIdsByOS).toHaveBeenCalledWith( + ['endpoint-1'], + params.endpointMetadataService + ); }); - it('should correctly build workflow insights', async () => { + it('should correctly build workflow insights for Windows with signerId provided', async () => { + (groupEndpointIdsByOS as jest.Mock).mockResolvedValue({ + windows: ['endpoint-1'], + }); + const params = generateParams('test.com'); + + params.esClient.search = jest.fn().mockResolvedValue({ + hits: { + hits: [ + { + _id: 'lqw5opMB9Ke6SNgnxRSZ', + _source: { + process: { + Ext: { + code_signature: { + trusted: true, + subject_name: 'test.com', + }, + }, + }, + }, + }, + ], + }, + }); + const result = await buildIncompatibleAntivirusWorkflowInsights(params); expect(result).toEqual([ - expect.objectContaining({ - '@timestamp': expect.any(moment), - message: 'Incompatible antiviruses detected', - category: Category.Endpoint, - type: 'incompatible_antivirus', - source: { - type: SourceType.LlmConnector, - id: 'connector-id-1', - data_range_start: expect.any(moment), - data_range_end: expect.any(moment), - }, - target: { - type: TargetType.Endpoint, - ids: ['endpoint-1'], - }, - action: { - type: ActionType.Refreshed, - timestamp: expect.any(moment), - }, - value: 'AVGAntivirus', - remediation: { - exception_list_items: [ - { - list_id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id, - name: 'AVGAntivirus', - description: 'Suggested by Security Workflow Insights', - entries: [ - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: - '/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity', + buildExpectedInsight('windows', 'process.Ext.code_signature', 'test.com'), + ]); + expect(groupEndpointIdsByOS).toHaveBeenCalledWith( + ['endpoint-1'], + params.endpointMetadataService + ); + }); + + it('should correctly build workflow insights for MacOS with signerId provided', async () => { + (groupEndpointIdsByOS as jest.Mock).mockResolvedValue({ + macos: ['endpoint-1'], + }); + + const params = generateParams('test.com'); + + params.esClient.search = jest.fn().mockResolvedValue({ + hits: { + hits: [ + { + _id: 'lqw5opMB9Ke6SNgnxRSZ', + _source: { + process: { + code_signature: { + trusted: true, + subject_name: 'test.com', }, - ], - tags: ['policy:all'], - os_types: ['windows'], + }, }, - ], - }, - metadata: { - notes: { - llm_model: 'model-1', }, - }, - }), - ]); + ], + }, + }); + + const result = await buildIncompatibleAntivirusWorkflowInsights(params); + + expect(result).toEqual([buildExpectedInsight('macos', 'process.code_signature', 'test.com')]); expect(groupEndpointIdsByOS).toHaveBeenCalledWith( ['endpoint-1'], params.endpointMetadataService diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/incompatible_antivirus.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/incompatible_antivirus.ts index 64485995c578d..b53c2d44555bd 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/incompatible_antivirus.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/incompatible_antivirus.ts @@ -6,6 +6,7 @@ */ import moment from 'moment'; +import { get as _get, uniqBy } from 'lodash'; import type { DefendInsight } from '@kbn/elastic-assistant-common'; @@ -15,6 +16,7 @@ import type { SecurityWorkflowInsight } from '../../../../../common/endpoint/typ import type { SupportedHostOsType } from '../../../../../common/endpoint/constants'; import type { BuildWorkflowInsightParams } from '.'; +import { FILE_EVENTS_INDEX_PATTERN } from '../../../../../common/endpoint/constants'; import { ActionType, Category, @@ -23,66 +25,160 @@ import { } from '../../../../../common/endpoint/types/workflow_insights'; import { groupEndpointIdsByOS } from '../helpers'; +interface FileEventDoc { + process: { + code_signature?: { + subject_name: string; + trusted: boolean; + }; + Ext?: { + code_signature?: { + subject_name: string; + trusted: boolean; + }; + }; + }; +} + export async function buildIncompatibleAntivirusWorkflowInsights( params: BuildWorkflowInsightParams ): Promise { const currentTime = moment(); - const { defendInsights, request, endpointMetadataService } = params; + const { defendInsights, request, endpointMetadataService, esClient } = params; const { insightType, endpointIds, apiConfig } = request.body; const osEndpointIdsMap = await groupEndpointIdsByOS(endpointIds, endpointMetadataService); - return defendInsights.flatMap((defendInsight: DefendInsight) => { - const filePaths = Array.from(new Set((defendInsight.events ?? []).map((event) => event.value))); - - return Object.keys(osEndpointIdsMap).flatMap((os) => - filePaths.map((filePath) => ({ - '@timestamp': currentTime, - // TODO add i18n support - message: 'Incompatible antiviruses detected', - category: Category.Endpoint, - type: insightType, - source: { - type: SourceType.LlmConnector, - id: apiConfig.connectorId, - // TODO use actual time range when we add support - data_range_start: currentTime, - data_range_end: currentTime.clone().add(24, 'hours'), - }, - target: { - type: TargetType.Endpoint, - ids: endpointIds, - }, - action: { - type: ActionType.Refreshed, - timestamp: currentTime, - }, - value: defendInsight.group, - remediation: { - exception_list_items: [ - { - list_id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id, - name: defendInsight.group, - description: 'Suggested by Security Workflow Insights', - entries: [ + + const insightsPromises = defendInsights.map( + async (defendInsight: DefendInsight): Promise => { + const uniqueFilePathsInsights = uniqBy(defendInsight.events, 'value'); + const eventIds = Array.from(new Set(uniqueFilePathsInsights.map((event) => event.id))); + + const codeSignaturesHits = ( + await esClient.search({ + index: FILE_EVENTS_INDEX_PATTERN, + query: { + bool: { + must: [ { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: filePath, + terms: { + _id: eventIds, + }, + }, + { + bool: { + should: [ + { + term: { + 'process.code_signature.trusted': true, + }, + }, + { + term: { + 'process.Ext.code_signature.trusted': true, + }, + }, + ], + }, }, ], - // TODO add per policy support - tags: ['policy:all'], - os_types: [os as SupportedHostOsType], }, - ], - }, - metadata: { - notes: { - llm_model: apiConfig.model ?? '', }, - }, - })) - ); - }); + }) + ).hits.hits; + + const createRemediation = ( + filePath: string, + os: string, + signatureField?: string, + signatureValue?: string + ): SecurityWorkflowInsight => { + return { + '@timestamp': currentTime, + // TODO add i18n support + message: 'Incompatible antiviruses detected', + category: Category.Endpoint, + type: insightType, + source: { + type: SourceType.LlmConnector, + id: apiConfig.connectorId, + // TODO use actual time range when we add support + data_range_start: currentTime, + data_range_end: currentTime.clone().add(24, 'hours'), + }, + target: { + type: TargetType.Endpoint, + ids: endpointIds, + }, + action: { + type: ActionType.Refreshed, + timestamp: currentTime, + }, + value: `${defendInsight.group} ${filePath}${signatureValue ? ` ${signatureValue}` : ''}`, + metadata: { + notes: { + llm_model: apiConfig.model ?? '', + }, + display_name: defendInsight.group, + }, + remediation: { + exception_list_items: [ + { + list_id: ENDPOINT_ARTIFACT_LISTS.trustedApps.id, + name: defendInsight.group, + description: 'Suggested by Security Workflow Insights', + entries: [ + { + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: filePath, + }, + ...(signatureField && signatureValue + ? [ + { + field: signatureField, + operator: 'included' as const, + type: 'match' as const, + value: signatureValue, + }, + ] + : []), + ], + // TODO add per policy support + tags: ['policy:all'], + os_types: [os as SupportedHostOsType], + }, + ], + }, + }; + }; + + return Object.keys(osEndpointIdsMap).flatMap((os): SecurityWorkflowInsight[] => { + return uniqueFilePathsInsights.map((insight) => { + const { value: filePath, id } = insight; + + if (codeSignaturesHits.length) { + const codeSignatureSearchHit = codeSignaturesHits.find((hit) => hit._id === id); + + if (codeSignatureSearchHit) { + const extPath = os === 'windows' ? '.Ext' : ''; + const field = `process${extPath}.code_signature`; + const value = _get( + codeSignatureSearchHit, + `_source.${field}.subject_name`, + 'invalid subject name' + ); + return createRemediation(filePath, os, field, value); + } + } + + return createRemediation(filePath, os); + }); + }); + } + ); + + const insightsArr = await Promise.all(insightsPromises); + return insightsArr.flat(); } diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/index.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/index.ts index 1f00d152f1f12..a19e800629598 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/builders/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { KibanaRequest } from '@kbn/core/server'; +import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; import type { DefendInsight, DefendInsightsPostRequestBody } from '@kbn/elastic-assistant-common'; @@ -21,6 +21,7 @@ export interface BuildWorkflowInsightParams { defendInsights: DefendInsight[]; request: KibanaRequest; endpointMetadataService: EndpointMetadataService; + esClient: ElasticsearchClient; } export function buildWorkflowInsights( diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.ts index f7b477a17018d..0e508c5bcfed7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.ts @@ -149,3 +149,14 @@ export function generateInsightId(insight: SecurityWorkflowInsight): string { return hash.digest('hex'); } + +export function getUniqueInsights(insights: SecurityWorkflowInsight[]): SecurityWorkflowInsight[] { + const uniqueInsights: { [key: string]: SecurityWorkflowInsight } = {}; + for (const insight of insights) { + const id = generateInsightId(insight); + if (!uniqueInsights[id]) { + uniqueInsights[id] = insight; + } + } + return Object.values(uniqueInsights); +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.test.ts index 849c6431a09a8..cca105152d3fe 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.test.ts @@ -223,17 +223,19 @@ describe('SecurityWorkflowInsightsService', () => { describe('createFromDefendInsights', () => { it('should create workflow insights from defend insights', async () => { + const insight = { + group: 'AVGAntivirus', + events: [ + { + id: 'lqw5opMB9Ke6SNgnxRSZ', + endpointId: 'f6e2f338-6fb7-4c85-9c23-d20e9f96a051', + value: '/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity', + }, + ], + }; const defendInsights: DefendInsight[] = [ - { - group: 'AVGAntivirus', - events: [ - { - id: 'lqw5opMB9Ke6SNgnxRSZ', - endpointId: 'f6e2f338-6fb7-4c85-9c23-d20e9f96a051', - value: '/Applications/AVGAntivirus.app/Contents/Backend/services/com.avg.activity', - }, - ], - }, + insight, + insight, // intentional dupe to confirm de-duping ]; const request = {} as KibanaRequest; @@ -266,6 +268,7 @@ describe('SecurityWorkflowInsightsService', () => { defendInsights, request, endpointMetadataService: expect.any(Object), + esClient, }); expect(result).toEqual(workflowInsights.map(() => esClientIndexResp)); }); @@ -283,8 +286,10 @@ describe('SecurityWorkflowInsightsService', () => { expect(esClient.index).toHaveBeenCalledTimes(1); expect(esClient.index).toHaveBeenCalledWith({ index: DATA_STREAM_NAME, - body: { ...insight, id: generateInsightId(insight) }, + id: generateInsightId(insight), + body: insight, refresh: 'wait_for', + op_type: 'create', }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.ts index 1baeaf74e00f0..5714f25c92be2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.ts @@ -23,7 +23,13 @@ import type { import type { EndpointAppContextService } from '../../endpoint_app_context_services'; import { SecurityWorkflowInsightsFailedInitialized } from './errors'; -import { buildEsQueryParams, createDatastream, createPipeline, generateInsightId } from './helpers'; +import { + buildEsQueryParams, + createDatastream, + createPipeline, + generateInsightId, + getUniqueInsights, +} from './helpers'; import { DATA_STREAM_NAME } from './constants'; import { buildWorkflowInsights } from './builders'; @@ -128,8 +134,10 @@ class SecurityWorkflowInsightsService { defendInsights, request, endpointMetadataService: this.endpointContext.getEndpointMetadataService(), + esClient: this.esClient, }); - return Promise.all(workflowInsights.map((insight) => this.create(insight))); + const uniqueInsights = getUniqueInsights(workflowInsights); + return Promise.all(uniqueInsights.map((insight) => this.create(insight))); } public async create(insight: SecurityWorkflowInsight): Promise { @@ -145,8 +153,10 @@ class SecurityWorkflowInsightsService { const response = await this.esClient.index({ index: DATA_STREAM_NAME, - body: { ...insight, id }, + id, + body: insight, refresh: 'wait_for', + op_type: 'create', }); return response;