From 6d3239ec2fbe2683fa07e779e600f68599e5d60c Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Fri, 24 Jan 2025 10:24:33 +0100 Subject: [PATCH] [Defend Workflows] Defend Insights - Check existing Trusted Apps before creating an insight (#207378) This PR ensures that prepared insights are checked for an existing path and signer already added to the trusted app. If such insights are found, the execution is stopped. This resolves the issue of displaying insights to the user that have already been remediated. --- .../insights/workflow_insights_results.tsx | 10 +- .../view/hooks/insights/use_fetch_insights.ts | 1 + .../builders/incompatible_antivirus.ts | 1 + .../workflow_insights/helpers.test.ts | 159 ++++++++++++++++++ .../services/workflow_insights/helpers.ts | 57 +++++++ .../services/workflow_insights/index.test.ts | 24 ++- .../services/workflow_insights/index.ts | 15 +- 7 files changed, 262 insertions(+), 5 deletions(-) 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 764b482a66826..035e7e3936a88 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 @@ -42,6 +42,12 @@ const CustomEuiCallOut = styled(EuiCallOut)` } `; +const ScrollableContainer = styled(EuiPanel)` + max-height: 500px; + overflow-y: auto; + padding: 0; +`; + export const WorkflowInsightsResults = ({ results, scanCompleted, @@ -127,7 +133,7 @@ export const WorkflowInsightsResults = ({ {insight.message} - + {item.entries[0].type === 'match' && item.entries[0].field === 'process.executable.caseless' && item.entries[0].value} @@ -173,7 +179,7 @@ export const WorkflowInsightsResults = ({ ) : null} - {insights} + {insights} ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/insights/use_fetch_insights.ts b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/insights/use_fetch_insights.ts index 4b70bbc3dade9..d4eee941200f4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/insights/use_fetch_insights.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/insights/use_fetch_insights.ts @@ -31,6 +31,7 @@ export const useFetchInsights = ({ endpointId, onSuccess }: UseFetchInsightsConf query: { actionTypes: JSON.stringify([ActionType.Refreshed]), targetIds: JSON.stringify([endpointId]), + size: 100, }, }); onSuccess(); 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 b53c2d44555bd..434d9356e2f7b 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 @@ -57,6 +57,7 @@ export async function buildIncompatibleAntivirusWorkflowInsights( const codeSignaturesHits = ( await esClient.search({ index: FILE_EVENTS_INDEX_PATTERN, + size: eventIds.length, query: { bool: { must: [ diff --git a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.test.ts b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.test.ts index 50184413063cf..5a37507517ddd 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.test.ts @@ -30,9 +30,11 @@ import { import type { EndpointMetadataService } from '../metadata'; import { buildEsQueryParams, + checkIfRemediationExists, createDatastream, createPipeline, generateInsightId, + generateTrustedAppsFilter, groupEndpointIdsByOS, } from './helpers'; import { @@ -44,6 +46,7 @@ import { } from './constants'; import { securityWorkflowInsightsFieldMap } from './field_map_configurations'; import { createMockEndpointAppContext } from '../../mocks'; +import type { ExceptionListClient } from '@kbn/lists-plugin/server'; jest.mock('@kbn/data-stream-adapter', () => ({ DataStreamSpacesAdapter: jest.fn().mockImplementation(() => ({ @@ -262,4 +265,160 @@ describe('helpers', () => { expect(result).toBe(expectedHash); }); }); + + describe('generateTrustedAppsFilter', () => { + it('should generate a filter for process.executable.caseless entries', () => { + const insight = getDefaultInsight({ + remediation: { + exception_list_items: [ + { + entries: [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: 'example-value', + }, + ], + }, + ], + }, + } as Partial); + + const filter = generateTrustedAppsFilter(insight); + + expect(filter).toBe('exception-list-agnostic.attributes.entries.value:"example-value"'); + }); + + it('should generate a filter for process.code_signature entries', () => { + const insight = getDefaultInsight({ + remediation: { + exception_list_items: [ + { + entries: [ + { + field: 'process.code_signature', + operator: 'included', + type: 'match', + value: 'Example, Inc.', + }, + ], + }, + ], + }, + } as Partial); + + const filter = generateTrustedAppsFilter(insight); + + expect(filter).toContain( + 'exception-list-agnostic.attributes.entries.entries.value:(*Example,*Inc.*)' + ); + }); + + it('should generate a filter for combined process.Ext.code_signature and process.executable.caseless', () => { + const insight = getDefaultInsight({ + remediation: { + exception_list_items: [ + { + entries: [ + { + field: 'process.Ext.code_signature', + operator: 'included', + type: 'match', + value: 'Example, (Inc.) http://example.com [example]', + }, + { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: 'example-value', + }, + ], + }, + ], + }, + } as Partial); + + const filter = generateTrustedAppsFilter(insight); + + expect(filter).toContain( + 'exception-list-agnostic.attributes.entries.entries.value:(*Example,*\\(Inc.\\)*http\\://example.com*[example]*) AND exception-list-agnostic.attributes.entries.value:"example-value"' + ); + }); + + it('should return empty string if no valid entries are present', () => { + const insight = getDefaultInsight({ + remediation: { + exception_list_items: [ + { + entries: [ + { + field: 'unknown-field', + operator: 'included', + type: 'match', + value: 'example-value', + }, + ], + }, + ], + }, + } as Partial); + + const filter = generateTrustedAppsFilter(insight); + + expect(filter).toBe(''); + }); + }); + describe('checkIfRemediationExists', () => { + it('should return false for non-incompatible_antivirus types', async () => { + const insight = getDefaultInsight({ + type: 'other-type' as DefendInsightType, + }); + + const result = await checkIfRemediationExists({ + insight, + exceptionListsClient: jest.fn() as unknown as ExceptionListClient, + }); + + expect(result).toBe(false); + }); + + it('should call exceptionListsClient with the correct filter', async () => { + const findExceptionListItemMock = jest.fn().mockResolvedValue({ total: 1 }); + const insight = getDefaultInsight({ + remediation: { + exception_list_items: [ + { + entries: [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: 'example-value', + }, + ], + }, + ], + }, + } as Partial); + + const result = await checkIfRemediationExists({ + insight, + exceptionListsClient: { + findExceptionListItem: findExceptionListItemMock, + } as unknown as ExceptionListClient, + }); + + expect(findExceptionListItemMock).toHaveBeenCalledWith({ + listId: 'endpoint_trusted_apps', + page: 1, + perPage: 1, + namespaceType: 'agnostic', + filter: expect.any(String), + sortField: 'created_at', + sortOrder: 'desc', + }); + expect(result).toBe(true); + }); + }); }); 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 0e508c5bcfed7..e356d0ed123a7 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 @@ -13,6 +13,8 @@ import type { ElasticsearchClient } from '@kbn/core/server'; import { DataStreamSpacesAdapter } from '@kbn/data-stream-adapter'; +import type { ExceptionListClient } from '@kbn/lists-plugin/server'; +import { DefendInsightType } from '@kbn/elastic-assistant-common'; import type { SearchParams, SecurityWorkflowInsight, @@ -160,3 +162,58 @@ export function getUniqueInsights(insights: SecurityWorkflowInsight[]): Security } return Object.values(uniqueInsights); } + +export const generateTrustedAppsFilter = (insight: SecurityWorkflowInsight): string | undefined => { + return insight.remediation.exception_list_items + ?.flatMap((item) => + item.entries.map((entry) => { + if (!('value' in entry)) return ''; + + if (entry.field === 'process.executable.caseless') { + return `exception-list-agnostic.attributes.entries.value:"${entry.value}"`; + } + + if ( + entry.field === 'process.code_signature' || + (entry.field === 'process.Ext.code_signature' && typeof entry.value === 'string') + ) { + const sanitizedValue = (entry.value as string) + .replace(/[)(<>}{":\\]/gm, '\\$&') + .replace(/\s/gm, '*'); + return `exception-list-agnostic.attributes.entries.entries.value:(*${sanitizedValue}*)`; + } + + return ''; + }) + ) + .filter(Boolean) + .join(' AND '); +}; + +export const checkIfRemediationExists = async ({ + insight, + exceptionListsClient, +}: { + insight: SecurityWorkflowInsight; + exceptionListsClient: ExceptionListClient; +}): Promise => { + if (insight.type !== DefendInsightType.Enum.incompatible_antivirus) { + return false; + } + + const filter = generateTrustedAppsFilter(insight); + + if (!filter) return false; + + const response = await exceptionListsClient.findExceptionListItem({ + listId: 'endpoint_trusted_apps', + page: 1, + perPage: 1, + namespaceType: 'agnostic', + filter, + sortField: 'created_at', + sortOrder: 'desc', + }); + + return !!response?.total && response.total > 0; +}; 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 cca105152d3fe..a9b71a3017209 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 @@ -32,7 +32,12 @@ import { ActionType, } from '../../../../common/endpoint/types/workflow_insights'; import { createMockEndpointAppContext } from '../../mocks'; -import { createDatastream, createPipeline, generateInsightId } from './helpers'; +import { + checkIfRemediationExists, + createDatastream, + createPipeline, + generateInsightId, +} from './helpers'; import { securityWorkflowInsightsService } from '.'; import { DATA_STREAM_NAME } from './constants'; import { buildWorkflowInsights } from './builders'; @@ -43,6 +48,7 @@ jest.mock('./helpers', () => { ...original, createDatastream: jest.fn(), createPipeline: jest.fn(), + checkIfRemediationExists: jest.fn(), }; }); @@ -293,6 +299,22 @@ describe('SecurityWorkflowInsightsService', () => { }); }); + it('should not index the doc if remediation exists', async () => { + await securityWorkflowInsightsService.start({ esClient }); + const insight = getDefaultInsight(); + + const remediationExistsMock = checkIfRemediationExists as jest.Mock; + remediationExistsMock.mockResolvedValueOnce(true); + + await securityWorkflowInsightsService.create(insight); + + expect(remediationExistsMock).toHaveBeenCalledTimes(1); + + // two since it calls fetch as well + expect(isInitializedSpy).toHaveBeenCalledTimes(1); + expect(esClient.index).toHaveBeenCalledTimes(0); + }); + it('should call update instead if insight already exists', async () => { const indexName = 'backing-index'; const fetchSpy = jest 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 5714f25c92be2..c9acf9c42204f 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 @@ -25,6 +25,7 @@ import type { EndpointAppContextService } from '../../endpoint_app_context_servi import { SecurityWorkflowInsightsFailedInitialized } from './errors'; import { buildEsQueryParams, + checkIfRemediationExists, createDatastream, createPipeline, generateInsightId, @@ -127,7 +128,7 @@ class SecurityWorkflowInsightsService { public async createFromDefendInsights( defendInsights: DefendInsight[], request: KibanaRequest - ): Promise { + ): Promise>> { await this.isInitialized; const workflowInsights = await buildWorkflowInsights({ @@ -137,14 +138,24 @@ class SecurityWorkflowInsightsService { esClient: this.esClient, }); const uniqueInsights = getUniqueInsights(workflowInsights); + return Promise.all(uniqueInsights.map((insight) => this.create(insight))); } - public async create(insight: SecurityWorkflowInsight): Promise { + public async create(insight: SecurityWorkflowInsight): Promise { await this.isInitialized; const id = generateInsightId(insight); + const remediationExists = await checkIfRemediationExists({ + insight, + exceptionListsClient: this.endpointContext.getExceptionListsClient(), + }); + + if (remediationExists) { + return; + } + // if insight already exists, update instead const existingInsights = await this.fetch({ ids: [id] }); if (existingInsights.length) {