diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 428cc90d2908..46ed524ff33e 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -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 = []; diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index 0d52b075ebf1..1556ef5a5dab 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -274,6 +274,7 @@ export type CursorOrUndefined = t.TypeOf; 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; export enum OperatorEnum { diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts index 5c4aff1fedcd..529e173618f1 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts @@ -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, diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts index dacd9d515de5..d1fc167f5a92 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts @@ -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, }) diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entries.mock.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.mock.ts new file mode 100644 index 000000000000..c3bc88ab57a9 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.mock.ts @@ -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(), +]; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entries.test.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.test.ts new file mode 100644 index 000000000000..ee52e6b7b656 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.test.ts @@ -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); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entries.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.ts new file mode 100644 index 000000000000..ebdac1a44293 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.ts @@ -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; + +/** + * 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 => { + 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; +export type NonEmptyEndpointEntriesArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.mock.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.mock.ts new file mode 100644 index 000000000000..35d10544c7fd --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.mock.ts @@ -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, +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.test.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.test.ts new file mode 100644 index 000000000000..48f128be0bb3 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.test.ts @@ -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 & { 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 & { 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 & { 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 & { 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()); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.ts new file mode 100644 index 000000000000..853e71cf68dd --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.ts @@ -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; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.mock.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.mock.ts new file mode 100644 index 000000000000..75544e76dada --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.mock.ts @@ -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], +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.test.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.test.ts new file mode 100644 index 000000000000..6e52855bc25d --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.test.ts @@ -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 & { 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 & { 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 & { 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 & { 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()); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.ts new file mode 100644 index 000000000000..8fda8357c721 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.ts @@ -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; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.mock.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.mock.ts new file mode 100644 index 000000000000..5501631655c6 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.mock.ts @@ -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, +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.test.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.test.ts new file mode 100644 index 000000000000..f19da71a5369 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.test.ts @@ -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 & { 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 & { + 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 & { + 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 & { + 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(); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.ts new file mode 100644 index 000000000000..aad24674af08 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.ts @@ -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; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/index.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/index.ts new file mode 100644 index 000000000000..91554cd441db --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/index.ts @@ -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'; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/non_empty_nested_entries_array.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/non_empty_nested_entries_array.ts new file mode 100644 index 000000000000..f7d6e307da76 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/non_empty_nested_entries_array.ts @@ -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; + +/** + * 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 => { + 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 +>; diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts index f092aec82a8f..e51e113239f2 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts @@ -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}`, diff --git a/x-pack/plugins/lists/server/routes/validate.ts b/x-pack/plugins/lists/server/routes/validate.ts index a7f5c96e13d7..703d0eee8c65 100644 --- a/x-pack/plugins/lists/server/routes/validate.ts +++ b/x-pack/plugins/lists/server/routes/validate.ts @@ -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 + ) + ); diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json index 8ccbe707f204..6999441d2194 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json @@ -8,8 +8,9 @@ "entries": [ { "field": "actingProcess.file.signer", - "operator": "excluded", - "type": "exists" + "operator": "included", + "type": "match", + "value": "test" }, { "field": "host.name", diff --git a/x-pack/plugins/lists/server/siem_server_deps.ts b/x-pack/plugins/lists/server/siem_server_deps.ts index 324103b7fb50..12543566db95 100644 --- a/x-pack/plugins/lists/server/siem_server_deps.ts +++ b/x-pack/plugins/lists/server/siem_server_deps.ts @@ -19,3 +19,5 @@ export { buildRouteValidation, readPrivileges, } from '../../security_solution/server'; + +export { formatErrors } from '../../security_solution/common'; diff --git a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts index bf35a6283aae..0d65b0e66c54 100644 --- a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts +++ b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts @@ -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 () => {