Skip to content

Commit

Permalink
[Defend Workflows] Defend Insights - Check existing Trusted Apps befo…
Browse files Browse the repository at this point in the history
…re 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.
  • Loading branch information
szwarckonrad authored Jan 24, 2025
1 parent b45030a commit 910b9c5
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -127,7 +133,7 @@ export const WorkflowInsightsResults = ({
<EuiText size={'s'} color={'subdued'}>
{insight.message}
</EuiText>
<EuiText size={'xs'} color={'subdued'}>
<EuiText size={'xs'} color={'subdued'} css={'word-break: break-word'}>
{item.entries[0].type === 'match' &&
item.entries[0].field === 'process.executable.caseless' &&
item.entries[0].value}
Expand Down Expand Up @@ -173,7 +179,7 @@ export const WorkflowInsightsResults = ({
<EuiSpacer size={'s'} />
</>
) : null}
{insights}
<ScrollableContainer hasBorder>{insights}</ScrollableContainer>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const useFetchInsights = ({ endpointId, onSuccess }: UseFetchInsightsConf
query: {
actionTypes: JSON.stringify([ActionType.Refreshed]),
targetIds: JSON.stringify([endpointId]),
size: 100,
},
});
onSuccess();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export async function buildIncompatibleAntivirusWorkflowInsights(
const codeSignaturesHits = (
await esClient.search<FileEventDoc>({
index: FILE_EVENTS_INDEX_PATTERN,
size: eventIds.length,
query: {
bool: {
must: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ import {
import type { EndpointMetadataService } from '../metadata';
import {
buildEsQueryParams,
checkIfRemediationExists,
createDatastream,
createPipeline,
generateInsightId,
generateTrustedAppsFilter,
groupEndpointIdsByOS,
} from './helpers';
import {
Expand All @@ -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(() => ({
Expand Down Expand Up @@ -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<SecurityWorkflowInsight>);

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<SecurityWorkflowInsight>);

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<SecurityWorkflowInsight>);

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<SecurityWorkflowInsight>);

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<SecurityWorkflowInsight>);

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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<boolean> => {
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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -43,6 +48,7 @@ jest.mock('./helpers', () => {
...original,
createDatastream: jest.fn(),
createPipeline: jest.fn(),
checkIfRemediationExists: jest.fn(),
};
});

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { EndpointAppContextService } from '../../endpoint_app_context_servi
import { SecurityWorkflowInsightsFailedInitialized } from './errors';
import {
buildEsQueryParams,
checkIfRemediationExists,
createDatastream,
createPipeline,
generateInsightId,
Expand Down Expand Up @@ -127,7 +128,7 @@ class SecurityWorkflowInsightsService {
public async createFromDefendInsights(
defendInsights: DefendInsight[],
request: KibanaRequest<unknown, unknown, DefendInsightsPostRequestBody>
): Promise<WriteResponseBase[]> {
): Promise<Array<Awaited<WriteResponseBase | void>>> {
await this.isInitialized;

const workflowInsights = await buildWorkflowInsights({
Expand All @@ -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<WriteResponseBase> {
public async create(insight: SecurityWorkflowInsight): Promise<WriteResponseBase | void> {
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) {
Expand Down

0 comments on commit 910b9c5

Please sign in to comment.