[EDR Workflows] Workflow Insights - filter trusted apps by policy (#209340)

This PR updates the logic for determining whether an Insight has already
been addressed by Trusted Apps. While we’ve been querying Trusted Apps
based on the Insight’s reported path and, for Windows and macOS, the
signature, this approach had a limitation: it didn’t account for cases
where a matching Trusted App existed but was assigned to a policy
unrelated to the endpoint where the Insight was generated.

To address this, we’ve extended the query to include an additional
filter for the specific policy ID associated with the endpoint, as well
as any global policies (policy:all).


https://github.com/user-attachments/assets/96470d0b-b7ea-4f59-af0a-e865ad7fd22c
This commit is contained in:
Konrad Szwarc 2025-02-07 09:48:21 +01:00 committed by GitHub
parent b750d46c8b
commit 8831e5b25d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 122 additions and 42 deletions

View file

@ -267,7 +267,6 @@ describe('helpers', () => {
expect(result).toBe(expectedHash);
});
});
describe('generateTrustedAppsFilter', () => {
it('should generate a filter for process.executable.caseless entries', () => {
const insight = getDefaultInsight({
@ -287,9 +286,10 @@ describe('helpers', () => {
},
} as Partial<SecurityWorkflowInsight>);
const filter = generateTrustedAppsFilter(insight);
expect(filter).toBe('exception-list-agnostic.attributes.entries.value:"example-value"');
const filter = generateTrustedAppsFilter(insight, 'test-id');
expect(filter).toBe(
'(exception-list-agnostic.attributes.tags:"policy:test-id" OR exception-list-agnostic.attributes.tags:"policy:all") AND exception-list-agnostic.attributes.entries.value:"example-value"'
);
});
it('should generate a filter for process.code_signature entries', () => {
@ -310,10 +310,9 @@ describe('helpers', () => {
},
} as Partial<SecurityWorkflowInsight>);
const filter = generateTrustedAppsFilter(insight);
expect(filter).toContain(
'exception-list-agnostic.attributes.entries.entries.value:(*Example,*Inc.*)'
const filter = generateTrustedAppsFilter(insight, 'test-id');
expect(filter).toBe(
'(exception-list-agnostic.attributes.tags:"policy:test-id" OR exception-list-agnostic.attributes.tags:"policy:all") AND exception-list-agnostic.attributes.entries.entries.value:(*Example,*Inc.*)'
);
});
@ -341,14 +340,13 @@ describe('helpers', () => {
},
} 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"'
const filter = generateTrustedAppsFilter(insight, 'test-id');
expect(filter).toBe(
'(exception-list-agnostic.attributes.tags:"policy:test-id" OR exception-list-agnostic.attributes.tags:"policy:all") AND 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', () => {
it('should return undefined if no valid entries are present', () => {
const insight = getDefaultInsight({
remediation: {
exception_list_items: [
@ -366,9 +364,8 @@ describe('helpers', () => {
},
} as Partial<SecurityWorkflowInsight>);
const filter = generateTrustedAppsFilter(insight);
expect(filter).toBe('');
const filter = generateTrustedAppsFilter(insight, 'test-id');
expect(filter).toBe(undefined);
});
});
@ -378,17 +375,34 @@ describe('helpers', () => {
type: 'other-type' as DefendInsightType,
});
// For non-incompatible_antivirus types, getHostMetadata should not be called.
const endpointMetadataClientMock = {
getHostMetadata: jest.fn(),
};
const exceptionListsClientMock = {
findExceptionListItem: jest.fn(),
};
const result = await checkIfRemediationExists({
insight,
exceptionListsClient: jest.fn() as unknown as ExceptionListClient,
exceptionListsClient: exceptionListsClientMock as unknown as ExceptionListClient,
endpointMetadataClient: endpointMetadataClientMock as unknown as EndpointMetadataService,
});
expect(result).toBe(false);
expect(endpointMetadataClientMock.getHostMetadata).not.toHaveBeenCalled();
});
it('should call exceptionListsClient with the correct filter', async () => {
it('should call exceptionListsClient with the correct filter when valid entries exist', async () => {
const findExceptionListItemMock = jest.fn().mockResolvedValue({ total: 1 });
const endpointMetadataClientMock = {
getHostMetadata: jest
.fn()
.mockResolvedValue({ Endpoint: { policy: { applied: { id: 'abc123' } } } }),
};
const insight = getDefaultInsight({
type: DefendInsightType.Enum.incompatible_antivirus,
remediation: {
exception_list_items: [
{
@ -403,6 +417,7 @@ describe('helpers', () => {
},
],
},
target: { ids: ['host-id'] },
} as Partial<SecurityWorkflowInsight>);
const result = await checkIfRemediationExists({
@ -410,19 +425,66 @@ describe('helpers', () => {
exceptionListsClient: {
findExceptionListItem: findExceptionListItemMock,
} as unknown as ExceptionListClient,
endpointMetadataClient: endpointMetadataClientMock as unknown as EndpointMetadataService,
});
// Ensure the metadata was fetched using the host id
expect(endpointMetadataClientMock.getHostMetadata).toHaveBeenCalledWith('host-id');
// Expected filter now includes the policy clause since valid entries exist.
expect(findExceptionListItemMock).toHaveBeenCalledWith({
listId: 'endpoint_trusted_apps',
page: 1,
perPage: 1,
namespaceType: 'agnostic',
filter: expect.any(String),
filter:
'(exception-list-agnostic.attributes.tags:"policy:abc123" OR exception-list-agnostic.attributes.tags:"policy:all") AND exception-list-agnostic.attributes.entries.value:"example-value"',
sortField: 'created_at',
sortOrder: 'desc',
});
expect(result).toBe(true);
});
it('should return false if no valid entries exist even when a policy id is provided', async () => {
const endpointMetadataClientMock = {
getHostMetadata: jest
.fn()
.mockResolvedValue({ Endpoint: { policy: { applied: { id: 'abc123' } } } }),
};
const exceptionListsClientMock = {
findExceptionListItem: jest.fn(),
};
// Here the entry field is not valid, so generateTrustedAppsFilter returns an empty string.
const insight = getDefaultInsight({
type: DefendInsightType.Enum.incompatible_antivirus,
remediation: {
exception_list_items: [
{
entries: [
{
field: 'unknown-field',
operator: 'included',
type: 'match',
value: 'example-value',
},
],
},
],
},
target: { ids: ['host-id'] },
} as Partial<SecurityWorkflowInsight>);
const result = await checkIfRemediationExists({
insight,
exceptionListsClient: exceptionListsClientMock as unknown as ExceptionListClient,
endpointMetadataClient: endpointMetadataClientMock as unknown as EndpointMetadataService,
});
// No valid remediation filter was created, so the exception list client should not be called.
expect(result).toBe(false);
expect(exceptionListsClientMock.findExceptionListItem).not.toHaveBeenCalled();
});
});
describe('getValidCodeSignature', () => {

View file

@ -180,45 +180,62 @@ 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 '';
export const generateTrustedAppsFilter = (
insight: SecurityWorkflowInsight,
packagePolicyId: string
): string | undefined => {
const filterParts =
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.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}*)`;
}
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 ');
return '';
})
)
.filter(Boolean) || [];
// Only create a filter if there are valid entries
if (filterParts.length) {
const combinedFilter = filterParts.join(' AND ');
const policyFilter = `(exception-list-agnostic.attributes.tags:"policy:${packagePolicyId}" OR exception-list-agnostic.attributes.tags:"policy:all")`;
return `${policyFilter} AND ${combinedFilter}`;
}
return undefined;
};
export const checkIfRemediationExists = async ({
insight,
exceptionListsClient,
endpointMetadataClient,
}: {
insight: SecurityWorkflowInsight;
exceptionListsClient: ExceptionListClient;
endpointMetadataClient: EndpointMetadataService;
}): Promise<boolean> => {
if (insight.type !== DefendInsightType.Enum.incompatible_antivirus) {
return false;
}
const filter = generateTrustedAppsFilter(insight);
// One endpoint only for incompatible antivirus insights
const hostMetadata = await endpointMetadataClient.getHostMetadata(insight.target.ids[0]);
const filter = generateTrustedAppsFilter(insight, hostMetadata.Endpoint.policy.applied.id);
if (!filter) return false;

View file

@ -150,6 +150,7 @@ class SecurityWorkflowInsightsService {
const remediationExists = await checkIfRemediationExists({
insight,
exceptionListsClient: this.endpointContext.getExceptionListsClient(),
endpointMetadataClient: this.endpointContext.getEndpointMetadataService(),
});
if (remediationExists) {