mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
b750d46c8b
commit
8831e5b25d
3 changed files with 122 additions and 42 deletions
|
@ -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', () => {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -150,6 +150,7 @@ class SecurityWorkflowInsightsService {
|
|||
const remediationExists = await checkIfRemediationExists({
|
||||
insight,
|
||||
exceptionListsClient: this.endpointContext.getExceptionListsClient(),
|
||||
endpointMetadataClient: this.endpointContext.getEndpointMetadataService(),
|
||||
});
|
||||
|
||||
if (remediationExists) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue