Skip to content

Commit

Permalink
[7.x] [Security Solution][Exception Modal] Create endpoint exception …
Browse files Browse the repository at this point in the history
…list if it doesn't already exist (#71807) (#71832)

Co-authored-by: Pedro Jaramillo <pedro.jaramillo@elastic.co>
  • Loading branch information
angorayc and peluja1012 authored Jul 15, 2020
1 parent 465f6dd commit c76ea55
Show file tree
Hide file tree
Showing 12 changed files with 207 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -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({});
});
});
Original file line number Diff line number Diff line change
@@ -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>;
1 change: 1 addition & 0 deletions x-pack/plugins/lists/common/schemas/response/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/lists/common/shared_exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
CreateComments,
ExceptionListSchema,
ExceptionListItemSchema,
CreateExceptionListSchema,
CreateExceptionListItemSchema,
UpdateExceptionListItemSchema,
Entry,
Expand Down Expand Up @@ -41,3 +42,5 @@ export {
ExceptionListType,
Type,
} from './schemas';

export { ENDPOINT_LIST_ID } from './constants';
36 changes: 36 additions & 0 deletions x-pack/plugins/lists/public/exceptions/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '../../common/schemas';

import {
addEndpointExceptionList,
addExceptionList,
addExceptionListItem,
deleteExceptionListById,
Expand Down Expand Up @@ -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({});
});
});
});
35 changes: 35 additions & 0 deletions x-pack/plugins/lists/public/exceptions/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +32,7 @@ import {
import { validate } from '../../common/siem_common_deps';

import {
AddEndpointExceptionListProps,
AddExceptionListItemProps,
AddExceptionListProps,
ApiCallByIdProps,
Expand Down Expand Up @@ -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);
}
};
5 changes: 5 additions & 0 deletions x-pack/plugins/lists/public/exceptions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,8 @@ export interface UpdateExceptionListItemProps {
listItem: UpdateExceptionListItemSchema;
signal: AbortSignal;
}

export interface AddEndpointExceptionListProps {
http: HttpStart;
signal: AbortSignal;
}
1 change: 1 addition & 0 deletions x-pack/plugins/lists/public/shared_exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
updateExceptionListItem,
fetchExceptionListById,
addExceptionList,
addEndpointExceptionList,
} from './exceptions/api';
export {
ExceptionList,
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/security_solution/common/shared_imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
CreateComments,
ExceptionListSchema,
ExceptionListItemSchema,
CreateExceptionListSchema,
CreateExceptionListItemSchema,
UpdateExceptionListItemSchema,
Entry,
Expand Down Expand Up @@ -40,4 +41,5 @@ export {
namespaceType,
ExceptionListType,
Type,
ENDPOINT_LIST_ID,
} from '../../lists/common';
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security_solution/public/shared_imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ export {
ExceptionList,
Pagination,
UseExceptionListSuccess,
addEndpointExceptionList,
} from '../../lists/public';

0 comments on commit c76ea55

Please sign in to comment.