[Security Solution][Exception Modal] Create endpoint exception list if it doesn't already exist (#71807)

* use createEndpointList api

* fix lint

* update list id constant

* add schema test

* add api test
This commit is contained in:
Pedro Jaramillo 2020-07-15 11:35:08 +02:00 committed by GitHub
parent a282af7ca3
commit e4f7acb90f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 207 additions and 22 deletions

View file

@ -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({});
});
});

View file

@ -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<typeof createEndpointListSchema>;

View file

@ -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';

View file

@ -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';

View file

@ -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({});
});
});
});

View file

@ -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<CreateEndpointListSchema> => {
try {
const response = await http.fetch<ExceptionListItemSchema>(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);
}
};

View file

@ -110,3 +110,8 @@ export interface UpdateExceptionListItemProps {
listItem: UpdateExceptionListItemSchema;
signal: AbortSignal;
}
export interface AddEndpointExceptionListProps {
http: HttpStart;
signal: AbortSignal;
}

View file

@ -24,6 +24,7 @@ export {
updateExceptionListItem,
fetchExceptionListById,
addExceptionList,
addEndpointExceptionList,
} from './exceptions/api';
export {
ExceptionList,

View file

@ -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';

View file

@ -27,6 +27,9 @@ describe('useFetchOrCreateRuleExceptionList', () => {
let fetchRuleById: jest.SpyInstance<ReturnType<typeof rulesApi.fetchRuleById>>;
let patchRule: jest.SpyInstance<ReturnType<typeof rulesApi.patchRule>>;
let addExceptionList: jest.SpyInstance<ReturnType<typeof listsApi.addExceptionList>>;
let addEndpointExceptionList: jest.SpyInstance<ReturnType<
typeof listsApi.addEndpointExceptionList
>>;
let fetchExceptionListById: jest.SpyInstance<ReturnType<typeof listsApi.fetchExceptionListById>>;
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 () => {

View file

@ -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<ExceptionListSchema> {
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<ExceptionListSchema> {
@ -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;
}

View file

@ -49,4 +49,5 @@ export {
ExceptionList,
Pagination,
UseExceptionListSuccess,
addEndpointExceptionList,
} from '../../lists/public';