Skip to content

Commit

Permalink
[EDR Workflows] Workflow Insights - filter trusted apps by policy (el…
Browse files Browse the repository at this point in the history
…astic#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
  • Loading branch information
szwarckonrad authored Feb 7, 2025
1 parent b750d46 commit 8831e5b
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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', () => {
Expand All @@ -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.*)'
);
});

Expand Down Expand Up @@ -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: [
Expand All @@ -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);
});
});

Expand All @@ -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: [
{
Expand All @@ -403,26 +417,74 @@ describe('helpers', () => {
},
],
},
target: { ids: ['host-id'] },
} as Partial<SecurityWorkflowInsight>);

const result = await checkIfRemediationExists({
insight,
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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';

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 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.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) || [];

// 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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ class SecurityWorkflowInsightsService {
const remediationExists = await checkIfRemediationExists({
insight,
exceptionListsClient: this.endpointContext.getExceptionListsClient(),
endpointMetadataClient: this.endpointContext.getEndpointMetadataService(),
});

if (remediationExists) {
Expand Down

0 comments on commit 8831e5b

Please sign in to comment.