[Security solution][Exceptions] Add separate io-ts types for endpoint exceptions (#74468)

* Add separate io-ts types for endpoint exception entries

* Fix text typos

* Fix test

* address review comments

* Add extra entry validation when adding items to endpoint_list

* fix test

* fix tests again

* really actually fix the tests

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Marshall Main 2020-08-17 16:42:04 -04:00 committed by GitHub
parent 83678e1388
commit 24773f3a00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 726 additions and 18 deletions

View file

@ -6,6 +6,7 @@
import moment from 'moment';
import { EntriesArray } from './schemas/types';
import { EndpointEntriesArray } from './schemas/types/endpoint';
export const DATE_NOW = '2020-04-20T15:25:31.830Z';
export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z';
export const USER = 'some user';
@ -41,6 +42,7 @@ export const ITEM_ID = 'some-list-item-id';
export const ENDPOINT_TYPE = 'endpoint';
export const FIELD = 'host.name';
export const OPERATOR = 'included';
export const OPERATOR_EXCLUDED = 'excluded';
export const ENTRY_VALUE = 'some host name';
export const MATCH = 'match';
export const MATCH_ANY = 'match_any';
@ -57,6 +59,14 @@ export const ENTRIES: EntriesArray = [
},
{ field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' },
];
export const ENDPOINT_ENTRIES: EndpointEntriesArray = [
{
entries: [{ field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }],
field: 'some.parentField',
type: 'nested',
},
{ field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' },
];
export const ITEM_TYPE = 'simple';
export const _TAGS = [];
export const TAGS = [];

View file

@ -274,6 +274,7 @@ export type CursorOrUndefined = t.TypeOf<typeof cursorOrUndefined>;
export const namespace_type = DefaultNamespace;
export const operatorIncluded = t.keyof({ included: null });
export const operator = t.keyof({ excluded: null, included: null });
export type Operator = t.TypeOf<typeof operator>;
export enum OperatorEnum {

View file

@ -7,7 +7,7 @@
import {
COMMENTS,
DESCRIPTION,
ENTRIES,
ENDPOINT_ENTRIES,
ITEM_TYPE,
META,
NAME,
@ -21,7 +21,7 @@ export const getCreateEndpointListItemSchemaMock = (): CreateEndpointListItemSch
_tags: _TAGS,
comments: COMMENTS,
description: DESCRIPTION,
entries: ENTRIES,
entries: ENDPOINT_ENTRIES,
item_id: undefined,
meta: META,
name: NAME,

View file

@ -18,7 +18,8 @@ import {
tags,
} from '../common/schemas';
import { RequiredKeepUndefined } from '../../types';
import { CreateCommentsArray, DefaultCreateCommentsArray, nonEmptyEntriesArray } from '../types';
import { CreateCommentsArray, DefaultCreateCommentsArray } from '../types';
import { nonEmptyEndpointEntriesArray } from '../types/endpoint';
import { EntriesArray } from '../types/entries';
import { DefaultUuid } from '../../shared_imports';
@ -26,7 +27,7 @@ export const createEndpointListItemSchema = t.intersection([
t.exact(
t.type({
description,
entries: nonEmptyEntriesArray,
entries: nonEmptyEndpointEntriesArray,
name,
type: exceptionListItemType,
})

View file

@ -0,0 +1,16 @@
/*
* 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 { EndpointEntriesArray } from './entries';
import { getEndpointEntryMatchMock } from './entry_match.mock';
import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock';
import { getEndpointEntryNestedMock } from './entry_nested.mock';
export const getEndpointEntriesArrayMock = (): EndpointEntriesArray => [
getEndpointEntryMatchMock(),
getEndpointEntryMatchAnyMock(),
getEndpointEntryNestedMock(),
];

View file

@ -0,0 +1,111 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../../shared_imports';
import { getEntryExistsMock } from '../entry_exists.mock';
import { getEntryListMock } from '../entry_list.mock';
import { getEndpointEntryMatchMock } from './entry_match.mock';
import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock';
import { getEndpointEntryNestedMock } from './entry_nested.mock';
import { getEndpointEntriesArrayMock } from './entries.mock';
import {
NonEmptyEndpointEntriesArray,
endpointEntriesArray,
nonEmptyEndpointEntriesArray,
} from './entries';
describe('Endpoint', () => {
describe('entriesArray', () => {
test('it should validate an array with match entry', () => {
const payload = [getEndpointEntryMatchMock()];
const decoded = endpointEntriesArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate an array with match_any entry', () => {
const payload = [getEndpointEntryMatchAnyMock()];
const decoded = endpointEntriesArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT validate an empty array', () => {
const payload: NonEmptyEndpointEntriesArray = [];
const decoded = nonEmptyEndpointEntriesArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "[]" supplied to "NonEmptyEndpointEntriesArray"',
]);
expect(message.schema).toEqual({});
});
test('type guard for nonEmptyEndpointNestedEntries should allow array of endpoint entries', () => {
const payload: NonEmptyEndpointEntriesArray = [getEndpointEntryMatchAnyMock()];
const guarded = nonEmptyEndpointEntriesArray.is(payload);
expect(guarded).toBeTruthy();
});
test('type guard for nonEmptyEndpointNestedEntries should disallow empty arrays', () => {
const payload: NonEmptyEndpointEntriesArray = [];
const guarded = nonEmptyEndpointEntriesArray.is(payload);
expect(guarded).toBeFalsy();
});
test('it should NOT validate an array with exists entry', () => {
const payload = [getEntryExistsMock()];
const decoded = endpointEntriesArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "exists" supplied to "type"',
'Invalid value "undefined" supplied to "value"',
'Invalid value "undefined" supplied to "entries"',
]);
expect(message.schema).toEqual({});
});
test('it should NOT validate an array with list entry', () => {
const payload = [getEntryListMock()];
const decoded = endpointEntriesArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "list" supplied to "type"',
'Invalid value "undefined" supplied to "value"',
'Invalid value "undefined" supplied to "entries"',
]);
expect(message.schema).toEqual({});
});
test('it should validate an array with nested entry', () => {
const payload = [getEndpointEntryNestedMock()];
const decoded = endpointEntriesArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should validate an array with all types of entries', () => {
const payload = getEndpointEntriesArrayMock();
const decoded = endpointEntriesArray.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import { endpointEntryMatchAny } from './entry_match_any';
import { endpointEntryMatch } from './entry_match';
import { endpointEntryNested } from './entry_nested';
export const endpointEntriesArray = t.array(
t.union([endpointEntryMatch, endpointEntryMatchAny, endpointEntryNested])
);
export type EndpointEntriesArray = t.TypeOf<typeof endpointEntriesArray>;
/**
* Types the nonEmptyEndpointEntriesArray as:
* - An array of entries of length 1 or greater
*
*/
export const nonEmptyEndpointEntriesArray = new t.Type<
EndpointEntriesArray,
EndpointEntriesArray,
unknown
>(
'NonEmptyEndpointEntriesArray',
(u: unknown): u is EndpointEntriesArray => endpointEntriesArray.is(u) && u.length > 0,
(input, context): Either<t.Errors, EndpointEntriesArray> => {
if (Array.isArray(input) && input.length === 0) {
return t.failure(input, context);
} else {
return endpointEntriesArray.validate(input, context);
}
},
t.identity
);
export type NonEmptyEndpointEntriesArray = t.OutputOf<typeof nonEmptyEndpointEntriesArray>;
export type NonEmptyEndpointEntriesArrayDecoded = t.TypeOf<typeof nonEmptyEndpointEntriesArray>;

View file

@ -0,0 +1,16 @@
/*
* 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 { ENTRY_VALUE, FIELD, MATCH, OPERATOR } from '../../../constants.mock';
import { EndpointEntryMatch } from './entry_match';
export const getEndpointEntryMatchMock = (): EndpointEntryMatch => ({
field: FIELD,
operator: OPERATOR,
type: MATCH,
value: ENTRY_VALUE,
});

View file

@ -0,0 +1,102 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../../shared_imports';
import { getEntryMatchMock } from '../entry_match.mock';
import { getEndpointEntryMatchMock } from './entry_match.mock';
import { EndpointEntryMatch, endpointEntryMatch } from './entry_match';
describe('endpointEntryMatch', () => {
test('it should validate an entry', () => {
const payload = getEndpointEntryMatchMock();
const decoded = endpointEntryMatch.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT validate when "operator" is "excluded"', () => {
// Use the generic entry mock so we can test operator: excluded
const payload = getEntryMatchMock();
payload.operator = 'excluded';
const decoded = endpointEntryMatch.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "excluded" supplied to "operator"',
]);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when "field" is empty string', () => {
const payload: Omit<EndpointEntryMatch, 'field'> & { field: string } = {
...getEndpointEntryMatchMock(),
field: '',
};
const decoded = endpointEntryMatch.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when "value" is not string', () => {
const payload: Omit<EndpointEntryMatch, 'value'> & { value: string[] } = {
...getEndpointEntryMatchMock(),
value: ['some value'],
};
const decoded = endpointEntryMatch.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "["some value"]" supplied to "value"',
]);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when "value" is empty string', () => {
const payload: Omit<EndpointEntryMatch, 'value'> & { value: string } = {
...getEndpointEntryMatchMock(),
value: '',
};
const decoded = endpointEntryMatch.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "value"']);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when "type" is not "match"', () => {
const payload: Omit<EndpointEntryMatch, 'type'> & { type: string } = {
...getEndpointEntryMatchMock(),
type: 'match_any',
};
const decoded = endpointEntryMatch.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "match_any" supplied to "type"',
]);
expect(message.schema).toEqual({});
});
test('it should strip out extra keys', () => {
const payload: EndpointEntryMatch & {
extraKey?: string;
} = getEndpointEntryMatchMock();
payload.extraKey = 'some value';
const decoded = endpointEntryMatch.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getEntryMatchMock());
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 * as t from 'io-ts';
import { NonEmptyString } from '../../../shared_imports';
import { operatorIncluded } from '../../common/schemas';
export const endpointEntryMatch = t.exact(
t.type({
field: NonEmptyString,
operator: operatorIncluded,
type: t.keyof({ match: null }),
value: NonEmptyString,
})
);
export type EndpointEntryMatch = t.TypeOf<typeof endpointEntryMatch>;

View file

@ -0,0 +1,16 @@
/*
* 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 { ENTRY_VALUE, FIELD, MATCH_ANY, OPERATOR } from '../../../constants.mock';
import { EndpointEntryMatchAny } from './entry_match_any';
export const getEndpointEntryMatchAnyMock = (): EndpointEntryMatchAny => ({
field: FIELD,
operator: OPERATOR,
type: MATCH_ANY,
value: [ENTRY_VALUE],
});

View file

@ -0,0 +1,100 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../../shared_imports';
import { getEntryMatchAnyMock } from '../entry_match_any.mock';
import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock';
import { EndpointEntryMatchAny, endpointEntryMatchAny } from './entry_match_any';
describe('endpointEntryMatchAny', () => {
test('it should validate an entry', () => {
const payload = getEndpointEntryMatchAnyMock();
const decoded = endpointEntryMatchAny.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should NOT validate when operator is "excluded"', () => {
// Use the generic entry mock so we can test operator: excluded
const payload = getEntryMatchAnyMock();
payload.operator = 'excluded';
const decoded = endpointEntryMatchAny.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "excluded" supplied to "operator"',
]);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when field is empty string', () => {
const payload: Omit<EndpointEntryMatchAny, 'field'> & { field: string } = {
...getEndpointEntryMatchAnyMock(),
field: '',
};
const decoded = endpointEntryMatchAny.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when value is empty array', () => {
const payload: Omit<EndpointEntryMatchAny, 'value'> & { value: string[] } = {
...getEndpointEntryMatchAnyMock(),
value: [],
};
const decoded = endpointEntryMatchAny.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "[]" supplied to "value"']);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when value is not string array', () => {
const payload: Omit<EndpointEntryMatchAny, 'value'> & { value: string } = {
...getEndpointEntryMatchAnyMock(),
value: 'some string',
};
const decoded = endpointEntryMatchAny.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "some string" supplied to "value"',
]);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when "type" is not "match_any"', () => {
const payload: Omit<EndpointEntryMatchAny, 'type'> & { type: string } = {
...getEndpointEntryMatchAnyMock(),
type: 'match',
};
const decoded = endpointEntryMatchAny.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']);
expect(message.schema).toEqual({});
});
test('it should strip out extra keys', () => {
const payload: EndpointEntryMatchAny & {
extraKey?: string;
} = getEndpointEntryMatchAnyMock();
payload.extraKey = 'some extra key';
const decoded = endpointEntryMatchAny.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getEntryMatchAnyMock());
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 * as t from 'io-ts';
import { NonEmptyString } from '../../../shared_imports';
import { operatorIncluded } from '../../common/schemas';
import { nonEmptyOrNullableStringArray } from '../non_empty_or_nullable_string_array';
export const endpointEntryMatchAny = t.exact(
t.type({
field: NonEmptyString,
operator: operatorIncluded,
type: t.keyof({ match_any: null }),
value: nonEmptyOrNullableStringArray,
})
);
export type EndpointEntryMatchAny = t.TypeOf<typeof endpointEntryMatchAny>;

View file

@ -0,0 +1,17 @@
/*
* 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 { FIELD, NESTED } from '../../../constants.mock';
import { EndpointEntryNested } from './entry_nested';
import { getEndpointEntryMatchMock } from './entry_match.mock';
import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock';
export const getEndpointEntryNestedMock = (): EndpointEntryNested => ({
entries: [getEndpointEntryMatchMock(), getEndpointEntryMatchAnyMock()],
field: FIELD,
type: NESTED,
});

View file

@ -0,0 +1,137 @@
/*
* 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 { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '../../../shared_imports';
import { getEntryExistsMock } from '../entry_exists.mock';
import { getEndpointEntryNestedMock } from './entry_nested.mock';
import { EndpointEntryNested, endpointEntryNested } from './entry_nested';
import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock';
import {
NonEmptyEndpointNestedEntriesArray,
nonEmptyEndpointNestedEntriesArray,
} from './non_empty_nested_entries_array';
import { getEndpointEntryMatchMock } from './entry_match.mock';
describe('endpointEntryNested', () => {
test('it should validate a nested entry', () => {
const payload = getEndpointEntryNestedMock();
const decoded = endpointEntryNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('it should FAIL validation when "type" is not "nested"', () => {
const payload: Omit<EndpointEntryNested, 'type'> & { type: 'match' } = {
...getEndpointEntryNestedMock(),
type: 'match',
};
const decoded = endpointEntryNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when "field" is empty string', () => {
const payload: Omit<EndpointEntryNested, 'field'> & {
field: string;
} = { ...getEndpointEntryNestedMock(), field: '' };
const decoded = endpointEntryNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when "field" is not a string', () => {
const payload: Omit<EndpointEntryNested, 'field'> & {
field: number;
} = { ...getEndpointEntryNestedMock(), field: 1 };
const decoded = endpointEntryNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "field"']);
expect(message.schema).toEqual({});
});
test('it should FAIL validation when "entries" is not an array', () => {
const payload: Omit<EndpointEntryNested, 'entries'> & {
entries: string;
} = { ...getEndpointEntryNestedMock(), entries: 'im a string' };
const decoded = endpointEntryNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "im a string" supplied to "entries"',
]);
expect(message.schema).toEqual({});
});
test('it should validate when "entries" contains an entry item that is type "match"', () => {
const payload = { ...getEndpointEntryNestedMock(), entries: [getEndpointEntryMatchAnyMock()] };
const decoded = endpointEntryNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual({
entries: [
{
field: 'host.name',
operator: 'included',
type: 'match_any',
value: ['some host name'],
},
],
field: 'host.name',
type: 'nested',
});
});
test('it should NOT validate when "entries" contains an entry item that is type "exists"', () => {
const payload = { ...getEndpointEntryNestedMock(), entries: [getEntryExistsMock()] };
const decoded = endpointEntryNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "exists" supplied to "entries,type"',
'Invalid value "undefined" supplied to "entries,value"',
]);
expect(message.schema).toEqual({});
});
test('it should strip out extra keys', () => {
const payload: EndpointEntryNested & {
extraKey?: string;
} = getEndpointEntryNestedMock();
payload.extraKey = 'some extra key';
const decoded = endpointEntryNested.decode(payload);
const message = pipe(decoded, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(getEndpointEntryNestedMock());
});
test('type guard for nonEmptyEndpointNestedEntries should allow array of endpoint entries', () => {
const payload: NonEmptyEndpointNestedEntriesArray = [
getEndpointEntryMatchMock(),
getEndpointEntryMatchAnyMock(),
];
const guarded = nonEmptyEndpointNestedEntriesArray.is(payload);
expect(guarded).toBeTruthy();
});
test('type guard for nonEmptyEndpointNestedEntries should disallow empty arrays', () => {
const payload: NonEmptyEndpointNestedEntriesArray = [];
const guarded = nonEmptyEndpointNestedEntriesArray.is(payload);
expect(guarded).toBeFalsy();
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 * as t from 'io-ts';
import { NonEmptyString } from '../../../shared_imports';
import { nonEmptyEndpointNestedEntriesArray } from './non_empty_nested_entries_array';
export const endpointEntryNested = t.exact(
t.type({
entries: nonEmptyEndpointNestedEntriesArray,
field: NonEmptyString,
type: t.keyof({ nested: null }),
})
);
export type EndpointEntryNested = t.TypeOf<typeof endpointEntryNested>;

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export * from './entries';

View file

@ -0,0 +1,45 @@
/*
* 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 * as t from 'io-ts';
import { Either } from 'fp-ts/lib/Either';
import { endpointEntryMatchAny } from './entry_match_any';
import { endpointEntryMatch } from './entry_match';
export const endpointNestedEntriesArray = t.array(
t.union([endpointEntryMatch, endpointEntryMatchAny])
);
export type EndpointNestedEntriesArray = t.TypeOf<typeof endpointNestedEntriesArray>;
/**
* Types the nonEmptyNestedEntriesArray as:
* - An array of entries of length 1 or greater
*
*/
export const nonEmptyEndpointNestedEntriesArray = new t.Type<
EndpointNestedEntriesArray,
EndpointNestedEntriesArray,
unknown
>(
'NonEmptyEndpointNestedEntriesArray',
(u: unknown): u is EndpointNestedEntriesArray => endpointNestedEntriesArray.is(u) && u.length > 0,
(input, context): Either<t.Errors, EndpointNestedEntriesArray> => {
if (Array.isArray(input) && input.length === 0) {
return t.failure(input, context);
} else {
return endpointNestedEntriesArray.validate(input, context);
}
},
t.identity
);
export type NonEmptyEndpointNestedEntriesArray = t.OutputOf<
typeof nonEmptyEndpointNestedEntriesArray
>;
export type NonEmptyEndpointNestedEntriesArrayDecoded = t.TypeOf<
typeof nonEmptyEndpointNestedEntriesArray
>;

View file

@ -17,7 +17,7 @@ import {
import { getExceptionListClient } from './utils/get_exception_list_client';
import { endpointDisallowedFields } from './endpoint_disallowed_fields';
import { validateExceptionListSize } from './validate';
import { validateEndpointExceptionItemEntries, validateExceptionListSize } from './validate';
export const createExceptionListItemRoute = (router: IRouter): void => {
router.post(
@ -73,13 +73,11 @@ export const createExceptionListItemRoute = (router: IRouter): void => {
});
} else {
if (exceptionList.type === 'endpoint') {
const error = validateEndpointExceptionItemEntries(request.body.entries);
if (error != null) {
return siemResponse.error(error);
}
for (const entry of entries) {
if (entry.type === 'list') {
return siemResponse.error({
body: `cannot add exception item with entry of type "list" to endpoint exception list`,
statusCode: 400,
});
}
if (endpointDisallowedFields.includes(entry.field)) {
return siemResponse.error({
body: `cannot add endpoint exception item on field ${entry.field}`,

View file

@ -4,11 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { pipe } from 'fp-ts/lib/pipeable';
import * as t from 'io-ts';
import { fold } from 'fp-ts/lib/Either';
import { ExceptionListClient } from '../services/exception_lists/exception_list_client';
import { MAX_EXCEPTION_LIST_SIZE } from '../../common/constants';
import { foundExceptionListItemSchema } from '../../common/schemas';
import { NamespaceType } from '../../common/schemas/types';
import { validate } from '../../common/shared_imports';
import { NamespaceType, NonEmptyEntriesArray } from '../../common/schemas/types';
import { nonEmptyEndpointEntriesArray } from '../../common/schemas/types/endpoint';
import { exactCheck, validate } from '../../common/shared_imports';
import { formatErrors } from '../siem_server_deps';
export const validateExceptionListSize = async (
exceptionLists: ExceptionListClient,
@ -54,3 +60,20 @@ export const validateExceptionListSize = async (
}
return null;
};
export const validateEndpointExceptionItemEntries = (
entries: NonEmptyEntriesArray
): { body: string[]; statusCode: number } | null =>
pipe(
nonEmptyEndpointEntriesArray.decode(entries),
(decoded) => exactCheck(entries, decoded),
fold(
(errors: t.Errors) => {
return {
body: formatErrors(errors),
statusCode: 400,
};
},
() => null
)
);

View file

@ -8,8 +8,9 @@
"entries": [
{
"field": "actingProcess.file.signer",
"operator": "excluded",
"type": "exists"
"operator": "included",
"type": "match",
"value": "test"
},
{
"field": "host.name",

View file

@ -19,3 +19,5 @@ export {
buildRouteValidation,
readPrivileges,
} from '../../security_solution/server';
export { formatErrors } from '../../security_solution/common';

View file

@ -40,9 +40,11 @@ export default function ({ getService }: FtrProviderContext) {
.set('kbn-xsrf', 'xxx')
.send(badItem)
.expect(400);
expect(body.message).to.eql(
'cannot add exception item with entry of type "list" to endpoint exception list'
);
expect(body.message).to.eql([
'Invalid value "list" supplied to "type"',
'Invalid value "undefined" supplied to "value"',
'Invalid value "undefined" supplied to "entries"',
]);
});
it('should return a 400 if endpoint exception entry has disallowed field', async () => {