[EDR Workflows] Add matches capabilities to Endpoint Exception creation (#166002)

## Summary

This PR adds `matches` (`wildcard include`) and `does not match`
(`wildcard exclude`) to fields which support them when creating an
Endpoint exception.

For backwards compatibility with Endpoints < 8.2.0, Manifest Manager
adds the following entry to Endpoint Exceptions containing _only_
wildcards:
```json
{
  "field": "event.module",
  "operator": "included",
  "type": "exact_cased",
  "value": "endpoint"
}
```

> [!Note]
> Warnings for wrongly formatted wildcards don't seem to work correctly
at the moment. #170495 will bring some changes in the related functions,
so this PR is waiting on that to be merged.


<img width="1465" alt="image"
src="db04fe0b-4cb3-4cba-a6d7-622a2239f059">

## Sample manifests
### Linux
⚠️ On Linux, the type is always `wildcard_cased`, see the following
comment for details:
https://github.com/elastic/kibana/pull/120349#issuecomment-989963682
```json
{
  "entries": [
    {
      "type": "simple",
      "entries": [
        {
          "field": "file.path",
          "operator": "included",
          "type": "wildcard_cased",
          "value": "*/test/*"
        },
        {
          "field": "event.module",
          "operator": "included",
          "type": "exact_cased",
          "value": "endpoint"
        }
      ]
    }
  ]
}
```

### Windows
```json
{
  "entries": [
    {
      "type": "simple",
      "entries": [
        {
          "field": "file.path",
          "operator": "included",
          "type": "wildcard_caseless",
          "value": "*/test/*"
        },
        {
          "field": "event.module",
          "operator": "included",
          "type": "exact_cased",
          "value": "endpoint"
        }
      ]
    }
  ]
}
```

### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Gergő Ábrahám 2023-12-05 09:59:45 +01:00 committed by GitHub
parent cc524d1d6d
commit 44d7c0ae95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 507 additions and 121 deletions

View file

@ -23,16 +23,14 @@ describe('endpointEntryMatchWildcard', () => {
expect(message.schema).toEqual(payload);
});
test('it should NOT validate when "operator" is "excluded"', () => {
test('it should validate when "operator" is "excluded"', () => {
const payload = getEntryMatchWildcardMock();
payload.operator = 'excluded';
const decoded = endpointEntryMatchWildcard.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "excluded" supplied to "operator"',
]);
expect(message.schema).toEqual({});
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should FAIL validation when "field" is empty string', () => {

View file

@ -7,12 +7,16 @@
*/
import * as t from 'io-ts';
import { NonEmptyString, operatorIncluded } from '@kbn/securitysolution-io-ts-types';
import {
NonEmptyString,
operatorExcluded,
operatorIncluded,
} from '@kbn/securitysolution-io-ts-types';
export const endpointEntryMatchWildcard = t.exact(
t.type({
field: NonEmptyString,
operator: operatorIncluded,
operator: t.union([operatorIncluded, operatorExcluded]),
type: t.keyof({ wildcard: null }),
value: NonEmptyString,
})

View file

@ -9,6 +9,7 @@
import * as t from 'io-ts';
export const operatorIncluded = t.keyof({ included: null });
export const operatorExcluded = t.keyof({ excluded: null });
export const operator = t.keyof({
equals: null,

View file

@ -48,6 +48,8 @@ import {
isNotOneOfOperator,
isInListOperator,
isNotInListOperator,
matchesOperator,
doesNotMatchOperator,
} from '../autocomplete_operators';
import {
@ -696,8 +698,14 @@ export const getOperatorOptions = (
): OperatorOption[] => {
if (item.nested === 'parent' || item.field == null) {
return [isOperator];
} else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') {
return isBoolean ? [isOperator] : [isOperator, isOneOfOperator];
} else if (listType === 'endpoint') {
if (isBoolean) {
return [isOperator];
} else {
return fieldSupportsMatches(item.field)
? [isOperator, isOneOfOperator, matchesOperator, doesNotMatchOperator]
: [isOperator, isOneOfOperator];
}
} else if (item.nested != null && listType === 'detection') {
return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator];
} else if (isBoolean) {

View file

@ -360,7 +360,7 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
};
// show this when wildcard with matches operator
const getWildcardWarningInfo = (precedingWarning: string): React.ReactNode => {
const getEventFilterWildcardWarningInfo = (precedingWarning: string): React.ReactNode => {
return (
<p>
{precedingWarning}{' '}
@ -437,7 +437,9 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
value: wildcardValue,
});
actualWarning =
warning === WILDCARD_WARNING ? warning && getWildcardWarningInfo(warning) : warning;
warning === WILDCARD_WARNING && listType === 'endpoint_events'
? getEventFilterWildcardWarningInfo(warning)
: warning;
}
return (

View file

@ -25,6 +25,7 @@ import {
FormattedBuilderEntry,
OperatorOption,
doesNotExistOperator,
doesNotMatchOperator,
existsOperator,
filterExceptionItems,
getCorrespondingKeywordField,
@ -50,6 +51,7 @@ import {
isNotOperator,
isOneOfOperator,
isOperator,
matchesOperator,
} from '@kbn/securitysolution-list-utils';
import { DataViewBase, DataViewFieldBase } from '@kbn/es-query';
import { fields, getField } from '@kbn/data-plugin/common/mocks';
@ -509,25 +511,57 @@ describe('Exception builder helpers', () => {
expect(output).toEqual(expected);
});
test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => {
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
const output = getOperatorOptions(payloadItem, 'endpoint', false);
const expected: OperatorOption[] = [isOperator, isOneOfOperator];
expect(output).toEqual(expected);
});
describe('"endpoint" list type', () => {
test('it returns operators "is", "isOneOf", "matches" and "doesNotMatch" if item is nested and field supports "matches"', () => {
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
const output = getOperatorOptions(payloadItem, 'endpoint', false);
const expected: OperatorOption[] = [
isOperator,
isOneOfOperator,
matchesOperator,
doesNotMatchOperator,
];
expect(output).toEqual(expected);
});
test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => {
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
const output = getOperatorOptions(payloadItem, 'endpoint', false);
const expected: OperatorOption[] = [isOperator, isOneOfOperator];
expect(output).toEqual(expected);
});
test('it returns operators "is" and "isOneOf" if item is nested and field does not support "matches"', () => {
const payloadItem: FormattedBuilderEntry = {
...getMockNestedBuilderEntry(),
field: getField('ip'),
};
const output = getOperatorOptions(payloadItem, 'endpoint', false);
const expected: OperatorOption[] = [isOperator, isOneOfOperator];
expect(output).toEqual(expected);
});
test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => {
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
const output = getOperatorOptions(payloadItem, 'endpoint', true);
const expected: OperatorOption[] = [isOperator];
expect(output).toEqual(expected);
test('it returns operators "is", "isOneOf", "matches" and "doesNotMatch" if field supports "matches"', () => {
const payloadItem: FormattedBuilderEntry = {
...getMockBuilderEntry(),
field: getField('@tags'),
};
const output = getOperatorOptions(payloadItem, 'endpoint', false);
const expected: OperatorOption[] = [
isOperator,
isOneOfOperator,
matchesOperator,
doesNotMatchOperator,
];
expect(output).toEqual(expected);
});
test('it returns "isOperator" and "isOneOfOperator" if field does not support "matches"', () => {
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
const output = getOperatorOptions(payloadItem, 'endpoint', false);
const expected: OperatorOption[] = [isOperator, isOneOfOperator];
expect(output).toEqual(expected);
});
test('it returns "isOperator" and field type is boolean', () => {
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
const output = getOperatorOptions(payloadItem, 'endpoint', true);
const expected: OperatorOption[] = [isOperator];
expect(output).toEqual(expected);
});
});
test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => {

View file

@ -6,13 +6,7 @@
*/
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import {
ENDPOINT_BLOCKLISTS_LIST_ID,
ENDPOINT_EVENT_FILTERS_LIST_ID,
ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID,
ENDPOINT_LIST_ID,
ENDPOINT_TRUSTED_APPS_LIST_ID,
} from '@kbn/securitysolution-list-constants';
import { ENDPOINT_LIST_ID, ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import type { PackagePolicy } from '@kbn/fleet-plugin/common/types/models';
import { getEmptyInternalArtifactMock } from '../../../schemas/artifacts/saved_objects.mock';
@ -82,6 +76,14 @@ describe('ManifestManager', () => {
const ARTIFACT_NAME_BLOCKLISTS_WINDOWS = 'endpoint-blocklist-windows-v1';
const ARTIFACT_NAME_BLOCKLISTS_LINUX = 'endpoint-blocklist-linux-v1';
const mockPolicyListIdsResponse = (items: string[]) =>
jest.fn().mockResolvedValue({
items,
page: 1,
per_page: 100,
total: items.length,
});
let ARTIFACTS: InternalArtifactCompleteSchema[] = [];
let ARTIFACTS_BY_ID: { [K: string]: InternalArtifactCompleteSchema } = {};
let ARTIFACT_EXCEPTIONS_MACOS: InternalArtifactCompleteSchema;
@ -311,14 +313,6 @@ describe('ManifestManager', () => {
...new Set(artifacts.map((artifact) => artifact.identifier)).values(),
];
const mockPolicyListIdsResponse = (items: string[]) =>
jest.fn().mockResolvedValue({
items,
page: 1,
per_page: 100,
total: items.length,
});
test('Fails when exception list client fails', async () => {
const context = buildManifestManagerContextMock({});
const manifestManager = new ManifestManager(context);
@ -383,10 +377,12 @@ describe('ManifestManager', () => {
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
[ENDPOINT_LIST_ID]: { macos: [exceptionListItem] },
[ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] },
[ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] },
[ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] },
[ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] },
[ENDPOINT_ARTIFACT_LISTS.trustedApps.id]: { linux: [trustedAppListItem] },
[ENDPOINT_ARTIFACT_LISTS.eventFilters.id]: { linux: [eventFiltersListItem] },
[ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id]: {
linux: [hostIsolationExceptionsItem],
},
[ENDPOINT_ARTIFACT_LISTS.blocklists.id]: { linux: [blocklistsListItem] },
});
context.savedObjectsClient.create = jest
.fn()
@ -474,10 +470,12 @@ describe('ManifestManager', () => {
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
[ENDPOINT_LIST_ID]: { macos: [exceptionListItem] },
[ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] },
[ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] },
[ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] },
[ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] },
[ENDPOINT_ARTIFACT_LISTS.trustedApps.id]: { linux: [trustedAppListItem] },
[ENDPOINT_ARTIFACT_LISTS.eventFilters.id]: { linux: [eventFiltersListItem] },
[ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id]: {
linux: [hostIsolationExceptionsItem],
},
[ENDPOINT_ARTIFACT_LISTS.blocklists.id]: { linux: [blocklistsListItem] },
});
const manifest = await manifestManager.buildNewManifest(oldManifest);
@ -557,17 +555,21 @@ describe('ManifestManager', () => {
macos: [exceptionListItem, exceptionListItem],
windows: [duplicatedEndpointExceptionInDifferentOS],
},
[ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem, trustedAppListItem] },
[ENDPOINT_EVENT_FILTERS_LIST_ID]: {
[ENDPOINT_ARTIFACT_LISTS.trustedApps.id]: {
linux: [trustedAppListItem, trustedAppListItem],
},
[ENDPOINT_ARTIFACT_LISTS.eventFilters.id]: {
windows: [eventFiltersListItem, duplicatedEventFilterInDifferentPolicy],
},
[ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: {
[ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id]: {
linux: [
hostIsolationExceptionsItem,
{ ...hostIsolationExceptionsItem, tags: [`policy:${TEST_POLICY_ID_2}`] },
],
},
[ENDPOINT_BLOCKLISTS_LIST_ID]: { macos: [blocklistsListItem, blocklistsListItem] },
[ENDPOINT_ARTIFACT_LISTS.blocklists.id]: {
macos: [blocklistsListItem, blocklistsListItem],
},
});
context.savedObjectsClient.create = jest
.fn()
@ -673,7 +675,7 @@ describe('ManifestManager', () => {
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
[ENDPOINT_LIST_ID]: { macos: [exceptionListItem] },
[ENDPOINT_TRUSTED_APPS_LIST_ID]: {
[ENDPOINT_ARTIFACT_LISTS.trustedApps.id]: {
linux: [trustedAppListItem, trustedAppListItemPolicy2],
},
});
@ -756,14 +758,6 @@ describe('ManifestManager', () => {
...new Set(artifacts.map((artifact) => artifact.identifier)).values(),
];
const mockPolicyListIdsResponse = (items: string[]) =>
jest.fn().mockResolvedValue({
items,
page: 1,
per_page: 100,
total: items.length,
});
test('when it has endpoint artifact management app feature it should not generate host isolation exceptions', async () => {
const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] });
const trustedAppListItem = getExceptionListItemSchemaMock({
@ -789,10 +783,12 @@ describe('ManifestManager', () => {
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
[ENDPOINT_LIST_ID]: { macos: [exceptionListItem] },
[ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] },
[ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] },
[ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] },
[ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] },
[ENDPOINT_ARTIFACT_LISTS.trustedApps.id]: { linux: [trustedAppListItem] },
[ENDPOINT_ARTIFACT_LISTS.eventFilters.id]: { linux: [eventFiltersListItem] },
[ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id]: {
linux: [hostIsolationExceptionsItem],
},
[ENDPOINT_ARTIFACT_LISTS.blocklists.id]: { linux: [blocklistsListItem] },
});
context.savedObjectsClient.create = jest
.fn()
@ -870,10 +866,12 @@ describe('ManifestManager', () => {
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
[ENDPOINT_LIST_ID]: { macos: [exceptionListItem] },
[ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] },
[ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] },
[ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] },
[ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] },
[ENDPOINT_ARTIFACT_LISTS.trustedApps.id]: { linux: [trustedAppListItem] },
[ENDPOINT_ARTIFACT_LISTS.eventFilters.id]: { linux: [eventFiltersListItem] },
[ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id]: {
linux: [hostIsolationExceptionsItem],
},
[ENDPOINT_ARTIFACT_LISTS.blocklists.id]: { linux: [blocklistsListItem] },
});
context.savedObjectsClient.create = jest
.fn()
@ -950,10 +948,12 @@ describe('ManifestManager', () => {
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
[ENDPOINT_LIST_ID]: { macos: [exceptionListItem] },
[ENDPOINT_TRUSTED_APPS_LIST_ID]: { linux: [trustedAppListItem] },
[ENDPOINT_EVENT_FILTERS_LIST_ID]: { linux: [eventFiltersListItem] },
[ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID]: { linux: [hostIsolationExceptionsItem] },
[ENDPOINT_BLOCKLISTS_LIST_ID]: { linux: [blocklistsListItem] },
[ENDPOINT_ARTIFACT_LISTS.trustedApps.id]: { linux: [trustedAppListItem] },
[ENDPOINT_ARTIFACT_LISTS.eventFilters.id]: { linux: [eventFiltersListItem] },
[ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id]: {
linux: [hostIsolationExceptionsItem],
},
[ENDPOINT_ARTIFACT_LISTS.blocklists.id]: { linux: [blocklistsListItem] },
});
context.savedObjectsClient.create = jest
.fn()
@ -998,6 +998,94 @@ describe('ManifestManager', () => {
});
});
describe('buildNewManifest when Endpoint Exceptions contain `matches`', () => {
test('when contains only `wildcard`, `event.module=endpoint` is added', async () => {
const exceptionListItem = getExceptionListItemSchemaMock({
os_types: ['macos'],
entries: [
{ type: 'wildcard', operator: 'included', field: 'path', value: '*match_me*' },
{ type: 'wildcard', operator: 'excluded', field: 'not_path', value: '*dont_match_me*' },
],
});
const expectedExceptionListItem = getExceptionListItemSchemaMock({
os_types: ['macos'],
entries: [
...exceptionListItem.entries,
{ type: 'match', operator: 'included', field: 'event.module', value: 'endpoint' },
],
});
const context = buildManifestManagerContextMock({});
const manifestManager = new ManifestManager(context);
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
[ENDPOINT_LIST_ID]: { macos: [exceptionListItem] },
});
context.savedObjectsClient.create = jest
.fn()
.mockImplementation((_type: string, object: InternalManifestSchema) => ({
attributes: object,
}));
context.packagePolicyService.listIds = mockPolicyListIdsResponse([TEST_POLICY_ID_1]);
const manifest = await manifestManager.buildNewManifest();
expect(manifest?.getSchemaVersion()).toStrictEqual('v1');
expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0');
expect(manifest?.getSavedObjectVersion()).toBeUndefined();
const artifacts = manifest.getAllArtifacts();
expect(artifacts.length).toBe(15);
expect(getArtifactObject(artifacts[0])).toStrictEqual({
entries: translateToEndpointExceptions([expectedExceptionListItem], 'v1'),
});
});
test('when contains anything next to `wildcard`, nothing is added', async () => {
const exceptionListItem = getExceptionListItemSchemaMock({
os_types: ['macos'],
entries: [
{ type: 'wildcard', operator: 'included', field: 'path', value: '*match_me*' },
{ type: 'wildcard', operator: 'excluded', field: 'path', value: '*dont_match_me*' },
{ type: 'match', operator: 'included', field: 'path', value: 'something' },
],
});
const expectedExceptionListItem = getExceptionListItemSchemaMock({
os_types: ['macos'],
entries: [...exceptionListItem.entries],
});
const context = buildManifestManagerContextMock({});
const manifestManager = new ManifestManager(context);
context.exceptionListClient.findExceptionListItem = mockFindExceptionListItemResponses({
[ENDPOINT_LIST_ID]: { macos: [exceptionListItem] },
});
context.savedObjectsClient.create = jest
.fn()
.mockImplementation((_type: string, object: InternalManifestSchema) => ({
attributes: object,
}));
context.packagePolicyService.listIds = mockPolicyListIdsResponse([TEST_POLICY_ID_1]);
const manifest = await manifestManager.buildNewManifest();
expect(manifest?.getSchemaVersion()).toStrictEqual('v1');
expect(manifest?.getSemanticVersion()).toStrictEqual('1.0.0');
expect(manifest?.getSavedObjectVersion()).toBeUndefined();
const artifacts = manifest.getAllArtifacts();
expect(artifacts.length).toBe(15);
expect(getArtifactObject(artifacts[0])).toStrictEqual({
entries: translateToEndpointExceptions([expectedExceptionListItem], 'v1'),
});
});
});
describe('deleteArtifacts', () => {
test('Successfully invokes saved objects client', async () => {
const context = buildManifestManagerContextMock({});
@ -1465,14 +1553,6 @@ describe('ManifestManager', () => {
});
describe('cleanup artifacts', () => {
const mockPolicyListIdsResponse = (items: string[]) =>
jest.fn().mockResolvedValue({
items,
page: 1,
per_page: 100,
total: items.length,
});
test('Successfully removes orphan artifacts', async () => {
const context = buildManifestManagerContextMock({});
const manifestManager = new ManifestManager(context);

View file

@ -9,13 +9,7 @@ import semver from 'semver';
import { chunk, isEmpty, isEqual, keyBy } from 'lodash';
import type { ElasticsearchClient } from '@kbn/core/server';
import { type Logger, type SavedObjectsClientContract } from '@kbn/core/server';
import {
ENDPOINT_BLOCKLISTS_LIST_ID,
ENDPOINT_EVENT_FILTERS_LIST_ID,
ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID,
ENDPOINT_LIST_ID,
ENDPOINT_TRUSTED_APPS_LIST_ID,
} from '@kbn/securitysolution-list-constants';
import { ENDPOINT_LIST_ID, ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants';
import type { ListResult, PackagePolicy } from '@kbn/fleet-plugin/common';
import type { Artifact, PackagePolicyClient } from '@kbn/fleet-plugin/server';
import type { ExceptionListClient } from '@kbn/lists-plugin/server';
@ -58,6 +52,7 @@ interface ArtifactsBuildResult {
interface BuildArtifactsForOsOptions {
listId: ArtifactListId;
name: string;
exceptionItemDecorator?: (item: ExceptionListItemSchema) => ExceptionListItemSchema;
}
const iterateArtifactsBuildResult = (
@ -159,19 +154,21 @@ export class ManifestManager {
os,
policyId,
schemaVersion,
exceptionItemDecorator,
}: {
elClient: ExceptionListClient;
listId: ArtifactListId;
os: string;
policyId?: string;
schemaVersion: string;
exceptionItemDecorator?: (item: ExceptionListItemSchema) => ExceptionListItemSchema;
}): Promise<WrappedTranslatedExceptionList> {
if (!this.cachedExceptionsListsByOs.has(`${listId}-${os}`)) {
let itemsByListId: ExceptionListItemSchema[] = [];
if (
(listId === ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID &&
(listId === ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id &&
this.appFeaturesService.isEnabled(AppFeatureKey.endpointResponseActions)) ||
(listId !== ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID &&
(listId !== ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id &&
this.appFeaturesService.isEnabled(AppFeatureKey.endpointArtifactManagement))
) {
itemsByListId = await getAllItemsFromEndpointExceptionList({
@ -179,6 +176,10 @@ export class ManifestManager {
os,
listId,
});
if (exceptionItemDecorator) {
itemsByListId = itemsByListId.map(exceptionItemDecorator);
}
}
this.cachedExceptionsListsByOs.set(`${listId}-${os}`, itemsByListId);
}
@ -209,6 +210,7 @@ export class ManifestManager {
name,
os,
policyId,
exceptionItemDecorator,
}: {
os: string;
policyId?: string;
@ -220,6 +222,7 @@ export class ManifestManager {
os,
policyId,
listId,
exceptionItemDecorator,
}),
this.schemaVersion,
os,
@ -261,9 +264,27 @@ export class ManifestManager {
): Promise<ArtifactsBuildResult> {
const defaultArtifacts: InternalArtifactCompleteSchema[] = [];
const policySpecificArtifacts: Record<string, InternalArtifactCompleteSchema[]> = {};
const decorateWildcardOnlyExceptionItem = (item: ExceptionListItemSchema) => {
const isWildcardOnly = item.entries.every(({ type }) => type === 'wildcard');
// add `event.module=endpoint` to make endpoints older than 8.2 work when only `wildcard` is used
if (isWildcardOnly) {
item.entries.push({
type: 'match',
operator: 'included',
field: 'event.module',
value: 'endpoint',
});
}
return item;
};
const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = {
listId: ENDPOINT_LIST_ID,
name: ArtifactConstants.GLOBAL_ALLOWLIST_NAME,
exceptionItemDecorator: decorateWildcardOnlyExceptionItem,
};
for (const os of ArtifactConstants.SUPPORTED_OPERATING_SYSTEMS) {
@ -284,7 +305,7 @@ export class ManifestManager {
protected async buildTrustedAppsArtifacts(allPolicyIds: string[]): Promise<ArtifactsBuildResult> {
const defaultArtifacts: InternalArtifactCompleteSchema[] = [];
const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = {
listId: ENDPOINT_TRUSTED_APPS_LIST_ID,
listId: ENDPOINT_ARTIFACT_LISTS.trustedApps.id,
name: ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME,
};
@ -312,7 +333,7 @@ export class ManifestManager {
): Promise<ArtifactsBuildResult> {
const defaultArtifacts: InternalArtifactCompleteSchema[] = [];
const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = {
listId: ENDPOINT_EVENT_FILTERS_LIST_ID,
listId: ENDPOINT_ARTIFACT_LISTS.eventFilters.id,
name: ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME,
};
@ -338,7 +359,7 @@ export class ManifestManager {
protected async buildBlocklistArtifacts(allPolicyIds: string[]): Promise<ArtifactsBuildResult> {
const defaultArtifacts: InternalArtifactCompleteSchema[] = [];
const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = {
listId: ENDPOINT_BLOCKLISTS_LIST_ID,
listId: ENDPOINT_ARTIFACT_LISTS.blocklists.id,
name: ArtifactConstants.GLOBAL_BLOCKLISTS_NAME,
};
@ -367,7 +388,7 @@ export class ManifestManager {
): Promise<ArtifactsBuildResult> {
const defaultArtifacts: InternalArtifactCompleteSchema[] = [];
const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = {
listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID,
listId: ENDPOINT_ARTIFACT_LISTS.hostIsolationExceptions.id,
name: ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME,
};

View file

@ -13,7 +13,6 @@ import {
ENDPOINT_ARTIFACT_LIST_IDS,
EXCEPTION_LIST_URL,
} from '@kbn/securitysolution-list-constants';
import { ManifestConstants } from '@kbn/security-solution-plugin/server/endpoint/lib/artifacts';
import { ArtifactElasticsearchProperties } from '@kbn/fleet-plugin/server/services';
import { FtrProviderContext } from '../../ftr_provider_context';
import {
@ -21,7 +20,6 @@ import {
getArtifactsListTestsData,
ArtifactActionsType,
AgentPolicyResponseType,
InternalManifestSchemaResponseType,
getCreateMultipleData,
MultipleArtifactActionsType,
} from './mocks';
@ -32,6 +30,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const pageObjects = getPageObjects(['common', 'artifactEntriesList']);
const testSubjects = getService('testSubjects');
const browser = getService('browser');
const endpointArtifactsTestResources = getService('endpointArtifactTestResources');
const endpointTestResources = getService('endpointTestResources');
const retry = getService('retry');
const esClient = getService('es');
@ -86,29 +85,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
// Check edited artifact is in the list with new values (wait for list to be updated)
let updatedArtifact: ArtifactElasticsearchProperties | undefined;
await retry.waitForWithTimeout('fleet artifact is updated', 120_000, async () => {
// Get endpoint manifest
const {
hits: { hits: manifestResults },
} = await esClient.search({
index: '.kibana*',
query: {
bool: {
filter: [
{
term: {
type: ManifestConstants.SAVED_OBJECT_TYPE,
},
},
],
},
},
size: 1,
});
const artifacts = await endpointArtifactsTestResources.getArtifacts();
const manifestResult = manifestResults[0] as InternalManifestSchemaResponseType;
const manifestArtifact = manifestResult._source[
'endpoint:user-artifact-manifest'
].artifacts.find((artifact) => {
const manifestArtifact = artifacts.find((artifact) => {
return (
artifact.artifactId ===
`${expectedArtifact.identifier}-${expectedArtifact.decoded_sha256}` &&

View file

@ -0,0 +1,240 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { unzip } from 'zlib';
import { promisify } from 'util';
import expect from '@kbn/expect';
import { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data';
import { EXCEPTION_LIST_ITEM_URL } from '@kbn/securitysolution-list-constants';
import { ArtifactElasticsearchProperties } from '@kbn/fleet-plugin/server/services';
import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper';
import { FtrProviderContext } from '../../ftr_provider_context';
import { targetTags } from '../../target_tags';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const pageObjects = getPageObjects(['common', 'header']);
const queryBar = getService('queryBar');
const testSubjects = getService('testSubjects');
const endpointTestResources = getService('endpointTestResources');
const endpointArtifactTestResources = getService('endpointArtifactTestResources');
const retry = getService('retry');
const esClient = getService('es');
const supertest = getService('supertest');
const find = getService('find');
const unzipPromisify = promisify(unzip);
describe('Endpoint Exceptions', function () {
targetTags(this, ['@ess', '@serverless']);
this.timeout(10 * 60_000);
const clearPrefilledEntries = async () => {
const entriesContainer = await testSubjects.find('exceptionEntriesContainer');
let deleteButtons: WebElementWrapper[];
do {
deleteButtons = await testSubjects.findAllDescendant(
'builderItemEntryDeleteButton',
entriesContainer
);
await deleteButtons[0].click();
} while (deleteButtons.length > 1);
};
const openNewEndpointExceptionFlyout = async () => {
await testSubjects.click('timeline-context-menu-button');
await testSubjects.click('add-endpoint-exception-menu-item');
await testSubjects.existOrFail('addExceptionFlyout');
await retry.waitFor('entries should be loaded', () =>
testSubjects.exists('exceptionItemEntryContainer')
);
};
const setLastFieldsValue = async ({
testSubj,
value,
optionSelector = `button[title="${value}"]`,
}: {
testSubj: string;
value: string;
optionSelector?: string;
}) => {
const fields = await find.allByCssSelector(`[data-test-subj="${testSubj}"]`);
const lastField = fields[fields.length - 1];
await lastField.click();
const inputField = await lastField.findByTagName('input');
await inputField.type(value);
const dropdownOptionSelector = `[data-test-subj="comboBoxOptionsList ${testSubj}-optionsList"] ${optionSelector}`;
await find.clickByCssSelector(dropdownOptionSelector);
};
const setLastEntry = async ({
field,
operator,
value,
}: {
field: string;
operator: 'matches' | 'is';
value: string;
}) => {
await setLastFieldsValue({ testSubj: 'fieldAutocompleteComboBox', value: field });
await setLastFieldsValue({ testSubj: 'operatorAutocompleteComboBox', value: operator });
await setLastFieldsValue({
testSubj: operator === 'matches' ? 'valuesAutocompleteWildcard' : 'valuesAutocompleteMatch',
value,
optionSelector: 'p',
});
};
const checkArtifact = (expectedArtifact: object) => {
return retry.tryForTime(120_000, async () => {
const artifacts = await endpointArtifactTestResources.getArtifacts();
const manifestArtifact = artifacts.find((artifact) =>
artifact.artifactId.startsWith('endpoint-exceptionlist-macos-v1')
);
expect(manifestArtifact).to.not.be(undefined);
// Get fleet artifact
const artifactResult = await esClient.get({
index: '.fleet-artifacts-7',
id: `endpoint:${manifestArtifact!.artifactId}`,
});
const artifact = artifactResult._source as ArtifactElasticsearchProperties;
const zippedBody = Buffer.from(artifact.body, 'base64');
const artifactBody = await unzipPromisify(zippedBody);
expect(JSON.parse(artifactBody.toString())).to.eql(expectedArtifact);
});
};
let indexedData: IndexedHostsAndAlertsResponse;
before(async () => {
indexedData = await endpointTestResources.loadEndpointData();
const waitForAlertsToAppear = async () => {
await pageObjects.common.navigateToUrlWithBrowserHistory('security', `/alerts`);
await pageObjects.header.waitUntilLoadingHasFinished();
await retry.waitForWithTimeout('alerts to appear', 10 * 60_000, async () => {
await queryBar.clickQuerySubmitButton();
return testSubjects.exists('timeline-context-menu-button');
});
};
await waitForAlertsToAppear();
});
after(async () => {
await endpointTestResources.unloadEndpointData(indexedData);
});
beforeEach(async () => {
const deleteEndpointExceptions = async () => {
const { body } = await supertest
.get(`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=endpoint_list&namespace_type=agnostic`)
.set('kbn-xsrf', 'true');
for (const exceptionListItem of (body as FoundExceptionListItemSchema).data) {
await supertest
.delete(`${EXCEPTION_LIST_ITEM_URL}?id=${exceptionListItem.id}&namespace_type=agnostic`)
.set('kbn-xsrf', 'true');
}
};
await deleteEndpointExceptions();
});
it('should add `event.module=endpoint` to entry if only wildcard operator is present', async () => {
await pageObjects.common.navigateToUrlWithBrowserHistory('security', `/alerts`);
await openNewEndpointExceptionFlyout();
await clearPrefilledEntries();
await testSubjects.setValue('exceptionFlyoutNameInput', 'test exception');
await setLastEntry({ field: 'file.path', operator: 'matches', value: '*/cheese/*' });
await testSubjects.click('exceptionsAndButton');
await setLastEntry({ field: 'process.executable', operator: 'matches', value: 'ex*' });
await testSubjects.click('addExceptionConfirmButton');
await pageObjects.common.closeToast();
await checkArtifact({
entries: [
{
type: 'simple',
entries: [
{
field: 'file.path',
operator: 'included',
type: 'wildcard_cased',
value: '*/cheese/*',
},
{
field: 'process.executable',
operator: 'included',
type: 'wildcard_cased',
value: 'ex*',
},
{
// this additional entry should be added
field: 'event.module',
operator: 'included',
type: 'exact_cased',
value: 'endpoint',
},
],
},
],
});
});
it('should NOT add `event.module=endpoint` to entry if there is another operator', async () => {
await pageObjects.common.navigateToUrlWithBrowserHistory('security', `/alerts`);
await openNewEndpointExceptionFlyout();
await clearPrefilledEntries();
await testSubjects.setValue('exceptionFlyoutNameInput', 'test exception');
await setLastEntry({ field: 'file.path', operator: 'matches', value: '*/cheese/*' });
await testSubjects.click('exceptionsAndButton');
await setLastEntry({ field: 'process.executable', operator: 'is', value: 'something' });
await testSubjects.click('addExceptionConfirmButton');
await pageObjects.common.closeToast();
await checkArtifact({
entries: [
{
type: 'simple',
entries: [
{
field: 'file.path',
operator: 'included',
type: 'wildcard_cased',
value: '*/cheese/*',
},
{
field: 'process.executable',
operator: 'included',
type: 'exact_cased',
value: 'something',
},
],
},
],
});
});
});
};

View file

@ -47,5 +47,6 @@ export default function (providerContext: FtrProviderContext) {
loadTestFile(require.resolve('./trusted_apps_list'));
loadTestFile(require.resolve('./fleet_integrations'));
loadTestFile(require.resolve('./artifact_entries_list'));
loadTestFile(require.resolve('./endpoint_exceptions'));
});
}

View file

@ -18,7 +18,9 @@ import { EndpointError } from '@kbn/security-solution-plugin/common/endpoint/err
import { EVENT_FILTER_LIST_DEFINITION } from '@kbn/security-solution-plugin/public/management/pages/event_filters/constants';
import { HOST_ISOLATION_EXCEPTIONS_LIST_DEFINITION } from '@kbn/security-solution-plugin/public/management/pages/host_isolation_exceptions/constants';
import { BLOCKLISTS_LIST_DEFINITION } from '@kbn/security-solution-plugin/public/management/pages/blocklist/constants';
import { ManifestConstants } from '@kbn/security-solution-plugin/server/endpoint/lib/artifacts';
import { FtrService } from '../../functional/ftr_provider_context';
import { InternalManifestSchemaResponseType } from '../apps/integrations/mocks';
export interface ArtifactTestData {
artifact: ExceptionListItemSchema;
@ -29,6 +31,7 @@ export class EndpointArtifactsTestResources extends FtrService {
private readonly exceptionsGenerator = new ExceptionsListItemGenerator();
private readonly supertest = this.ctx.getService('supertest');
private readonly log = this.ctx.getService('log');
private readonly esClient = this.ctx.getService('es');
private getHttpResponseFailureHandler(
ignoredStatusCodes: number[] = []
@ -118,4 +121,19 @@ export class EndpointArtifactsTestResources extends FtrService {
return this.createExceptionItem(blocklist);
}
async getArtifacts() {
const {
hits: { hits: manifestResults },
} = await this.esClient.search({
index: '.kibana*',
query: { bool: { filter: [{ term: { type: ManifestConstants.SAVED_OBJECT_TYPE } }] } },
size: 1,
});
const manifestResult = manifestResults[0] as InternalManifestSchemaResponseType;
const artifacts = manifestResult._source['endpoint:user-artifact-manifest'].artifacts;
return artifacts;
}
}