Skip to content

Commit db87cd9

Browse files
[9.0] [EDR Workflows] Workflow Insights - filter trusted apps by policy (#209340) (#210142)
# Backport This will backport the following commits from `main` to `9.0`: - [[EDR Workflows] Workflow Insights - filter trusted apps by policy (#209340)](#209340) <!--- Backport version: 9.4.3 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Konrad Szwarc","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-02-07T08:48:21Z","message":"[EDR Workflows] Workflow Insights - filter trusted apps by policy (#209340)\n\nThis PR updates the logic for determining whether an Insight has already\nbeen addressed by Trusted Apps. While we’ve been querying Trusted Apps\nbased on the Insight’s reported path and, for Windows and macOS, the\nsignature, this approach had a limitation: it didn’t account for cases\nwhere a matching Trusted App existed but was assigned to a policy\nunrelated to the endpoint where the Insight was generated.\n\nTo address this, we’ve extended the query to include an additional\nfilter for the specific policy ID associated with the endpoint, as well\nas any global policies (policy:all).\n\n\nhttps://github.com/user-attachments/assets/96470d0b-b7ea-4f59-af0a-e865ad7fd22c","sha":"8831e5b25d7151398e219539664530c3eec916ef","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","Team:Defend Workflows","backport:prev-minor","v9.1.0"],"title":"[EDR Workflows] Workflow Insights - filter trusted apps by policy","number":209340,"url":"https://github.com/elastic/kibana/pull/209340","mergeCommit":{"message":"[EDR Workflows] Workflow Insights - filter trusted apps by policy (#209340)\n\nThis PR updates the logic for determining whether an Insight has already\nbeen addressed by Trusted Apps. While we’ve been querying Trusted Apps\nbased on the Insight’s reported path and, for Windows and macOS, the\nsignature, this approach had a limitation: it didn’t account for cases\nwhere a matching Trusted App existed but was assigned to a policy\nunrelated to the endpoint where the Insight was generated.\n\nTo address this, we’ve extended the query to include an additional\nfilter for the specific policy ID associated with the endpoint, as well\nas any global policies (policy:all).\n\n\nhttps://github.com/user-attachments/assets/96470d0b-b7ea-4f59-af0a-e865ad7fd22c","sha":"8831e5b25d7151398e219539664530c3eec916ef"}},"sourceBranch":"main","suggestedTargetBranches":["9.0"],"targetPullRequestStates":[{"branch":"9.0","label":"v9.0.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/209340","number":209340,"mergeCommit":{"message":"[EDR Workflows] Workflow Insights - filter trusted apps by policy (#209340)\n\nThis PR updates the logic for determining whether an Insight has already\nbeen addressed by Trusted Apps. While we’ve been querying Trusted Apps\nbased on the Insight’s reported path and, for Windows and macOS, the\nsignature, this approach had a limitation: it didn’t account for cases\nwhere a matching Trusted App existed but was assigned to a policy\nunrelated to the endpoint where the Insight was generated.\n\nTo address this, we’ve extended the query to include an additional\nfilter for the specific policy ID associated with the endpoint, as well\nas any global policies (policy:all).\n\n\nhttps://github.com/user-attachments/assets/96470d0b-b7ea-4f59-af0a-e865ad7fd22c","sha":"8831e5b25d7151398e219539664530c3eec916ef"}}]}] BACKPORT--> Co-authored-by: Konrad Szwarc <[email protected]>
1 parent a407578 commit db87cd9

File tree

3 files changed

+125
-45
lines changed

3 files changed

+125
-45
lines changed

x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.test.ts

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,6 @@ describe('helpers', () => {
267267
expect(result).toBe(expectedHash);
268268
});
269269
});
270-
271270
describe('generateTrustedAppsFilter', () => {
272271
it('should generate a filter for process.executable.caseless entries', () => {
273272
const insight = getDefaultInsight({
@@ -287,9 +286,10 @@ describe('helpers', () => {
287286
},
288287
} as Partial<SecurityWorkflowInsight>);
289288

290-
const filter = generateTrustedAppsFilter(insight);
291-
292-
expect(filter).toBe('exception-list-agnostic.attributes.entries.value:"example-value"');
289+
const filter = generateTrustedAppsFilter(insight, 'test-id');
290+
expect(filter).toBe(
291+
'(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"'
292+
);
293293
});
294294

295295
it('should generate a filter for process.code_signature entries', () => {
@@ -310,10 +310,9 @@ describe('helpers', () => {
310310
},
311311
} as Partial<SecurityWorkflowInsight>);
312312

313-
const filter = generateTrustedAppsFilter(insight);
314-
315-
expect(filter).toContain(
316-
'exception-list-agnostic.attributes.entries.entries.value:(*Example,*Inc.*)'
313+
const filter = generateTrustedAppsFilter(insight, 'test-id');
314+
expect(filter).toBe(
315+
'(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.*)'
317316
);
318317
});
319318

@@ -341,14 +340,13 @@ describe('helpers', () => {
341340
},
342341
} as Partial<SecurityWorkflowInsight>);
343342

344-
const filter = generateTrustedAppsFilter(insight);
345-
346-
expect(filter).toContain(
347-
'exception-list-agnostic.attributes.entries.entries.value:(*Example,*\\(Inc.\\)*http\\://example.com*[example]*) AND exception-list-agnostic.attributes.entries.value:"example-value"'
343+
const filter = generateTrustedAppsFilter(insight, 'test-id');
344+
expect(filter).toBe(
345+
'(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"'
348346
);
349347
});
350348

351-
it('should return empty string if no valid entries are present', () => {
349+
it('should return undefined if no valid entries are present', () => {
352350
const insight = getDefaultInsight({
353351
remediation: {
354352
exception_list_items: [
@@ -366,9 +364,8 @@ describe('helpers', () => {
366364
},
367365
} as Partial<SecurityWorkflowInsight>);
368366

369-
const filter = generateTrustedAppsFilter(insight);
370-
371-
expect(filter).toBe('');
367+
const filter = generateTrustedAppsFilter(insight, 'test-id');
368+
expect(filter).toBe(undefined);
372369
});
373370
});
374371

@@ -378,17 +375,34 @@ describe('helpers', () => {
378375
type: 'other-type' as DefendInsightType,
379376
});
380377

378+
// For non-incompatible_antivirus types, getHostMetadata should not be called.
379+
const endpointMetadataClientMock = {
380+
getHostMetadata: jest.fn(),
381+
};
382+
const exceptionListsClientMock = {
383+
findExceptionListItem: jest.fn(),
384+
};
385+
381386
const result = await checkIfRemediationExists({
382387
insight,
383-
exceptionListsClient: jest.fn() as unknown as ExceptionListClient,
388+
exceptionListsClient: exceptionListsClientMock as unknown as ExceptionListClient,
389+
endpointMetadataClient: endpointMetadataClientMock as unknown as EndpointMetadataService,
384390
});
385391

386392
expect(result).toBe(false);
393+
expect(endpointMetadataClientMock.getHostMetadata).not.toHaveBeenCalled();
387394
});
388395

389-
it('should call exceptionListsClient with the correct filter', async () => {
396+
it('should call exceptionListsClient with the correct filter when valid entries exist', async () => {
390397
const findExceptionListItemMock = jest.fn().mockResolvedValue({ total: 1 });
398+
const endpointMetadataClientMock = {
399+
getHostMetadata: jest
400+
.fn()
401+
.mockResolvedValue({ Endpoint: { policy: { applied: { id: 'abc123' } } } }),
402+
};
403+
391404
const insight = getDefaultInsight({
405+
type: DefendInsightType.Enum.incompatible_antivirus,
392406
remediation: {
393407
exception_list_items: [
394408
{
@@ -403,26 +417,74 @@ describe('helpers', () => {
403417
},
404418
],
405419
},
420+
target: { ids: ['host-id'] },
406421
} as Partial<SecurityWorkflowInsight>);
407422

408423
const result = await checkIfRemediationExists({
409424
insight,
410425
exceptionListsClient: {
411426
findExceptionListItem: findExceptionListItemMock,
412427
} as unknown as ExceptionListClient,
428+
endpointMetadataClient: endpointMetadataClientMock as unknown as EndpointMetadataService,
413429
});
414430

431+
// Ensure the metadata was fetched using the host id
432+
expect(endpointMetadataClientMock.getHostMetadata).toHaveBeenCalledWith('host-id');
433+
434+
// Expected filter now includes the policy clause since valid entries exist.
415435
expect(findExceptionListItemMock).toHaveBeenCalledWith({
416436
listId: 'endpoint_trusted_apps',
417437
page: 1,
418438
perPage: 1,
419439
namespaceType: 'agnostic',
420-
filter: expect.any(String),
440+
filter:
441+
'(exception-list-agnostic.attributes.tags:"policy:abc123" OR exception-list-agnostic.attributes.tags:"policy:all") AND exception-list-agnostic.attributes.entries.value:"example-value"',
421442
sortField: 'created_at',
422443
sortOrder: 'desc',
423444
});
424445
expect(result).toBe(true);
425446
});
447+
448+
it('should return false if no valid entries exist even when a policy id is provided', async () => {
449+
const endpointMetadataClientMock = {
450+
getHostMetadata: jest
451+
.fn()
452+
.mockResolvedValue({ Endpoint: { policy: { applied: { id: 'abc123' } } } }),
453+
};
454+
const exceptionListsClientMock = {
455+
findExceptionListItem: jest.fn(),
456+
};
457+
458+
// Here the entry field is not valid, so generateTrustedAppsFilter returns an empty string.
459+
const insight = getDefaultInsight({
460+
type: DefendInsightType.Enum.incompatible_antivirus,
461+
remediation: {
462+
exception_list_items: [
463+
{
464+
entries: [
465+
{
466+
field: 'unknown-field',
467+
operator: 'included',
468+
type: 'match',
469+
value: 'example-value',
470+
},
471+
],
472+
},
473+
],
474+
},
475+
target: { ids: ['host-id'] },
476+
} as Partial<SecurityWorkflowInsight>);
477+
478+
const result = await checkIfRemediationExists({
479+
insight,
480+
exceptionListsClient: exceptionListsClientMock as unknown as ExceptionListClient,
481+
endpointMetadataClient: endpointMetadataClientMock as unknown as EndpointMetadataService,
482+
});
483+
484+
// No valid remediation filter was created, so the exception list client should not be called.
485+
expect(result).toBe(false);
486+
expect(exceptionListsClientMock.findExceptionListItem).not.toHaveBeenCalled();
487+
});
426488
});
427489

428490
describe('getValidCodeSignature', () => {

x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/helpers.ts

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -180,45 +180,62 @@ export function getUniqueInsights(insights: SecurityWorkflowInsight[]): Security
180180
return Object.values(uniqueInsights);
181181
}
182182

183-
export const generateTrustedAppsFilter = (insight: SecurityWorkflowInsight): string | undefined => {
184-
return insight.remediation.exception_list_items
185-
?.flatMap((item) =>
186-
item.entries.map((entry) => {
187-
if (!('value' in entry)) return '';
188-
189-
if (entry.field === 'process.executable.caseless') {
190-
return `exception-list-agnostic.attributes.entries.value:"${entry.value}"`;
191-
}
192-
193-
if (
194-
entry.field === 'process.code_signature' ||
195-
(entry.field === 'process.Ext.code_signature' && typeof entry.value === 'string')
196-
) {
197-
const sanitizedValue = (entry.value as string)
198-
.replace(/[)(<>}{":\\]/gm, '\\$&')
199-
.replace(/\s/gm, '*');
200-
return `exception-list-agnostic.attributes.entries.entries.value:(*${sanitizedValue}*)`;
201-
}
202-
203-
return '';
204-
})
205-
)
206-
.filter(Boolean)
207-
.join(' AND ');
183+
export const generateTrustedAppsFilter = (
184+
insight: SecurityWorkflowInsight,
185+
packagePolicyId: string
186+
): string | undefined => {
187+
const filterParts =
188+
insight.remediation.exception_list_items
189+
?.flatMap((item) =>
190+
item.entries.map((entry) => {
191+
if (!('value' in entry)) return '';
192+
193+
if (entry.field === 'process.executable.caseless') {
194+
return `exception-list-agnostic.attributes.entries.value:"${entry.value}"`;
195+
}
196+
197+
if (
198+
entry.field === 'process.code_signature' ||
199+
(entry.field === 'process.Ext.code_signature' && typeof entry.value === 'string')
200+
) {
201+
const sanitizedValue = (entry.value as string)
202+
.replace(/[)(<>}{":\\]/gm, '\\$&')
203+
.replace(/\s/gm, '*');
204+
return `exception-list-agnostic.attributes.entries.entries.value:(*${sanitizedValue}*)`;
205+
}
206+
207+
return '';
208+
})
209+
)
210+
.filter(Boolean) || [];
211+
212+
// Only create a filter if there are valid entries
213+
if (filterParts.length) {
214+
const combinedFilter = filterParts.join(' AND ');
215+
const policyFilter = `(exception-list-agnostic.attributes.tags:"policy:${packagePolicyId}" OR exception-list-agnostic.attributes.tags:"policy:all")`;
216+
return `${policyFilter} AND ${combinedFilter}`;
217+
}
218+
219+
return undefined;
208220
};
209221

210222
export const checkIfRemediationExists = async ({
211223
insight,
212224
exceptionListsClient,
225+
endpointMetadataClient,
213226
}: {
214227
insight: SecurityWorkflowInsight;
215228
exceptionListsClient: ExceptionListClient;
229+
endpointMetadataClient: EndpointMetadataService;
216230
}): Promise<boolean> => {
217231
if (insight.type !== DefendInsightType.Enum.incompatible_antivirus) {
218232
return false;
219233
}
220234

221-
const filter = generateTrustedAppsFilter(insight);
235+
// One endpoint only for incompatible antivirus insights
236+
const hostMetadata = await endpointMetadataClient.getHostMetadata(insight.target.ids[0]);
237+
238+
const filter = generateTrustedAppsFilter(insight, hostMetadata.Endpoint.policy.applied.id);
222239

223240
if (!filter) return false;
224241

x-pack/solutions/security/plugins/security_solution/server/endpoint/services/workflow_insights/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ class SecurityWorkflowInsightsService {
150150
const remediationExists = await checkIfRemediationExists({
151151
insight,
152152
exceptionListsClient: this.endpointContext.getExceptionListsClient(),
153+
endpointMetadataClient: this.endpointContext.getEndpointMetadataService(),
153154
});
154155

155156
if (remediationExists) {

0 commit comments

Comments
 (0)