diff --git a/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts new file mode 100644 index 00000000000000..1f51140005e597 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getExceptionListSchemaMock } from './exception_list_schema.mock'; +import { CreateEndpointListSchema, createEndpointListSchema } from './create_endpoint_list_schema'; + +describe('create_endpoint_list_schema', () => { + test('it should validate a typical endpoint list response', () => { + const payload = getExceptionListSchemaMock(); + const decoded = createEndpointListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should accept an empty object when an endpoint list already exists', () => { + const payload = {}; + const decoded = createEndpointListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT allow missing fields', () => { + const payload = getExceptionListSchemaMock(); + delete payload.list_id; + const decoded = createEndpointListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors)).length).toEqual(1); + expect(message.schema).toEqual({}); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: CreateEndpointListSchema & { + extraKey?: string; + } = getExceptionListSchemaMock(); + payload.extraKey = 'some new value'; + const decoded = createEndpointListSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.ts b/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.ts new file mode 100644 index 00000000000000..4653b73347f72e --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { exceptionListSchema } from './exception_list_schema'; + +export const createEndpointListSchema = t.union([exceptionListSchema, t.exact(t.type({}))]); + +export type CreateEndpointListSchema = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/response/index.ts b/x-pack/plugins/lists/common/schemas/response/index.ts index fb6f17a896ddb5..deca06ad99feac 100644 --- a/x-pack/plugins/lists/common/schemas/response/index.ts +++ b/x-pack/plugins/lists/common/schemas/response/index.ts @@ -5,6 +5,7 @@ */ export * from './acknowledge_schema'; +export * from './create_endpoint_list_schema'; export * from './exception_list_schema'; export * from './exception_list_item_schema'; export * from './found_exception_list_item_schema'; diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts index 7bb565792969cb..dc0a9aa5926ef1 100644 --- a/x-pack/plugins/lists/common/shared_exports.ts +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -12,6 +12,7 @@ export { CreateComments, ExceptionListSchema, ExceptionListItemSchema, + CreateExceptionListSchema, CreateExceptionListItemSchema, UpdateExceptionListItemSchema, Entry, @@ -41,3 +42,5 @@ export { ExceptionListType, Type, } from './schemas'; + +export { ENDPOINT_LIST_ID } from './constants'; diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index cd54c24e95e2f6..1414d828fa6d4f 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -19,6 +19,7 @@ import { } from '../../common/schemas'; import { + addEndpointExceptionList, addExceptionList, addExceptionListItem, deleteExceptionListById, @@ -738,4 +739,39 @@ describe('Exceptions Lists API', () => { ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); }); }); + + describe('#addEndpointExceptionList', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(getExceptionListSchemaMock()); + }); + + test('it invokes "addEndpointExceptionList" with expected url and body values', async () => { + await addEndpointExceptionList({ + http: mockKibanaHttpService(), + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith('/api/endpoint_list', { + method: 'POST', + signal: abortCtrl.signal, + }); + }); + + test('it returns expected exception list on success', async () => { + const exceptionResponse = await addEndpointExceptionList({ + http: mockKibanaHttpService(), + signal: abortCtrl.signal, + }); + expect(exceptionResponse).toEqual(getExceptionListSchemaMock()); + }); + + test('it returns an empty object when list already exists', async () => { + fetchMock.mockResolvedValue({}); + const exceptionResponse = await addEndpointExceptionList({ + http: mockKibanaHttpService(), + signal: abortCtrl.signal, + }); + expect(exceptionResponse).toEqual({}); + }); + }); }); diff --git a/x-pack/plugins/lists/public/exceptions/api.ts b/x-pack/plugins/lists/public/exceptions/api.ts index a581cfd08ecc19..4d9397ec0adc6c 100644 --- a/x-pack/plugins/lists/public/exceptions/api.ts +++ b/x-pack/plugins/lists/public/exceptions/api.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ import { + ENDPOINT_LIST_URL, EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_NAMESPACE, EXCEPTION_LIST_NAMESPACE_AGNOSTIC, EXCEPTION_LIST_URL, } from '../../common/constants'; import { + CreateEndpointListSchema, ExceptionListItemSchema, ExceptionListSchema, FoundExceptionListItemSchema, + createEndpointListSchema, createExceptionListItemSchema, createExceptionListSchema, deleteExceptionListItemSchema, @@ -29,6 +32,7 @@ import { import { validate } from '../../common/siem_common_deps'; import { + AddEndpointExceptionListProps, AddExceptionListItemProps, AddExceptionListProps, ApiCallByIdProps, @@ -440,3 +444,34 @@ export const deleteExceptionListItemById = async ({ return Promise.reject(errorsRequest); } }; + +/** + * Add new Endpoint ExceptionList + * + * @param http Kibana http service + * @param signal to cancel request + * + * @throws An error if response is not OK + * + */ +export const addEndpointExceptionList = async ({ + http, + signal, +}: AddEndpointExceptionListProps): Promise => { + try { + const response = await http.fetch(ENDPOINT_LIST_URL, { + method: 'POST', + signal, + }); + + const [validatedResponse, errorsResponse] = validate(response, createEndpointListSchema); + + if (errorsResponse != null || validatedResponse == null) { + return Promise.reject(errorsResponse); + } else { + return Promise.resolve(validatedResponse); + } + } catch (error) { + return Promise.reject(error); + } +}; diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index 1b4e09b07f1de0..f99323b3847814 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -110,3 +110,8 @@ export interface UpdateExceptionListItemProps { listItem: UpdateExceptionListItemSchema; signal: AbortSignal; } + +export interface AddEndpointExceptionListProps { + http: HttpStart; + signal: AbortSignal; +} diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index 57fb2f90b64045..56341035f839fd 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -24,6 +24,7 @@ export { updateExceptionListItem, fetchExceptionListById, addExceptionList, + addEndpointExceptionList, } from './exceptions/api'; export { ExceptionList, diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts index a607906e1b92ab..7fb94cea7b6129 100644 --- a/x-pack/plugins/security_solution/common/shared_imports.ts +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -12,6 +12,7 @@ export { CreateComments, ExceptionListSchema, ExceptionListItemSchema, + CreateExceptionListSchema, CreateExceptionListItemSchema, UpdateExceptionListItemSchema, Entry, @@ -40,4 +41,5 @@ export { namespaceType, ExceptionListType, Type, + ENDPOINT_LIST_ID, } from '../../lists/common'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx index afc3568fd6c655..7bef771d367f30 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx @@ -27,6 +27,9 @@ describe('useFetchOrCreateRuleExceptionList', () => { let fetchRuleById: jest.SpyInstance>; let patchRule: jest.SpyInstance>; let addExceptionList: jest.SpyInstance>; + let addEndpointExceptionList: jest.SpyInstance>; let fetchExceptionListById: jest.SpyInstance>; let render: ( listType?: UseFetchOrCreateRuleExceptionListProps['exceptionListType'] @@ -75,6 +78,10 @@ describe('useFetchOrCreateRuleExceptionList', () => { .spyOn(listsApi, 'addExceptionList') .mockResolvedValue(newDetectionExceptionList); + addEndpointExceptionList = jest + .spyOn(listsApi, 'addEndpointExceptionList') + .mockResolvedValue(newEndpointExceptionList); + fetchExceptionListById = jest .spyOn(listsApi, 'fetchExceptionListById') .mockResolvedValue(detectionExceptionList); @@ -299,7 +306,7 @@ describe('useFetchOrCreateRuleExceptionList', () => { await waitForNextUpdate(); await waitForNextUpdate(); await waitForNextUpdate(); - expect(addExceptionList).toHaveBeenCalledTimes(1); + expect(addEndpointExceptionList).toHaveBeenCalledTimes(1); }); }); it('should update the rule', async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx index 245ce192b3cfa6..b238e25f6de592 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -7,17 +7,22 @@ import { useEffect, useState } from 'react'; import { HttpStart } from '../../../../../../../src/core/public'; -import { - ExceptionListSchema, - CreateExceptionListSchema, -} from '../../../../../lists/common/schemas'; import { Rule } from '../../../detections/containers/detection_engine/rules/types'; import { List, ListArray } from '../../../../common/detection_engine/schemas/types'; import { fetchRuleById, patchRule, } from '../../../detections/containers/detection_engine/rules/api'; -import { fetchExceptionListById, addExceptionList } from '../../../lists_plugin_deps'; +import { + fetchExceptionListById, + addExceptionList, + addEndpointExceptionList, +} from '../../../lists_plugin_deps'; +import { + ExceptionListSchema, + CreateExceptionListSchema, + ENDPOINT_LIST_ID, +} from '../../../../common/shared_imports'; export type ReturnUseFetchOrCreateRuleExceptionList = [boolean, ExceptionListSchema | null]; @@ -51,27 +56,43 @@ export const useFetchOrCreateRuleExceptionList = ({ const abortCtrl = new AbortController(); async function createExceptionList(ruleResponse: Rule): Promise { - const exceptionListToCreate: CreateExceptionListSchema = { - name: ruleResponse.name, - description: ruleResponse.description, - type: exceptionListType, - namespace_type: exceptionListType === 'endpoint' ? 'agnostic' : 'single', - _tags: undefined, - tags: undefined, - list_id: exceptionListType === 'endpoint' ? 'endpoint_list' : undefined, - meta: undefined, - }; - try { - const newExceptionList = await addExceptionList({ + let newExceptionList: ExceptionListSchema; + if (exceptionListType === 'endpoint') { + const possibleEndpointExceptionList = await addEndpointExceptionList({ + http, + signal: abortCtrl.signal, + }); + if (Object.keys(possibleEndpointExceptionList).length === 0) { + // Endpoint exception list already exists, fetch it + newExceptionList = await fetchExceptionListById({ + http, + id: ENDPOINT_LIST_ID, + namespaceType: 'agnostic', + signal: abortCtrl.signal, + }); + } else { + newExceptionList = possibleEndpointExceptionList as ExceptionListSchema; + } + } else { + const exceptionListToCreate: CreateExceptionListSchema = { + name: ruleResponse.name, + description: ruleResponse.description, + type: exceptionListType, + namespace_type: 'single', + list_id: undefined, + _tags: undefined, + tags: undefined, + meta: undefined, + }; + newExceptionList = await addExceptionList({ http, list: exceptionListToCreate, signal: abortCtrl.signal, }); - return Promise.resolve(newExceptionList); - } catch (error) { - return Promise.reject(error); } + return Promise.resolve(newExceptionList); } + async function createAndAssociateExceptionList( ruleResponse: Rule ): Promise { @@ -133,7 +154,7 @@ export const useFetchOrCreateRuleExceptionList = ({ let exceptionListToUse: ExceptionListSchema; const matchingList = exceptionLists.find((list) => { if (exceptionListType === 'endpoint') { - return list.type === exceptionListType && list.list_id === 'endpoint_list'; + return list.type === exceptionListType && list.list_id === ENDPOINT_LIST_ID; } else { return list.type === exceptionListType; } diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 5d4579b427f18f..9939345324f110 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -49,4 +49,5 @@ export { ExceptionList, Pagination, UseExceptionListSuccess, + addEndpointExceptionList, } from '../../lists/public';