diff --git a/.eslintrc.js b/.eslintrc.js index ace215690c7d1..e8dae54cc49d2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1292,14 +1292,19 @@ module.exports = { * Osquery overrides */ { - extends: ['eslint:recommended', 'plugin:react/recommended'], - plugins: ['react'], + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:@typescript-eslint/recommended', + ], + plugins: ['react', '@typescript-eslint'], files: ['x-pack/plugins/osquery/**/*.{js,mjs,ts,tsx}'], rules: { 'arrow-body-style': ['error', 'as-needed'], 'prefer-arrow-callback': 'error', 'no-unused-vars': 'off', 'react/prop-types': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', }, }, { diff --git a/api_docs/osquery.json b/api_docs/osquery.json index 7f801056de2c8..338bb00726253 100644 --- a/api_docs/osquery.json +++ b/api_docs/osquery.json @@ -16,7 +16,7 @@ "children": [], "source": { "path": "x-pack/plugins/osquery/public/types.ts", - "lineNumber": 14 + "lineNumber": 18 }, "lifecycle": "setup", "initialIsOpen": true @@ -30,7 +30,7 @@ "children": [], "source": { "path": "x-pack/plugins/osquery/public/types.ts", - "lineNumber": 16 + "lineNumber": 20 }, "lifecycle": "start", "initialIsOpen": true @@ -52,7 +52,7 @@ "children": [], "source": { "path": "x-pack/plugins/osquery/server/types.ts", - "lineNumber": 15 + "lineNumber": 16 }, "lifecycle": "setup", "initialIsOpen": true @@ -66,7 +66,7 @@ "children": [], "source": { "path": "x-pack/plugins/osquery/server/types.ts", - "lineNumber": 17 + "lineNumber": 18 }, "lifecycle": "start", "initialIsOpen": true @@ -134,7 +134,7 @@ "lineNumber": 11 }, "signature": [ - "\"osquery\"" + "\"Osquery\"" ], "initialIsOpen": false } diff --git a/package.json b/package.json index 286e646d22441..0da66a6883578 100644 --- a/package.json +++ b/package.json @@ -277,6 +277,7 @@ "react-intl": "^2.8.0", "react-is": "^16.8.0", "react-moment-proptypes": "^1.7.0", + "react-query": "^3.12.0", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-router": "^5.2.0", diff --git a/x-pack/plugins/osquery/common/exact_check.test.ts b/x-pack/plugins/osquery/common/exact_check.test.ts new file mode 100644 index 0000000000000..d4a4ad4ce76ce --- /dev/null +++ b/x-pack/plugins/osquery/common/exact_check.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { left, right, Either } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, findDifferencesRecursive } from './exact_check'; +import { foldLeftRight, getPaths } from './test_utils'; + +describe('exact_check', () => { + test('it returns an error if given extra object properties', () => { + const someType = t.exact( + t.type({ + a: t.string, + }) + ); + const payload = { a: 'test', b: 'test' }; + const decoded = someType.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "b"']); + expect(message.schema).toEqual({}); + }); + + test('it returns an error if the data type is not as expected', () => { + type UnsafeCastForTest = Either< + t.Errors, + { + a: number; + } + >; + + const someType = t.exact( + t.type({ + a: t.string, + }) + ); + + const payload = { a: 1 }; + const decoded = someType.decode(payload); + const checked = exactCheck(payload, decoded as UnsafeCastForTest); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "a"']); + expect(message.schema).toEqual({}); + }); + + test('it does NOT return an error if given normal object properties', () => { + const someType = t.exact( + t.type({ + a: t.string, + }) + ); + const payload = { a: 'test' }; + const decoded = someType.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 will return an existing error and not validate', () => { + const payload = { a: 'test' }; + const validationError: t.ValidationError = { + value: 'Some existing error', + context: [], + message: 'some error', + }; + const error: t.Errors = [validationError]; + const leftValue = left(error); + const checked = exactCheck(payload, leftValue); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['some error']); + expect(message.schema).toEqual({}); + }); + + test('it will work with a regular "right" payload without any decoding', () => { + const payload = { a: 'test' }; + const rightValue = right(payload); + const checked = exactCheck(payload, rightValue); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ a: 'test' }); + }); + + test('it will work with decoding a null payload when the schema expects a null', () => { + const someType = t.union([ + t.exact( + t.type({ + a: t.string, + }) + ), + t.null, + ]); + const payload = null; + const decoded = someType.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(null); + }); + + test('it should find no differences recursively with two empty objects', () => { + const difference = findDifferencesRecursive({}, {}); + expect(difference).toEqual([]); + }); + + test('it should find a single difference with two objects with different keys', () => { + const difference = findDifferencesRecursive({ a: 1 }, { b: 1 }); + expect(difference).toEqual(['a']); + }); + + test('it should find a two differences with two objects with multiple different keys', () => { + const difference = findDifferencesRecursive({ a: 1, c: 1 }, { b: 1 }); + expect(difference).toEqual(['a', 'c']); + }); + + test('it should find no differences with two objects with the same keys', () => { + const difference = findDifferencesRecursive({ a: 1, b: 1 }, { a: 1, b: 1 }); + expect(difference).toEqual([]); + }); + + test('it should find a difference with two deep objects with different same keys', () => { + const difference = findDifferencesRecursive({ a: 1, b: { c: 1 } }, { a: 1, b: { d: 1 } }); + expect(difference).toEqual(['c']); + }); + + test('it should find a difference within an array', () => { + const difference = findDifferencesRecursive({ a: 1, b: [{ c: 1 }] }, { a: 1, b: [{ a: 1 }] }); + expect(difference).toEqual(['c']); + }); + + test('it should find a no difference when using arrays that are identical', () => { + const difference = findDifferencesRecursive({ a: 1, b: [{ c: 1 }] }, { a: 1, b: [{ c: 1 }] }); + expect(difference).toEqual([]); + }); + + test('it should find differences when one has an array and the other does not', () => { + const difference = findDifferencesRecursive({ a: 1, b: [{ c: 1 }] }, { a: 1 }); + expect(difference).toEqual(['b', '[{"c":1}]']); + }); + + test('it should find differences when one has an deep object and the other does not', () => { + const difference = findDifferencesRecursive({ a: 1, b: { c: 1 } }, { a: 1 }); + expect(difference).toEqual(['b', '{"c":1}']); + }); + + test('it should find differences when one has a deep object with multiple levels and the other does not', () => { + const difference = findDifferencesRecursive({ a: 1, b: { c: { d: 1 } } }, { a: 1 }); + expect(difference).toEqual(['b', '{"c":{"d":1}}']); + }); + + test('it tests two deep objects as the same with no key differences', () => { + const difference = findDifferencesRecursive( + { a: 1, b: { c: { d: 1 } } }, + { a: 1, b: { c: { d: 1 } } } + ); + expect(difference).toEqual([]); + }); + + test('it tests two deep objects with just one deep key difference', () => { + const difference = findDifferencesRecursive( + { a: 1, b: { c: { d: 1 } } }, + { a: 1, b: { c: { e: 1 } } } + ); + expect(difference).toEqual(['d']); + }); + + test('it should not find any differences when the original and decoded are both null', () => { + const difference = findDifferencesRecursive(null, null); + expect(difference).toEqual([]); + }); +}); diff --git a/x-pack/plugins/osquery/common/exact_check.ts b/x-pack/plugins/osquery/common/exact_check.ts new file mode 100644 index 0000000000000..b10328a4db233 --- /dev/null +++ b/x-pack/plugins/osquery/common/exact_check.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { left, Either, fold, right } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { isObject, get } from 'lodash/fp'; + +/** + * Given an original object and a decoded object this will return an error + * if and only if the original object has additional keys that the decoded + * object does not have. If the original decoded already has an error, then + * this will return the error as is and not continue. + * + * NOTE: You MUST use t.exact(...) for this to operate correctly as your schema + * needs to remove additional keys before the compare + * + * You might not need this in the future if the below issue is solved: + * https://github.com/gcanti/io-ts/issues/322 + * + * @param original The original to check if it has additional keys + * @param decoded The decoded either which has either an existing error or the + * decoded object which could have additional keys stripped from it. + */ +export const exactCheck = ( + original: unknown, + decoded: Either +): Either => { + const onLeft = (errors: t.Errors): Either => left(errors); + const onRight = (decodedValue: T): Either => { + const differences = findDifferencesRecursive(original, decodedValue); + if (differences.length !== 0) { + const validationError: t.ValidationError = { + value: differences, + context: [], + message: `invalid keys "${differences.join(',')}"`, + }; + const error: t.Errors = [validationError]; + return left(error); + } else { + return right(decodedValue); + } + }; + return pipe(decoded, fold(onLeft, onRight)); +}; + +export const findDifferencesRecursive = (original: unknown, decodedValue: T): string[] => { + if (decodedValue === null && original === null) { + // both the decodedValue and the original are null which indicates that they are equal + // so do not report differences + return []; + } else if (decodedValue == null) { + try { + // It is null and painful when the original contains an object or an array + // the the decoded value does not have. + return [JSON.stringify(original)]; + } catch (err) { + return ['circular reference']; + } + } else if (typeof original !== 'object' || original == null) { + // We are not an object or null so do not report differences + return []; + } else { + const decodedKeys = Object.keys(decodedValue); + const differences = Object.keys(original).flatMap((originalKey) => { + const foundKey = decodedKeys.some((key) => key === originalKey); + const topLevelKey = foundKey ? [] : [originalKey]; + // I use lodash to cheat and get an any (not going to lie ;-)) + const valueObjectOrArrayOriginal = get(originalKey, original); + const valueObjectOrArrayDecoded = get(originalKey, decodedValue); + if (isObject(valueObjectOrArrayOriginal)) { + return [ + ...topLevelKey, + ...findDifferencesRecursive(valueObjectOrArrayOriginal, valueObjectOrArrayDecoded), + ]; + } else if (Array.isArray(valueObjectOrArrayOriginal)) { + return [ + ...topLevelKey, + ...valueObjectOrArrayOriginal.flatMap((arrayElement, index) => + findDifferencesRecursive(arrayElement, get(index, valueObjectOrArrayDecoded)) + ), + ]; + } else { + return topLevelKey; + } + }); + return differences; + } +}; diff --git a/x-pack/plugins/osquery/common/format_errors.test.ts b/x-pack/plugins/osquery/common/format_errors.test.ts new file mode 100644 index 0000000000000..149bae85fec8a --- /dev/null +++ b/x-pack/plugins/osquery/common/format_errors.test.ts @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { formatErrors } from './format_errors'; + +describe('utils', () => { + test('returns an empty error message string if there are no errors', () => { + const errors: t.Errors = []; + const output = formatErrors(errors); + expect(output).toEqual([]); + }); + + test('returns a single error message if given one', () => { + const validationError: t.ValidationError = { + value: 'Some existing error', + context: [], + message: 'some error', + }; + const errors: t.Errors = [validationError]; + const output = formatErrors(errors); + expect(output).toEqual(['some error']); + }); + + test('returns a two error messages if given two', () => { + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context: [], + message: 'some error 1', + }; + const validationError2: t.ValidationError = { + value: 'Some existing error 2', + context: [], + message: 'some error 2', + }; + const errors: t.Errors = [validationError1, validationError2]; + const output = formatErrors(errors); + expect(output).toEqual(['some error 1', 'some error 2']); + }); + + test('it filters out duplicate error messages', () => { + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context: [], + message: 'some error 1', + }; + const validationError2: t.ValidationError = { + value: 'Some existing error 1', + context: [], + message: 'some error 1', + }; + const errors: t.Errors = [validationError1, validationError2]; + const output = formatErrors(errors); + expect(output).toEqual(['some error 1']); + }); + + test('will use message before context if it is set', () => { + const context: t.Context = ([{ key: 'some string key' }] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + message: 'I should be used first', + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual(['I should be used first']); + }); + + test('will use context entry of a single string', () => { + const context: t.Context = ([{ key: 'some string key' }] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual(['Invalid value "Some existing error 1" supplied to "some string key"']); + }); + + test('will use two context entries of two strings', () => { + const context: t.Context = ([ + { key: 'some string key 1' }, + { key: 'some string key 2' }, + ] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual([ + 'Invalid value "Some existing error 1" supplied to "some string key 1,some string key 2"', + ]); + }); + + test('will filter out and not use any strings of numbers', () => { + const context: t.Context = ([ + { key: '5' }, + { key: 'some string key 2' }, + ] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual([ + 'Invalid value "Some existing error 1" supplied to "some string key 2"', + ]); + }); + + test('will filter out and not use null', () => { + const context: t.Context = ([ + { key: null }, + { key: 'some string key 2' }, + ] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual([ + 'Invalid value "Some existing error 1" supplied to "some string key 2"', + ]); + }); + + test('will filter out and not use empty strings', () => { + const context: t.Context = ([ + { key: '' }, + { key: 'some string key 2' }, + ] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual([ + 'Invalid value "Some existing error 1" supplied to "some string key 2"', + ]); + }); + + test('will use a name context if it cannot find a keyContext', () => { + const context: t.Context = ([ + { key: '' }, + { key: '', type: { name: 'someName' } }, + ] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual(['Invalid value "Some existing error 1" supplied to "someName"']); + }); + + test('will return an empty string if name does not exist but type does', () => { + const context: t.Context = ([{ key: '' }, { key: '', type: {} }] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: 'Some existing error 1', + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual(['Invalid value "Some existing error 1" supplied to ""']); + }); + + test('will stringify an error value', () => { + const context: t.Context = ([ + { key: '' }, + { key: 'some string key 2' }, + ] as unknown) as t.Context; + const validationError1: t.ValidationError = { + value: { foo: 'some error' }, + context, + }; + const errors: t.Errors = [validationError1]; + const output = formatErrors(errors); + expect(output).toEqual([ + 'Invalid value "{"foo":"some error"}" supplied to "some string key 2"', + ]); + }); +}); diff --git a/x-pack/plugins/osquery/common/format_errors.ts b/x-pack/plugins/osquery/common/format_errors.ts new file mode 100644 index 0000000000000..7b33612b4e887 --- /dev/null +++ b/x-pack/plugins/osquery/common/format_errors.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { isObject } from 'lodash/fp'; + +export const formatErrors = (errors: t.Errors): string[] => { + const err = errors.map((error) => { + if (error.message != null) { + return error.message; + } else { + const keyContext = error.context + .filter( + (entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== '' + ) + .map((entry) => entry.key) + .join(','); + + const nameContext = error.context.find((entry) => entry.type?.name?.length > 0); + const suppliedValue = + keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : ''; + const value = isObject(error.value) ? JSON.stringify(error.value) : error.value; + return `Invalid value "${value}" supplied to "${suppliedValue}"`; + } + }); + + return [...new Set(err)]; +}; diff --git a/x-pack/plugins/osquery/common/index.ts b/x-pack/plugins/osquery/common/index.ts index 59381629cdca8..fd2c71e290e46 100644 --- a/x-pack/plugins/osquery/common/index.ts +++ b/x-pack/plugins/osquery/common/index.ts @@ -8,4 +8,4 @@ export * from './constants'; export const PLUGIN_ID = 'osquery'; -export const PLUGIN_NAME = 'osquery'; +export const PLUGIN_NAME = 'Osquery'; diff --git a/x-pack/plugins/osquery/common/schemas/common/index.ts b/x-pack/plugins/osquery/common/schemas/common/index.ts new file mode 100644 index 0000000000000..7aa477e1db748 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/common/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './schemas'; diff --git a/x-pack/plugins/osquery/common/schemas/common/schemas.ts b/x-pack/plugins/osquery/common/schemas/common/schemas.ts new file mode 100644 index 0000000000000..ffcadc7cfea8f --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/common/schemas.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; + +export const name = t.string; +export type Name = t.TypeOf; +export const nameOrUndefined = t.union([name, t.undefined]); +export type NameOrUndefined = t.TypeOf; + +export const description = t.string; +export type Description = t.TypeOf; +export const descriptionOrUndefined = t.union([description, t.undefined]); +export type DescriptionOrUndefined = t.TypeOf; + +export const platform = t.string; +export type Platform = t.TypeOf; +export const platformOrUndefined = t.union([platform, t.undefined]); +export type PlatformOrUndefined = t.TypeOf; + +export const query = t.string; +export type Query = t.TypeOf; +export const queryOrUndefined = t.union([query, t.undefined]); +export type QueryOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/osquery/common/schemas/routes/saved_query/create_saved_query_request_schema.ts b/x-pack/plugins/osquery/common/schemas/routes/saved_query/create_saved_query_request_schema.ts new file mode 100644 index 0000000000000..9e901be1476bc --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/routes/saved_query/create_saved_query_request_schema.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; + +import { name, description, Description, platform, query } from '../../common/schemas'; +import { RequiredKeepUndefined } from '../../../types'; + +export const createSavedQueryRequestSchema = t.type({ + name, + description, + platform, + query, +}); + +export type CreateSavedQueryRequestSchema = t.OutputOf; + +// This type is used after a decode since some things are defaults after a decode. +export type CreateSavedQueryRequestSchemaDecoded = Omit< + RequiredKeepUndefined>, + 'description' +> & { + description: Description; +}; diff --git a/x-pack/plugins/osquery/common/schemas/routes/saved_query/index.ts b/x-pack/plugins/osquery/common/schemas/routes/saved_query/index.ts new file mode 100644 index 0000000000000..6fbd10c7db6ca --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/routes/saved_query/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './create_saved_query_request_schema'; diff --git a/x-pack/plugins/osquery/common/schemas/types/default_uuid.test.ts b/x-pack/plugins/osquery/common/schemas/types/default_uuid.test.ts new file mode 100644 index 0000000000000..24c5986d5ef20 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/types/default_uuid.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DefaultUuid } from './default_uuid'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../test_utils'; + +describe('default_uuid', () => { + test('it should validate a regular string', () => { + const payload = '1'; + const decoded = DefaultUuid.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate a number', () => { + const payload = 5; + const decoded = DefaultUuid.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to "DefaultUuid"']); + expect(message.schema).toEqual({}); + }); + + test('it should return a default of a uuid', () => { + const payload = null; + const decoded = DefaultUuid.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); +}); diff --git a/x-pack/plugins/osquery/common/schemas/types/default_uuid.ts b/x-pack/plugins/osquery/common/schemas/types/default_uuid.ts new file mode 100644 index 0000000000000..0546862748b36 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/types/default_uuid.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; +import uuid from 'uuid'; + +import { NonEmptyString } from './non_empty_string'; + +/** + * Types the DefaultUuid as: + * - If null or undefined, then a default string uuid.v4() will be + * created otherwise it will be checked just against an empty string + */ +export const DefaultUuid = new t.Type( + 'DefaultUuid', + t.string.is, + (input, context): Either => + input == null ? t.success(uuid.v4()) : NonEmptyString.validate(input, context), + t.identity +); diff --git a/x-pack/plugins/osquery/common/schemas/types/non_empty_string.test.ts b/x-pack/plugins/osquery/common/schemas/types/non_empty_string.test.ts new file mode 100644 index 0000000000000..e379bcd5b8ea7 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/types/non_empty_string.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { NonEmptyString } from './non_empty_string'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../test_utils'; + +describe('non_empty_string', () => { + test('it should validate a regular string', () => { + const payload = '1'; + const decoded = NonEmptyString.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate a number', () => { + const payload = 5; + const decoded = NonEmptyString.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "NonEmptyString"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate an empty string', () => { + const payload = ''; + const decoded = NonEmptyString.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "" supplied to "NonEmptyString"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate empty spaces', () => { + const payload = ' '; + const decoded = NonEmptyString.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value " " supplied to "NonEmptyString"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/osquery/common/schemas/types/non_empty_string.ts b/x-pack/plugins/osquery/common/schemas/types/non_empty_string.ts new file mode 100644 index 0000000000000..5ba85f2ab0249 --- /dev/null +++ b/x-pack/plugins/osquery/common/schemas/types/non_empty_string.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the NonEmptyString as: + * - A string that is not empty + */ +export const NonEmptyString = new t.Type( + 'NonEmptyString', + t.string.is, + (input, context): Either => { + if (typeof input === 'string' && input.trim() !== '') { + return t.success(input); + } else { + return t.failure(input, context); + } + }, + t.identity +); + +export type NonEmptyStringC = typeof NonEmptyString; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts index 194b0aad62af7..561866d5077a6 100644 --- a/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts @@ -21,9 +21,10 @@ export interface ActionsStrategyResponse extends IEsSearchResponse { inspect?: Maybe; } -export type ActionsRequestOptions = RequestOptionsPaginated<{}>; +export type ActionsRequestOptions = RequestOptionsPaginated; export interface ActionDetailsStrategyResponse extends IEsSearchResponse { + // eslint-disable-next-line @typescript-eslint/no-explicit-any actionDetails: Record; inspect?: Maybe; } diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts index ca0a6bbff5713..57c7a42f2a481 100644 --- a/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts @@ -18,4 +18,4 @@ export interface AgentsStrategyResponse extends IEsSearchResponse { inspect?: Maybe; } -export type AgentsRequestOptions = RequestOptionsPaginated<{}>; +export type AgentsRequestOptions = RequestOptionsPaginated; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts index 36d8836281063..add8598bb77ad 100644 --- a/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts @@ -20,6 +20,7 @@ export interface ResultsStrategyResponse extends IEsSearchResponse { inspect?: Maybe; } -export interface ResultsRequestOptions extends RequestOptionsPaginated<{}> { +export interface ResultsRequestOptions extends RequestOptionsPaginated { actionId: string; + agentId?: string; } diff --git a/x-pack/plugins/osquery/common/test_utils.ts b/x-pack/plugins/osquery/common/test_utils.ts new file mode 100644 index 0000000000000..84bf0d25057a2 --- /dev/null +++ b/x-pack/plugins/osquery/common/test_utils.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { formatErrors } from './format_errors'; + +interface Message { + errors: t.Errors; + schema: T | {}; +} + +const onLeft = (errors: t.Errors): Message => { + return { errors, schema: {} }; +}; + +const onRight = (schema: T): Message => { + return { + errors: [], + schema, + }; +}; + +export const foldLeftRight = fold(onLeft, onRight); + +/** + * Convenience utility to keep the error message handling within tests to be + * very concise. + * @param validation The validation to get the errors from + */ +export const getPaths = (validation: t.Validation): string[] => { + return pipe( + validation, + fold( + (errors) => formatErrors(errors), + () => ['no errors'] + ) + ); +}; + +/** + * Convenience utility to remove text appended to links by EUI + */ +export const removeExternalLinkText = (str: string): string => + str.replace(/\(opens in a new tab or window\)/g, ''); diff --git a/x-pack/plugins/osquery/common/types.ts b/x-pack/plugins/osquery/common/types.ts new file mode 100644 index 0000000000000..11c418a51fc7c --- /dev/null +++ b/x-pack/plugins/osquery/common/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const savedQuerySavedObjectType = 'osquery-saved-query'; +export const packSavedObjectType = 'osquery-pack'; +export type SavedObjectType = 'osquery-saved-query' | 'osquery-pack'; + +/** + * This makes any optional property the same as Required would but also has the + * added benefit of keeping your undefined. + * + * For example: + * type A = RequiredKeepUndefined<{ a?: undefined; b: number }>; + * + * will yield a type of: + * type A = { a: undefined; b: number; } + * + */ +export type RequiredKeepUndefined = { [K in keyof T]-?: [T[K]] } extends infer U + ? U extends Record + ? { [K in keyof U]: U[K][0] } + : never + : never; diff --git a/x-pack/plugins/osquery/kibana.json b/x-pack/plugins/osquery/kibana.json index 8adb30f4271d0..fea20d9fb3cb5 100644 --- a/x-pack/plugins/osquery/kibana.json +++ b/x-pack/plugins/osquery/kibana.json @@ -17,10 +17,12 @@ "kibanaReact" ], "requiredPlugins": [ + "actions", "data", "dataEnhanced", "fleet", - "navigation" + "navigation", + "triggersActionsUi" ], "server": true, "ui": true, diff --git a/x-pack/plugins/osquery/public/action_results/action_results_table.tsx b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx index 0f5c3a3c96292..1dd5b63eedc23 100644 --- a/x-pack/plugins/osquery/public/action_results/action_results_table.tsx +++ b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx @@ -5,12 +5,23 @@ * 2.0. */ -import { isEmpty, isEqual, keys, map } from 'lodash/fp'; -import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiDataGridSorting } from '@elastic/eui'; -import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; - -import { useAllResults } from './use_action_results'; +import { find, map } from 'lodash/fp'; +import { + EuiDataGrid, + EuiDataGridProps, + EuiDataGridColumn, + EuiDataGridSorting, + EuiHealth, + EuiIcon, + EuiLink, +} from '@elastic/eui'; +import React, { createContext, useState, useCallback, useContext, useMemo } from 'react'; + +import { useAllAgents } from './../agents/use_all_agents'; +import { useActionResults } from './use_action_results'; +import { useAllResults } from '../results/use_all_results'; import { Direction, ResultEdges } from '../../common/search_strategy'; +import { useRouterNavigate } from '../common/lib/kibana'; const DataContext = createContext([]); @@ -34,12 +45,38 @@ const ActionResultsTableComponent: React.FC = ({ action [setPagination] ); - const [columns, setColumns] = useState([]); + const [columns] = useState([ + { + id: 'status', + displayAsText: 'status', + defaultSortDirection: Direction.asc, + }, + { + id: 'rows_count', + displayAsText: '# rows', + defaultSortDirection: Direction.asc, + }, + { + id: 'agent_status', + displayAsText: 'online', + defaultSortDirection: Direction.asc, + }, + { + id: 'agent', + displayAsText: 'agent', + defaultSortDirection: Direction.asc, + }, + { + id: '@timestamp', + displayAsText: '@timestamp', + defaultSortDirection: Direction.asc, + }, + ]); // ** Sorting config const [sortingColumns, setSortingColumns] = useState([]); - const [, { results, totalCount }] = useAllResults({ + const { data: actionResultsData } = useActionResults({ actionId, activePage: pagination.pageIndex, limit: pagination.pageSize, @@ -47,23 +84,85 @@ const ActionResultsTableComponent: React.FC = ({ action sortField: '@timestamp', }); - // Column visibility - const [visibleColumns, setVisibleColumns] = useState([]); // initialize to the full set of columns + const [visibleColumns, setVisibleColumns] = useState(() => map('id', columns)); // initialize to the full set of columns const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns }), [ visibleColumns, setVisibleColumns, ]); + const { data: agentsData } = useAllAgents({ + activePage: 0, + limit: 1000, + direction: Direction.desc, + sortField: 'updated_at', + }); + const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo( - () => ({ rowIndex, columnId, setCellProps }) => { + () => ({ rowIndex, columnId }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const data = useContext(DataContext); - const value = data[rowIndex].fields[columnId]; - - return !isEmpty(value) ? value : '-'; + const value = data[rowIndex]; + + if (columnId === 'status') { + // eslint-disable-next-line react-hooks/rules-of-hooks + const linkProps = useRouterNavigate( + `/live_query/${actionId}/results/${value.fields.agent_id[0]}` + ); + + return ( + <> + + {'View results'} + + ); + } + + if (columnId === 'rows_count') { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { data: allResultsData } = useAllResults({ + actionId, + agentId: value.fields.agent_id[0], + activePage: pagination.pageIndex, + limit: pagination.pageSize, + direction: Direction.asc, + sortField: '@timestamp', + }); + // @ts-expect-error update types + return allResultsData?.totalCount ?? '-'; + } + + if (columnId === 'agent_status') { + const agentIdValue = value.fields.agent_id[0]; + // @ts-expect-error update types + const agent = find(['_id', agentIdValue], agentsData?.agents); + const online = agent?.active; + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + } + + if (columnId === 'agent') { + const agentIdValue = value.fields.agent_id[0]; + // @ts-expect-error update types + const agent = find(['_id', agentIdValue], agentsData?.agents); + const agentName = agent?.local_metadata.host.name; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const linkProps = useRouterNavigate(`/live_query/${actionId}/results/${agentIdValue}`); + return ( + {`(${agent?.local_metadata.os.name}) ${agentName}`} + ); + } + + if (columnId === '@timestamp') { + return value.fields['@timestamp']; + } + + return '-'; }, - [] + // @ts-expect-error update types + [actionId, agentsData?.agents, pagination.pageIndex, pagination.pageSize] ); const tableSorting: EuiDataGridSorting = useMemo( @@ -81,31 +180,19 @@ const ActionResultsTableComponent: React.FC = ({ action [onChangeItemsPerPage, onChangePage, pagination] ); - useEffect(() => { - const newColumns = keys(results[0]?.fields) - .sort() - .map((fieldName) => ({ - id: fieldName, - displayAsText: fieldName.split('.')[1], - defaultSortDirection: Direction.asc, - })); - - if (!isEqual(columns, newColumns)) { - setColumns(newColumns); - setVisibleColumns(map('id', newColumns)); - } - }, [columns, results]); - return ( - + // @ts-expect-error update types + ); diff --git a/x-pack/plugins/osquery/public/action_results/translations.ts b/x-pack/plugins/osquery/public/action_results/translations.ts index 0f785f0c1f4d1..272b48d1b12e6 100644 --- a/x-pack/plugins/osquery/public/action_results/translations.ts +++ b/x-pack/plugins/osquery/public/action_results/translations.ts @@ -7,10 +7,16 @@ import { i18n } from '@kbn/i18n'; -export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', { - defaultMessage: `An error has occurred on all results search`, -}); +export const ERROR_ACTION_RESULTS = i18n.translate( + 'xpack.osquery.action_results.errorSearchDescription', + { + defaultMessage: `An error has occurred on action results search`, + } +); -export const FAIL_ALL_RESULTS = i18n.translate('xpack.osquery.results.failSearchDescription', { - defaultMessage: `Failed to fetch results`, -}); +export const FAIL_ACTION_RESULTS = i18n.translate( + 'xpack.osquery.action_results.failSearchDescription', + { + defaultMessage: `Failed to fetch action results`, + } +); diff --git a/x-pack/plugins/osquery/public/action_results/use_action_results.ts b/x-pack/plugins/osquery/public/action_results/use_action_results.ts index 389dded243277..58a877e799703 100644 --- a/x-pack/plugins/osquery/public/action_results/use_action_results.ts +++ b/x-pack/plugins/osquery/public/action_results/use_action_results.ts @@ -6,14 +6,14 @@ */ import deepEqual from 'fast-deep-equal'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useQuery } from 'react-query'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { ResultEdges, PageInfoPaginated, - DocValueFields, OsqueryQueries, ResultsRequestOptions, ResultsStrategyResponse, @@ -21,13 +21,8 @@ import { } from '../../common/search_strategy'; import { ESTermQuery } from '../../common/typed_json'; -import * as i18n from './translations'; -import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; -import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; -const ID = 'resultsAllQuery'; - export interface ResultsArgs { results: ResultEdges; id: string; @@ -37,103 +32,50 @@ export interface ResultsArgs { totalCount: number; } -interface UseAllResults { +interface UseActionResults { actionId: string; activePage: number; direction: Direction; limit: number; sortField: string; - docValueFields?: DocValueFields[]; filterQuery?: ESTermQuery | string; skip?: boolean; } -export const useAllResults = ({ +export const useActionResults = ({ actionId, activePage, direction, limit, sortField, - docValueFields, filterQuery, skip = false, -}: UseAllResults): [boolean, ResultsArgs] => { - const { data, notifications } = useKibana().services; +}: UseActionResults) => { + const { data } = useKibana().services; - const abortCtrl = useRef(new AbortController()); - const [loading, setLoading] = useState(false); const [resultsRequest, setHostRequest] = useState(null); - const [resultsResponse, setResultsResponse] = useState({ - results: [], - id: ID, - inspect: { - dsl: [], - response: [], - }, - isInspected: false, - pageInfo: { - activePage: 0, - fakeTotalCount: 0, - showMorePagesIndicator: false, - }, - totalCount: -1, - }); - - const resultsSearch = useCallback( - (request: ResultsRequestOptions | null) => { - if (request == null || skip) { - return; - } + const response = useQuery( + ['actionResults', { actionId, activePage, direction, limit, sortField }], + async () => { + if (!resultsRequest) return Promise.resolve(); - let didCancel = false; - const asyncSearch = async () => { - abortCtrl.current = new AbortController(); - setLoading(true); + const responseData = await data.search + .search(resultsRequest, { + strategy: 'osquerySearchStrategy', + }) + .toPromise(); - const searchSubscription$ = data.search - .search(request, { - strategy: 'osquerySearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (response) => { - if (isCompleteResponse(response)) { - if (!didCancel) { - setLoading(false); - setResultsResponse((prevResponse) => ({ - ...prevResponse, - results: response.edges, - inspect: getInspectResponse(response, prevResponse.inspect), - pageInfo: response.pageInfo, - totalCount: response.totalCount, - })); - } - searchSubscription$.unsubscribe(); - } else if (isErrorResponse(response)) { - if (!didCancel) { - setLoading(false); - } - // TODO: Make response error status clearer - notifications.toasts.addWarning(i18n.ERROR_ALL_RESULTS); - searchSubscription$.unsubscribe(); - } - }, - error: (msg) => { - if (!(msg instanceof AbortError)) { - notifications.toasts.addDanger({ title: i18n.FAIL_ALL_RESULTS, text: msg.message }); - } - }, - }); - }; - abortCtrl.current.abort(); - asyncSearch(); - return () => { - didCancel = true; - abortCtrl.current.abort(); + return { + ...responseData, + results: responseData.edges, + inspect: getInspectResponse(responseData, {} as InspectResponse), }; }, - [data.search, notifications.toasts, skip] + { + refetchInterval: 1000, + enabled: !skip && !!resultsRequest, + } ); useEffect(() => { @@ -141,7 +83,6 @@ export const useAllResults = ({ const myRequest = { ...(prevRequest ?? {}), actionId, - docValueFields: docValueFields ?? [], factoryQueryType: OsqueryQueries.actionResults, filterQuery: createFilter(filterQuery), pagination: generateTablePaginationOptions(activePage, limit), @@ -155,11 +96,7 @@ export const useAllResults = ({ } return prevRequest; }); - }, [actionId, activePage, direction, docValueFields, filterQuery, limit, sortField]); - - useEffect(() => { - resultsSearch(resultsRequest); - }, [resultsRequest, resultsSearch]); + }, [actionId, activePage, direction, filterQuery, limit, sortField]); - return [loading, resultsResponse]; + return response; }; diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx index 5fc33ab73d28a..986b46b1a4089 100644 --- a/x-pack/plugins/osquery/public/actions/actions_table.tsx +++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx @@ -6,11 +6,19 @@ */ import { isEmpty, isEqual, keys, map } from 'lodash/fp'; -import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiDataGridSorting } from '@elastic/eui'; +import { + EuiLink, + EuiDataGrid, + EuiDataGridProps, + EuiDataGridColumn, + EuiDataGridSorting, + EuiLoadingContent, +} from '@elastic/eui'; import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; import { useAllActions } from './use_all_actions'; import { ActionEdges, Direction } from '../../common/search_strategy'; +import { useRouterNavigate } from '../common/lib/kibana'; const DataContext = createContext([]); @@ -35,10 +43,10 @@ const ActionsTableComponent = () => { // ** Sorting config const [sortingColumns, setSortingColumns] = useState([]); - const [, { actions, totalCount }] = useAllActions({ + const { isLoading: actionsLoading, data: actionsData } = useAllActions({ activePage: pagination.pageIndex, limit: pagination.pageSize, - direction: Direction.asc, + direction: Direction.desc, sortField: '@timestamp', }); @@ -50,15 +58,22 @@ const ActionsTableComponent = () => { setVisibleColumns, ]); - const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo(() => { - return ({ rowIndex, columnId, setCellProps }) => { + const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo( + () => ({ rowIndex, columnId }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const data = useContext(DataContext); const value = data[rowIndex].fields[columnId]; + if (columnId === 'action_id') { + // eslint-disable-next-line react-hooks/rules-of-hooks + const linkProps = useRouterNavigate(`/live_query/${value}`); + return {value}; + } + return !isEmpty(value) ? value : '-'; - }; - }, []); + }, + [] + ); const tableSorting: EuiDataGridSorting = useMemo( () => ({ columns: sortingColumns, onSort: setSortingColumns }), @@ -76,7 +91,8 @@ const ActionsTableComponent = () => { ); useEffect(() => { - const newColumns = keys(actions[0]?.fields) + // @ts-expect-error update types + const newColumns = keys(actionsData?.actions[0]?.fields) .sort() .map((fieldName) => ({ id: fieldName, @@ -88,15 +104,23 @@ const ActionsTableComponent = () => { setColumns(newColumns); setVisibleColumns(map('id', newColumns)); } - }, [columns, actions]); + // @ts-expect-error update types + }, [columns, actionsData?.actions]); + + if (actionsLoading) { + return ; + } return ( - + // @ts-expect-error update types + // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop + ; id: string; @@ -34,88 +29,34 @@ export interface ActionDetailsArgs { interface UseActionDetails { actionId: string; - docValueFields?: DocValueFields[]; filterQuery?: ESTermQuery | string; skip?: boolean; } -export const useActionDetails = ({ - actionId, - docValueFields, - filterQuery, - skip = false, -}: UseActionDetails): [boolean, ActionDetailsArgs] => { - const { data, notifications } = useKibana().services; +export const useActionDetails = ({ actionId, filterQuery, skip = false }: UseActionDetails) => { + const { data } = useKibana().services; - const abortCtrl = useRef(new AbortController()); - const [loading, setLoading] = useState(false); const [actionDetailsRequest, setHostRequest] = useState(null); - const [actionDetailsResponse, setActionDetailsResponse] = useState({ - actionDetails: {}, - id: ID, - inspect: { - dsl: [], - response: [], - }, - isInspected: false, - }); - - const actionDetailsSearch = useCallback( - (request: ActionDetailsRequestOptions | null) => { - if (request == null || skip) { - return; - } + const response = useQuery( + ['action', { actionId }], + async () => { + if (!actionDetailsRequest) return Promise.resolve(); - let didCancel = false; - const asyncSearch = async () => { - abortCtrl.current = new AbortController(); - setLoading(true); + const responseData = await data.search + .search(actionDetailsRequest, { + strategy: 'osquerySearchStrategy', + }) + .toPromise(); - const searchSubscription$ = data.search - .search(request, { - strategy: 'osquerySearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (response) => { - if (isCompleteResponse(response)) { - if (!didCancel) { - setLoading(false); - setActionDetailsResponse((prevResponse) => ({ - ...prevResponse, - actionDetails: response.actionDetails, - inspect: getInspectResponse(response, prevResponse.inspect), - })); - } - searchSubscription$.unsubscribe(); - } else if (isErrorResponse(response)) { - if (!didCancel) { - setLoading(false); - } - // TODO: Make response error status clearer - notifications.toasts.addWarning(i18n.ERROR_ACTION_DETAILS); - searchSubscription$.unsubscribe(); - } - }, - error: (msg) => { - if (!(msg instanceof AbortError)) { - notifications.toasts.addDanger({ - title: i18n.FAIL_ACTION_DETAILS, - text: msg.message, - }); - } - }, - }); - }; - abortCtrl.current.abort(); - asyncSearch(); - return () => { - didCancel = true; - abortCtrl.current.abort(); + return { + ...responseData, + inspect: getInspectResponse(responseData, {} as InspectResponse), }; }, - [data.search, notifications.toasts, skip] + { + enabled: !skip && !!actionDetailsRequest, + } ); useEffect(() => { @@ -123,7 +64,6 @@ export const useActionDetails = ({ const myRequest = { ...(prevRequest ?? {}), actionId, - docValueFields: docValueFields ?? [], factoryQueryType: OsqueryQueries.actionDetails, filterQuery: createFilter(filterQuery), }; @@ -132,11 +72,7 @@ export const useActionDetails = ({ } return prevRequest; }); - }, [actionId, docValueFields, filterQuery]); - - useEffect(() => { - actionDetailsSearch(actionDetailsRequest); - }, [actionDetailsRequest, actionDetailsSearch]); + }, [actionId, filterQuery]); - return [loading, actionDetailsResponse]; + return response; }; diff --git a/x-pack/plugins/osquery/public/actions/use_all_actions.ts b/x-pack/plugins/osquery/public/actions/use_all_actions.ts index e351995752ca7..2b76435efff0a 100644 --- a/x-pack/plugins/osquery/public/actions/use_all_actions.ts +++ b/x-pack/plugins/osquery/public/actions/use_all_actions.ts @@ -5,15 +5,15 @@ * 2.0. */ +import { useEffect, useState } from 'react'; +import { useQuery } from 'react-query'; import deepEqual from 'fast-deep-equal'; -import { useCallback, useEffect, useRef, useState } from 'react'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { ActionEdges, PageInfoPaginated, - DocValueFields, OsqueryQueries, ActionsRequestOptions, ActionsStrategyResponse, @@ -21,13 +21,8 @@ import { } from '../../common/search_strategy'; import { ESTermQuery } from '../../common/typed_json'; -import * as i18n from './translations'; -import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; -import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; -const ID = 'actionsAllQuery'; - export interface ActionsArgs { actions: ActionEdges; id: string; @@ -42,7 +37,6 @@ interface UseAllActions { direction: Direction; limit: number; sortField: string; - docValueFields?: DocValueFields[]; filterQuery?: ESTermQuery | string; skip?: boolean; } @@ -52,93 +46,39 @@ export const useAllActions = ({ direction, limit, sortField, - docValueFields, filterQuery, skip = false, -}: UseAllActions): [boolean, ActionsArgs] => { - const { data, notifications } = useKibana().services; +}: UseAllActions) => { + const { data } = useKibana().services; - const abortCtrl = useRef(new AbortController()); - const [loading, setLoading] = useState(false); const [actionsRequest, setHostRequest] = useState(null); - const [actionsResponse, setActionsResponse] = useState({ - actions: [], - id: ID, - inspect: { - dsl: [], - response: [], - }, - isInspected: false, - pageInfo: { - activePage: 0, - fakeTotalCount: 0, - showMorePagesIndicator: false, - }, - totalCount: -1, - }); - - const actionsSearch = useCallback( - (request: ActionsRequestOptions | null) => { - if (request == null || skip) { - return; - } + const response = useQuery( + ['actions', { activePage, direction, limit, sortField }], + async () => { + if (!actionsRequest) return Promise.resolve(); - let didCancel = false; - const asyncSearch = async () => { - abortCtrl.current = new AbortController(); - setLoading(true); + const responseData = await data.search + .search(actionsRequest, { + strategy: 'osquerySearchStrategy', + }) + .toPromise(); - const searchSubscription$ = data.search - .search(request, { - strategy: 'osquerySearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (response) => { - if (isCompleteResponse(response)) { - if (!didCancel) { - setLoading(false); - setActionsResponse((prevResponse) => ({ - ...prevResponse, - actions: response.edges, - inspect: getInspectResponse(response, prevResponse.inspect), - pageInfo: response.pageInfo, - totalCount: response.totalCount, - })); - } - searchSubscription$.unsubscribe(); - } else if (isErrorResponse(response)) { - if (!didCancel) { - setLoading(false); - } - // TODO: Make response error status clearer - notifications.toasts.addWarning(i18n.ERROR_ALL_ACTIONS); - searchSubscription$.unsubscribe(); - } - }, - error: (msg) => { - if (!(msg instanceof AbortError)) { - notifications.toasts.addDanger({ title: i18n.FAIL_ALL_ACTIONS, text: msg.message }); - } - }, - }); - }; - abortCtrl.current.abort(); - asyncSearch(); - return () => { - didCancel = true; - abortCtrl.current.abort(); + return { + ...responseData, + actions: responseData.edges, + inspect: getInspectResponse(responseData, {} as InspectResponse), }; }, - [data.search, notifications.toasts, skip] + { + enabled: !skip && !!actionsRequest, + } ); useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { ...(prevRequest ?? {}), - docValueFields: docValueFields ?? [], factoryQueryType: OsqueryQueries.actions, filterQuery: createFilter(filterQuery), pagination: generateTablePaginationOptions(activePage, limit), @@ -152,11 +92,7 @@ export const useAllActions = ({ } return prevRequest; }); - }, [activePage, direction, docValueFields, filterQuery, limit, sortField]); - - useEffect(() => { - actionsSearch(actionsRequest); - }, [actionsRequest, actionsSearch]); + }, [activePage, direction, filterQuery, limit, sortField]); - return [loading, actionsResponse]; + return response; }; diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx index 00ff1763601f2..e41b74c672e9b 100644 --- a/x-pack/plugins/osquery/public/agents/agents_table.tsx +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -27,7 +27,7 @@ interface AgentsTableProps { const AgentsTableComponent: React.FC = ({ selectedAgents, onChange }) => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('id'); + const [sortField, setSortField] = useState('upgraded_at'); const [sortDirection, setSortDirection] = useState(Direction.asc); const [selectedItems, setSelectedItems] = useState([]); const tableRef = useRef>(null); @@ -49,8 +49,11 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh const onSelectionChange: EuiTableSelectionType<{}>['onSelectionChange'] = useCallback( (newSelectedItems) => { setSelectedItems(newSelectedItems); - // @ts-expect-error - onChange(newSelectedItems.map((item) => item._id)); + + if (onChange) { + // @ts-expect-error update types + onChange(newSelectedItems.map((item) => item._id)); + } }, [onChange] ); @@ -61,7 +64,7 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh return {label}; }; - const [, { agents, totalCount }] = useAllAgents({ + const { data = {} } = useAllAgents({ activePage: pageIndex, limit: pageSize, direction: sortDirection, @@ -96,10 +99,12 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh () => ({ pageIndex, pageSize, - totalItemCount: totalCount, + // @ts-expect-error update types + totalItemCount: data.totalCount ?? 0, pageSizeOptions: [3, 5, 8], }), - [pageIndex, pageSize, totalCount] + // @ts-expect-error update types + [pageIndex, pageSize, data.totalCount] ); const sorting = useMemo( @@ -123,18 +128,26 @@ const AgentsTableComponent: React.FC = ({ selectedAgents, onCh ); useEffect(() => { - if (selectedAgents?.length && agents.length && selectedItems.length !== selectedAgents.length) { + if ( + selectedAgents?.length && + // @ts-expect-error update types + data.agents?.length && + selectedItems.length !== selectedAgents.length + ) { tableRef?.current?.setSelection( - // @ts-expect-error - selectedAgents.map((agentId) => find({ _id: agentId }, agents)) + // @ts-expect-error update types + selectedAgents.map((agentId) => find({ _id: agentId }, data.agents)) ); } - }, [selectedAgents, agents, selectedItems.length]); + // @ts-expect-error update types + }, [selectedAgents, data.agents, selectedItems.length]); return ( ref={tableRef} - items={agents} + // @ts-expect-error update types + // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop + items={data.agents ?? []} itemId="_id" columns={columns} pagination={pagination} diff --git a/x-pack/plugins/osquery/public/agents/helpers.ts b/x-pack/plugins/osquery/public/agents/helpers.ts index 802674ee0398c..fef17aadb62be 100644 --- a/x-pack/plugins/osquery/public/agents/helpers.ts +++ b/x-pack/plugins/osquery/public/agents/helpers.ts @@ -30,9 +30,10 @@ export const generateTablePaginationOptions = ( export const getInspectResponse = ( response: StrategyResponseType, - prevResponse: InspectResponse + prevResponse?: InspectResponse ): InspectResponse => ({ dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + // @ts-expect-error update types response: response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, }); diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts index 062be9e65eebc..663c6936fe55b 100644 --- a/x-pack/plugins/osquery/public/agents/use_all_agents.ts +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -6,13 +6,13 @@ */ import deepEqual from 'fast-deep-equal'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useQuery } from 'react-query'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { PageInfoPaginated, - DocValueFields, OsqueryQueries, AgentsRequestOptions, AgentsStrategyResponse, @@ -21,13 +21,8 @@ import { import { ESTermQuery } from '../../common/typed_json'; import { Agent } from '../../common/shared_imports'; -import * as i18n from './translations'; -import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; -import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; -const ID = 'agentsAllQuery'; - export interface AgentsArgs { agents: Agent[]; id: string; @@ -42,7 +37,6 @@ interface UseAllAgents { direction: Direction; limit: number; sortField: string; - docValueFields?: DocValueFields[]; filterQuery?: ESTermQuery | string; skip?: boolean; } @@ -52,93 +46,39 @@ export const useAllAgents = ({ direction, limit, sortField, - docValueFields, filterQuery, skip = false, -}: UseAllAgents): [boolean, AgentsArgs] => { - const { data, notifications } = useKibana().services; +}: UseAllAgents) => { + const { data } = useKibana().services; - const abortCtrl = useRef(new AbortController()); - const [loading, setLoading] = useState(false); const [agentsRequest, setHostRequest] = useState(null); - const [agentsResponse, setAgentsResponse] = useState({ - agents: [], - id: ID, - inspect: { - dsl: [], - response: [], - }, - isInspected: false, - pageInfo: { - activePage: 0, - fakeTotalCount: 0, - showMorePagesIndicator: false, - }, - totalCount: -1, - }); - - const agentsSearch = useCallback( - (request: AgentsRequestOptions | null) => { - if (request == null || skip) { - return; - } + const response = useQuery( + ['agents', { activePage, direction, limit, sortField }], + async () => { + if (!agentsRequest) return Promise.resolve(); - let didCancel = false; - const asyncSearch = async () => { - abortCtrl.current = new AbortController(); - setLoading(true); + const responseData = await data.search + .search(agentsRequest, { + strategy: 'osquerySearchStrategy', + }) + .toPromise(); - const searchSubscription$ = data.search - .search(request, { - strategy: 'osquerySearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (response) => { - if (isCompleteResponse(response)) { - if (!didCancel) { - setLoading(false); - setAgentsResponse((prevResponse) => ({ - ...prevResponse, - agents: response.edges, - inspect: getInspectResponse(response, prevResponse.inspect), - pageInfo: response.pageInfo, - totalCount: response.totalCount, - })); - } - searchSubscription$.unsubscribe(); - } else if (isErrorResponse(response)) { - if (!didCancel) { - setLoading(false); - } - // TODO: Make response error status clearer - notifications.toasts.addWarning(i18n.ERROR_ALL_AGENTS); - searchSubscription$.unsubscribe(); - } - }, - error: (msg) => { - if (!(msg instanceof AbortError)) { - notifications.toasts.addDanger({ title: i18n.FAIL_ALL_AGENTS, text: msg.message }); - } - }, - }); - }; - abortCtrl.current.abort(); - asyncSearch(); - return () => { - didCancel = true; - abortCtrl.current.abort(); + return { + ...responseData, + agents: responseData.edges, + inspect: getInspectResponse(responseData), }; }, - [data.search, notifications.toasts, skip] + { + enabled: !skip && !!agentsRequest, + } ); useEffect(() => { setHostRequest((prevRequest) => { const myRequest = { ...(prevRequest ?? {}), - docValueFields: docValueFields ?? [], factoryQueryType: OsqueryQueries.agents, filterQuery: createFilter(filterQuery), pagination: generateTablePaginationOptions(activePage, limit), @@ -152,11 +92,7 @@ export const useAllAgents = ({ } return prevRequest; }); - }, [activePage, direction, docValueFields, filterQuery, limit, sortField]); - - useEffect(() => { - agentsSearch(agentsRequest); - }, [agentsRequest, agentsSearch]); + }, [activePage, direction, filterQuery, limit, sortField]); - return [loading, agentsResponse]; + return response; }; diff --git a/x-pack/plugins/osquery/public/application.tsx b/x-pack/plugins/osquery/public/application.tsx index 611831e4c08dd..d72a788b16245 100644 --- a/x-pack/plugins/osquery/public/application.tsx +++ b/x-pack/plugins/osquery/public/application.tsx @@ -13,6 +13,8 @@ import ReactDOM from 'react-dom'; import { Router } from 'react-router-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { ThemeProvider } from 'styled-components'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { ReactQueryDevtools } from 'react-query/devtools'; import { useUiSetting$ } from '../../../../src/plugins/kibana_react/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; @@ -22,6 +24,8 @@ import { OsqueryApp } from './components/app'; import { DEFAULT_DARK_MODE, PLUGIN_NAME } from '../common'; import { KibanaContextProvider } from './common/lib/kibana'; +const queryClient = new QueryClient(); + const OsqueryAppContext = () => { const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); const theme = useMemo( @@ -51,6 +55,7 @@ export const renderApp = ( // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop services={{ appName: PLUGIN_NAME, + kibanaVersion, ...core, ...services, storage, @@ -59,7 +64,10 @@ export const renderApp = ( - + + + + diff --git a/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts index 88213d2b03452..63288507b29d4 100644 --- a/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts +++ b/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { useHistory } from 'react-router-dom'; import { KibanaContextProvider, KibanaReactContextValue, @@ -12,6 +13,7 @@ import { useUiSetting, useUiSetting$, withKibana, + reactRouterNavigate, } from '../../../../../../../src/plugins/kibana_react/public'; import { StartServices } from '../../../types'; @@ -22,8 +24,17 @@ export interface WithKibanaProps { const useTypedKibana = () => useKibana(); +const useRouterNavigate = ( + to: Parameters[1], + onClickCallback?: Parameters[2] +) => { + const history = useHistory(); + return reactRouterNavigate(history, to, onClickCallback); +}; + export { KibanaContextProvider, + useRouterNavigate, useTypedKibana as useKibana, useUiSetting, useUiSetting$, diff --git a/x-pack/plugins/osquery/public/components/app.tsx b/x-pack/plugins/osquery/public/components/app.tsx index 4c01949d5d678..a4a1f51fdd02b 100644 --- a/x-pack/plugins/osquery/public/components/app.tsx +++ b/x-pack/plugins/osquery/public/components/app.tsx @@ -5,54 +5,45 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Switch, Route } from 'react-router-dom'; +import { EuiFlexGroup, EuiFlexItem, EuiTabs, EuiTab } from '@elastic/eui'; +import { useLocation } from 'react-router-dom'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPageContentBody, - EuiPageHeader, - EuiTitle, - EuiSpacer, -} from '@elastic/eui'; - -import { PLUGIN_NAME } from '../../common'; -import { LiveQuery } from '../live_query'; +import { Container, Nav, Wrapper } from './layouts'; +import { OsqueryAppRoutes } from '../routes'; +import { useRouterNavigate } from '../common/lib/kibana'; export const OsqueryAppComponent = () => { - return ( - - - - -

- -

-
-
- - - + const location = useLocation(); + const section = useMemo(() => location.pathname.split('/')[1] ?? 'overview', [location.pathname]); - - - - - - - - - -
-
+ return ( + + + + + + ); }; diff --git a/x-pack/plugins/osquery/public/components/layouts/default.tsx b/x-pack/plugins/osquery/public/components/layouts/default.tsx new file mode 100644 index 0000000000000..3a9a5d85bc0bf --- /dev/null +++ b/x-pack/plugins/osquery/public/components/layouts/default.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import styled from 'styled-components'; + +export const Container = styled.div` + min-height: calc( + 100vh - ${(props) => parseFloat(props.theme.eui.euiHeaderHeightCompensation) * 2}px + ); + background: ${(props) => props.theme.eui.euiColorEmptyShade}; + display: flex; + flex-direction: column; +`; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + flex: 1; +`; + +export const Nav = styled.nav` + background: ${(props) => props.theme.eui.euiColorEmptyShade}; + border-bottom: ${(props) => props.theme.eui.euiBorderThin}; + padding: ${(props) => + `${props.theme.eui.euiSize} ${props.theme.eui.euiSizeL} ${props.theme.eui.euiSize} ${props.theme.eui.euiSizeL}`}; + .euiTabs { + padding-left: 3px; + margin-left: -3px; + } +`; diff --git a/x-pack/plugins/osquery/public/components/layouts/header.tsx b/x-pack/plugins/osquery/public/components/layouts/header.tsx new file mode 100644 index 0000000000000..5e8ed0923a0d9 --- /dev/null +++ b/x-pack/plugins/osquery/public/components/layouts/header.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// copied from x-pack/plugins/fleet/public/applications/fleet/components/header.tsx + +import React, { memo } from 'react'; +import styled from 'styled-components'; +import { EuiFlexGroup, EuiFlexItem, EuiTabs, EuiTab, EuiSpacer } from '@elastic/eui'; +import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; +import { EuiFlexItemProps } from '@elastic/eui/src/components/flex/flex_item'; + +const Container = styled.div` + border-bottom: ${(props) => props.theme.eui.euiBorderThin}; + background-color: ${(props) => props.theme.eui.euiPageBackgroundColor}; +`; + +const Wrapper = styled.div<{ maxWidth?: number }>` + max-width: ${(props) => props.maxWidth || 1200}px; + margin-left: auto; + margin-right: auto; + padding-top: ${(props) => props.theme.eui.paddingSizes.xl}; + padding-left: ${(props) => props.theme.eui.paddingSizes.m}; + padding-right: ${(props) => props.theme.eui.paddingSizes.m}; +`; + +const Tabs = styled(EuiTabs)` + top: 1px; + &:before { + height: 0px; + } +`; + +export interface HeaderProps { + maxWidth?: number; + leftColumn?: JSX.Element; + rightColumn?: JSX.Element; + rightColumnGrow?: EuiFlexItemProps['grow']; + tabs?: Array & { name?: JSX.Element | string }>; + tabsClassName?: string; + 'data-test-subj'?: string; +} + +const HeaderColumns: React.FC> = memo( + ({ leftColumn, rightColumn, rightColumnGrow }) => ( + + {leftColumn ? {leftColumn} : null} + {rightColumn ? {rightColumn} : null} + + ) +); + +HeaderColumns.displayName = 'HeaderColumns'; + +export const Header: React.FC = ({ + leftColumn, + rightColumn, + rightColumnGrow, + tabs, + maxWidth, + tabsClassName, + 'data-test-subj': dataTestSubj, +}) => ( + + + + + {tabs ? ( + + + + {tabs.map((props) => ( + + {props.name} + + ))} + + + ) : ( + + + + )} + + + +); diff --git a/x-pack/plugins/osquery/public/components/layouts/index.tsx b/x-pack/plugins/osquery/public/components/layouts/index.tsx new file mode 100644 index 0000000000000..e9d1987592eb9 --- /dev/null +++ b/x-pack/plugins/osquery/public/components/layouts/index.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// copied from x-pack/plugins/fleet/public/applications/fleet/layouts/index.tsx + +export { Container, Nav, Wrapper } from './default'; +export { WithHeaderLayout, WithHeaderLayoutProps } from './with_header'; +export { WithoutHeaderLayout } from './without_header'; diff --git a/x-pack/plugins/osquery/public/components/layouts/with_header.tsx b/x-pack/plugins/osquery/public/components/layouts/with_header.tsx new file mode 100644 index 0000000000000..a620194b37877 --- /dev/null +++ b/x-pack/plugins/osquery/public/components/layouts/with_header.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment } from 'react'; +import { EuiPageBody, EuiSpacer } from '@elastic/eui'; + +import { Header, HeaderProps } from './header'; +import { Page, ContentWrapper } from './without_header'; + +export interface WithHeaderLayoutProps extends HeaderProps { + restrictWidth?: number; + restrictHeaderWidth?: number; + 'data-test-subj'?: string; + children?: React.ReactNode; +} + +export const WithHeaderLayout: React.FC = ({ + restrictWidth, + restrictHeaderWidth, + children, + 'data-test-subj': dataTestSubj, + ...rest +}) => ( + +
+ + + + + {children} + + + + +); diff --git a/x-pack/plugins/osquery/public/components/layouts/without_header.tsx b/x-pack/plugins/osquery/public/components/layouts/without_header.tsx new file mode 100644 index 0000000000000..38b6198e088c5 --- /dev/null +++ b/x-pack/plugins/osquery/public/components/layouts/without_header.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; + +export const Page = styled(EuiPage)` + background: ${(props) => props.theme.eui.euiColorEmptyShade}; + width: 100%; + align-self: center; + margin-left: 0; + margin-right: 0; + flex: 1; +`; + +export const ContentWrapper = styled.div` + height: 100%; +`; + +interface Props { + restrictWidth?: number; + children?: React.ReactNode; +} + +export const WithoutHeaderLayout: React.FC = ({ restrictWidth, children }) => ( + + + + + + {children} + + + + +); diff --git a/x-pack/plugins/osquery/public/editor/index.tsx b/x-pack/plugins/osquery/public/editor/index.tsx index 122de746cac36..2a8a52d062395 100644 --- a/x-pack/plugins/osquery/public/editor/index.tsx +++ b/x-pack/plugins/osquery/public/editor/index.tsx @@ -42,7 +42,8 @@ const OsqueryEditorComponent: React.FC = ({ defaultValue, on name="osquery_editor" setOptions={EDITOR_SET_OPTIONS} editorProps={EDITOR_PROPS} - height="200px" + height="100px" + width="100%" /> ); }; diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/add_new_query_flyout.tsx b/x-pack/plugins/osquery/public/fleet_integration/components/add_new_query_flyout.tsx new file mode 100644 index 0000000000000..b02b3d288256e --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/add_new_query_flyout.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react/jsx-no-bind */ + +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + +import { produce } from 'immer'; +import { EuiFlyout, EuiTitle, EuiFlyoutBody, EuiFlyoutHeader, EuiPortal } from '@elastic/eui'; +import React from 'react'; + +import { AddPackQueryForm } from '../../packs/common/add_pack_query'; + +// @ts-expect-error update types +export const AddNewQueryFlyout = ({ data, handleChange, onClose }) => { + // @ts-expect-error update types + const handleSubmit = (payload) => { + // @ts-expect-error update types + const updatedPolicy = produce(data, (draft) => { + draft.inputs[0].streams.push({ + data_stream: { + type: 'logs', + dataset: 'osquery_elastic_managed.osquery', + }, + vars: { + query: { + type: 'text', + value: payload.query.attributes.query, + }, + interval: { + type: 'text', + value: `${payload.interval}`, + }, + id: { + type: 'text', + value: payload.query.id, + }, + }, + enabled: true, + }); + }); + + onClose(); + handleChange({ + isValid: true, + updatedPolicy, + }); + }; + + return ( + + + + +

Attach next query

+
+
+ + + +
+
+ ); +}; diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/custom_tab_tabs.tsx b/x-pack/plugins/osquery/public/fleet_integration/components/custom_tab_tabs.tsx new file mode 100644 index 0000000000000..9d2df5bbb0960 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/custom_tab_tabs.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import qs from 'query-string'; + +import { Queries } from '../../queries'; +import { Packs } from '../../packs'; +import { LiveQuery } from '../../live_query'; + +const CustomTabTabsComponent = () => { + const location = useLocation(); + + const selectedTab = useMemo(() => qs.parse(location.search)?.tab, [location.search]); + + if (selectedTab === 'packs') { + return ; + } + + if (selectedTab === 'saved_queries') { + return ; + } + + if (selectedTab === 'live_query') { + return ; + } + + return ; +}; + +export const CustomTabTabs = React.memo(CustomTabTabsComponent); diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/form.tsx b/x-pack/plugins/osquery/public/fleet_integration/components/form.tsx new file mode 100644 index 0000000000000..bb9bf066a9f92 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/form.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import produce from 'immer'; +import { find } from 'lodash/fp'; +import { EuiSpacer, EuiText, EuiHorizontalRule, EuiSuperSelect } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import deepEqual from 'fast-deep-equal'; +import { useQuery } from 'react-query'; + +import { + // UseField, + useForm, + useFormData, + UseArray, + getUseField, + Field, + ToggleField, + Form, +} from '../../shared_imports'; + +// import { OsqueryStreamField } from '../../scheduled_query/common/osquery_stream_field'; +import { useKibana } from '../../common/lib/kibana'; +import { ScheduledQueryQueriesTable } from './scheduled_queries_table'; +import { schema } from './schema'; + +const CommonUseField = getUseField({ component: Field }); + +const EDIT_SCHEDULED_QUERY_FORM_ID = 'editScheduledQueryForm'; + +interface EditScheduledQueryFormProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: Array>; + handleSubmit: () => Promise; +} + +const EditScheduledQueryFormComponent: React.FC = ({ + data, + handleSubmit, +}) => { + const { http } = useKibana().services; + + const { + data: { saved_objects: packs } = { + saved_objects: [], + }, + } = useQuery('packs', () => http.get('/internal/osquery/pack')); + + const { form } = useForm({ + id: EDIT_SCHEDULED_QUERY_FORM_ID, + onSubmit: handleSubmit, + schema, + defaultValue: data, + options: { + stripEmptyFields: false, + }, + // @ts-expect-error update types + deserializer: (payload) => { + const deserialized = produce(payload, (draft) => { + // @ts-expect-error update types + draft.streams = draft.inputs[0].streams.map(({ data_stream, enabled, vars }) => ({ + data: { + data_stream, + enabled, + vars, + }, + })); + }); + + return deserialized; + }, + // @ts-expect-error update types + serializer: (payload) => { + const serialized = produce(payload, (draft) => { + // @ts-expect-error update types + if (draft.inputs) { + // @ts-expect-error update types + draft.inputs[0].config = { + pack: { + type: 'id', + value: 'e33f5f30-705e-11eb-9e99-9f6b4d0d9506', + }, + }; + // @ts-expect-error update types + draft.inputs[0].type = 'osquery'; + // @ts-expect-error update types + draft.inputs[0].streams = draft.inputs[0].streams?.map((stream) => stream.data) ?? []; + } + }); + + return serialized; + }, + }); + + const { setFieldValue } = form; + + const handlePackChange = useCallback( + (value) => { + const newPack = find(['id', value], packs); + + setFieldValue( + 'streams', + // @ts-expect-error update types + newPack.queries.map((packQuery, index) => ({ + id: index, + isNew: true, + path: `streams[${index}]`, + data: { + data_stream: { + type: 'logs', + dataset: 'osquery_elastic_managed.osquery', + }, + id: 'osquery-osquery_elastic_managed.osquery-7065c2dc-f835-4d13-9486-6eec515f39bd', + vars: { + query: { + type: 'text', + value: packQuery.query, + }, + interval: { + type: 'text', + value: `${packQuery.interval}`, + }, + id: { + type: 'text', + value: packQuery.id, + }, + }, + enabled: true, + }, + })) + ); + }, + [packs, setFieldValue] + ); + + const [formData] = useFormData({ form, watch: ['streams'] }); + + const scheduledQueries = useMemo(() => { + if (formData.inputs) { + // @ts-expect-error update types + return formData.streams.reduce((acc, stream) => { + if (!stream.data) { + return acc; + } + + return [...acc, stream.data]; + }, []); + } + + return []; + }, [formData]); + + return ( +
+ ({ + value: pack.id, + inputDisplay: ( + <> + {pack.name} + +

{pack.description}

+
+ + ), + }))} + valueOfSelected={packs[0]?.id} + onChange={handlePackChange} + /> + + + + + + { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ items, form: streamsForm, addItem, removeItem }) => { + return ( + <> + {/* {items.map((item) => { + return ( + removeItem(item.id)} + // readDefaultValueOnForm={true} + defaultValue={ + item.isNew + ? // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + { + data_stream: { + type: 'logs', + dataset: 'osquery_elastic_managed.osquery', + }, + vars: { + query: { + type: 'text', + value: 'select * from uptime', + }, + interval: { + type: 'text', + value: '120', + }, + id: { + type: 'text', + value: uuid.v4(), + }, + }, + enabled: true, + } + : get(item.path, streamsForm.getFormData()) + } + /> + ); + })} */} + {/* + {'Add query'} + */} + + ); + } + } + + + ); +}; + +export const EditScheduledQueryForm = React.memo( + EditScheduledQueryFormComponent, + (prevProps, nextProps) => deepEqual(prevProps.data, nextProps.data) +); diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/input_stream_form.tsx b/x-pack/plugins/osquery/public/fleet_integration/components/input_stream_form.tsx new file mode 100644 index 0000000000000..34508c93e73bd --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/input_stream_form.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useForm, Form, getUseField, Field, FIELD_TYPES } from '../../shared_imports'; + +const CommonUseField = getUseField({ component: Field }); + +const FORM_ID = 'inputStreamForm'; + +const schema = { + data_stream: { + dataset: { + type: FIELD_TYPES.TEXT, + }, + type: { + type: FIELD_TYPES.TEXT, + }, + }, + enabled: { + type: FIELD_TYPES.TOGGLE, + label: 'Active', + }, + id: { + type: FIELD_TYPES.TEXT, + }, + vars: { + id: { + type: { + type: FIELD_TYPES.TEXT, + }, + value: { type: FIELD_TYPES.TEXT }, + }, + interval: { + type: { + type: FIELD_TYPES.TEXT, + }, + value: { type: FIELD_TYPES.TEXT }, + }, + query: { + type: { + type: FIELD_TYPES.TEXT, + }, + value: { type: FIELD_TYPES.TEXT }, + }, + }, +}; + +// @ts-expect-error update types +const InputStreamFormComponent = ({ data }) => { + const { form } = useForm({ + id: FORM_ID, + schema, + defaultValue: data, + }); + + return ( +
+ + + ); +}; + +export const InputStreamForm = React.memo(InputStreamFormComponent); diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/input_type.tsx b/x-pack/plugins/osquery/public/fleet_integration/components/input_type.tsx new file mode 100644 index 0000000000000..4a4e2a799ae42 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/input_type.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ + +/* eslint-disable react-perf/jsx-no-new-array-as-prop */ + +import React, { useCallback } from 'react'; +import produce from 'immer'; +import { EuiRadioGroup } from '@elastic/eui'; + +// @ts-expect-error update types +export const ScheduledQueryInputType = ({ data, handleChange }) => { + const radios = [ + { + id: 'pack', + label: 'Pack', + }, + { + id: 'saved_queries', + label: 'Saved queries', + }, + ]; + + const onChange = useCallback( + (optionId: string) => { + // @ts-expect-error update types + const updatedPolicy = produce(data, (draft) => { + if (!draft.inputs[0].config) { + draft.inputs[0].config = { + input_source: { + type: 'text', + value: optionId, + }, + }; + } else { + draft.inputs[0].config.input_source.value = optionId; + } + }); + + handleChange({ + isValid: true, + updatedPolicy, + }); + }, + [data, handleChange] + ); + + return ( + {'Choose input type'}, + }} + /> + ); +}; diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/navigation.tsx b/x-pack/plugins/osquery/public/fleet_integration/components/navigation.tsx new file mode 100644 index 0000000000000..5f5d5c0c8b546 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/navigation.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { snakeCase } from 'lodash/fp'; +import { EuiIcon, EuiSideNav } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import qs from 'query-string'; + +export const Navigation = () => { + const { push } = useHistory(); + const location = useLocation(); + + const selectedItemName = useMemo(() => qs.parse(location.search)?.tab, [location.search]); + + const handleTabClick = useCallback( + (tab) => { + push({ + search: qs.stringify({ tab }), + }); + }, + [push] + ); + + const createItem = useCallback( + (name, data = {}) => ({ + ...data, + id: snakeCase(name), + name, + isSelected: selectedItemName === name, + onClick: () => handleTabClick(snakeCase(name)), + }), + [handleTabClick, selectedItemName] + ); + + const sideNav = useMemo( + () => [ + createItem('Packs', { + forceOpen: true, + items: [ + createItem('List', { + icon: , + }), + createItem('New pack', { + icon: , + }), + ], + }), + createItem('Saved Queries', { + forceOpen: true, + items: [ + createItem('List', { + icon: , + }), + createItem('New query', { + icon: , + }), + ], + }), + // createItem('Scheduled Queries', { + // forceOpen: true, + // items: [ + // createItem('List', { + // icon: , + // }), + // createItem('Schedule new query', { + // icon: , + // }), + // ], + // }), + createItem('Live Query', { + forceOpen: true, + items: [ + createItem('Run', { + icon: , + }), + createItem('History', { + icon: , + }), + ], + }), + ], + [createItem] + ); + + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + return ; +}; diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/pack_selector.tsx b/x-pack/plugins/osquery/public/fleet_integration/components/pack_selector.tsx new file mode 100644 index 0000000000000..7d3f7debace72 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/pack_selector.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react/jsx-no-bind */ + +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + +import { find } from 'lodash/fp'; +import { produce } from 'immer'; +import { EuiText, EuiSuperSelect } from '@elastic/eui'; +import React from 'react'; +import { useQuery } from 'react-query'; + +import { useKibana } from '../../common/lib/kibana'; + +// @ts-expect-error update types +export const ScheduledQueryPackSelector = ({ data, handleChange }) => { + const { http } = useKibana().services; + const { + data: { saved_objects: packs } = { + saved_objects: [], + }, + } = useQuery('packs', () => http.get('/internal/osquery/pack')); + + // @ts-expect-error update types + const handlePackChange = (value) => { + const newPack = find(['id', value], packs); + + // @ts-expect-error update types + const updatedPolicy = produce(data, (draft) => { + draft.inputs[0].config.pack = { + type: 'text', + value: newPack.id, + }; + // @ts-expect-error update types + draft.inputs[0].streams = newPack.queries.map((packQuery) => ({ + data_stream: { + type: 'logs', + dataset: 'osquery_elastic_managed.osquery', + }, + vars: { + query: { + type: 'text', + value: packQuery.query, + }, + interval: { + type: 'text', + value: `${packQuery.interval}`, + }, + id: { + type: 'text', + value: packQuery.id, + }, + }, + enabled: true, + })); + }); + + handleChange({ + isValid: true, + updatedPolicy, + }); + }; + + return ( + ({ + value: pack.id, + inputDisplay: ( + <> + {pack.name} + +

{pack.description}

+
+ + ), + }))} + valueOfSelected={data.inputs[0].config} + onChange={handlePackChange} + /> + ); +}; diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/scheduled_queries_table.tsx b/x-pack/plugins/osquery/public/fleet_integration/components/scheduled_queries_table.tsx new file mode 100644 index 0000000000000..67a94ec518d60 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/scheduled_queries_table.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + +/* eslint-disable react/jsx-no-bind */ + +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ + +/* eslint-disable react/display-name */ + +/* eslint-disable react-perf/jsx-no-new-array-as-prop */ + +import React, { useState } from 'react'; +import { + EuiBasicTable, + EuiButtonIcon, + EuiHealth, + EuiDescriptionList, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; + +// @ts-expect-error update types +export const ScheduledQueryQueriesTable = ({ data }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState('firstName'); + const [sortDirection, setSortDirection] = useState('asc'); + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState({}); + + const onTableChange = ({ page = {}, sort = {} }) => { + // @ts-expect-error update types + const { index, size } = page; + // @ts-expect-error update types + const { field, direction } = sort; + + setPageIndex(index); + setPageSize(size); + setSortField(field); + setSortDirection(direction); + }; + + // @ts-expect-error update types + const toggleDetails = (item) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + // @ts-expect-error update types + if (itemIdToExpandedRowMapValues[item.id]) { + // @ts-expect-error update types + delete itemIdToExpandedRowMapValues[item.id]; + } else { + const { online } = item; + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + const listItems = [ + { + title: 'Online', + description: {label}, + }, + ]; + // @ts-expect-error update types + itemIdToExpandedRowMapValues[item.id] = ; + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; + + const columns = [ + { + field: 'vars.id.value', + name: 'ID', + }, + { + field: 'vars.interval.value', + name: 'Interval', + }, + { + field: 'enabled', + name: 'Active', + }, + { + name: 'Actions', + actions: [ + { + name: 'Clone', + description: 'Clone this person', + type: 'icon', + icon: 'copy', + onClick: () => '', + }, + ], + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + // @ts-expect-error update types + render: (item) => ( + toggleDetails(item)} + // @ts-expect-error update types + aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'} + // @ts-expect-error update types + iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} + /> + ), + }, + ]; + + const pagination = { + pageIndex, + pageSize, + totalItemCount: data.inputs[0].streams.length, + pageSizeOptions: [3, 5, 8], + }; + + const sorting = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + return ( + + ); +}; diff --git a/x-pack/plugins/osquery/public/fleet_integration/components/schema.ts b/x-pack/plugins/osquery/public/fleet_integration/components/schema.ts new file mode 100644 index 0000000000000..9a59c443b0a50 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/components/schema.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FIELD_TYPES } from '../../shared_imports'; + +export const schema = { + name: { + type: FIELD_TYPES.TEXT, + label: 'Name', + }, + description: { + type: FIELD_TYPES.TEXT, + label: 'Description', + }, + namespace: { + type: FIELD_TYPES.TEXT, + }, + enabled: { + type: FIELD_TYPES.TOGGLE, + }, + policy_id: { + type: FIELD_TYPES.TEXT, + }, + streams: { + type: FIELD_TYPES.MULTI_SELECT, + vars: { + query: { + type: { + type: FIELD_TYPES.TEXT, + }, + value: { + type: FIELD_TYPES.TEXT, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/osquery/public/fleet_integration/index.ts b/x-pack/plugins/osquery/public/fleet_integration/index.ts new file mode 100644 index 0000000000000..b36a2698b8337 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './lazy_osquery_managed_empty_create_policy_extension'; +export * from './lazy_osquery_managed_empty_edit_policy_extension'; +export * from './lazy_osquery_managed_policy_create_extension'; +export * from './lazy_osquery_managed_policy_edit_extension'; +export * from './lazy_osquery_managed_custom_extension'; diff --git a/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_custom_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_custom_extension.tsx new file mode 100644 index 0000000000000..1493182cdbaa6 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_custom_extension.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { PackageCustomExtensionComponent } from '../../../fleet/public'; + +export const LazyOsqueryManagedCustomExtension = lazy(async () => { + const { OsqueryManagedCustomExtension } = await import('./osquery_managed_custom_extension'); + return { + default: OsqueryManagedCustomExtension, + }; +}); diff --git a/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_empty_create_policy_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_empty_create_policy_extension.tsx new file mode 100644 index 0000000000000..21f59c505952b --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_empty_create_policy_extension.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { PackagePolicyCreateExtensionComponent } from '../../../fleet/public'; + +export const LazyOsqueryManagedEmptyCreatePolicyExtension = lazy( + async () => { + const { OsqueryManagedEmptyCreatePolicyExtension } = await import( + './osquery_managed_empty_create_policy_extension' + ); + return { + default: OsqueryManagedEmptyCreatePolicyExtension, + }; + } +); diff --git a/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_empty_edit_policy_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_empty_edit_policy_extension.tsx new file mode 100644 index 0000000000000..3f9ef42e97104 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_empty_edit_policy_extension.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { PackagePolicyEditExtensionComponent } from '../../../fleet/public'; + +export const LazyOsqueryManagedEmptyEditPolicyExtension = lazy( + async () => { + const { OsqueryManagedEmptyEditPolicyExtension } = await import( + './osquery_managed_empty_edit_policy_extension' + ); + return { + default: OsqueryManagedEmptyEditPolicyExtension, + }; + } +); diff --git a/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_policy_create_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_policy_create_extension.tsx new file mode 100644 index 0000000000000..8f0726fdbe209 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_policy_create_extension.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { PackagePolicyCreateExtensionComponent } from '../../../fleet/public'; + +export const LazyOsqueryManagedPolicyCreateExtension = lazy( + async () => { + const { OsqueryManagedPolicyCreateExtension } = await import( + './osquery_managed_policy_create_extension' + ); + return { + default: OsqueryManagedPolicyCreateExtension, + }; + } +); diff --git a/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_policy_edit_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_policy_edit_extension.tsx new file mode 100644 index 0000000000000..4289bcccdbc56 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/lazy_osquery_managed_policy_edit_extension.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { PackagePolicyEditExtensionComponent } from '../../../fleet/public'; + +export const LazyOsqueryManagedPolicyEditExtension = lazy( + async () => { + const { OsqueryManagedPolicyCreateExtension } = await import( + './osquery_managed_policy_create_extension' + ); + return { + default: OsqueryManagedPolicyCreateExtension, + }; + } +); diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_extension.tsx new file mode 100644 index 0000000000000..1295699a270a5 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_extension.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +import { PackageCustomExtensionComponentProps } from '../../../fleet/public'; +import { CustomTabTabs } from './components/custom_tab_tabs'; +import { Navigation } from './components/navigation'; + +const queryClient = new QueryClient(); + +/** + * Exports Osquery-specific package policy instructions + * for use in the Fleet app custom tab + */ +export const OsqueryManagedCustomExtension = React.memo( + () => ( + + + + + + + + + + + ) +); +OsqueryManagedCustomExtension.displayName = 'OsqueryManagedCustomExtension'; diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_empty_create_policy_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_empty_create_policy_extension.tsx new file mode 100644 index 0000000000000..828edfc0a29b4 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_empty_create_policy_extension.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { produce } from 'immer'; +import deepEqual from 'fast-deep-equal'; + +import { PackagePolicyCreateExtensionComponentProps } from '../../../fleet/public'; + +/** + * Exports Osquery-specific package policy instructions + * for use in the Fleet app create / edit package policy + */ +const OsqueryManagedEmptyCreatePolicyExtensionComponent: React.FC = ({ + onChange, + newPolicy, +}) => { + useEffect(() => { + const updatedPolicy = produce(newPolicy, (draft) => { + draft.inputs.forEach((input) => (input.streams = [])); + }); + + onChange({ + isValid: true, + updatedPolicy, + }); + }); + + return <>; +}; + +OsqueryManagedEmptyCreatePolicyExtensionComponent.displayName = + 'OsqueryManagedEmptyCreatePolicyExtension'; + +export const OsqueryManagedEmptyCreatePolicyExtension = React.memo( + OsqueryManagedEmptyCreatePolicyExtensionComponent, + // we don't want to update the component if onChange has changed + (prevProps, nextProps) => deepEqual(prevProps.newPolicy, nextProps.newPolicy) +); diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_empty_edit_policy_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_empty_edit_policy_extension.tsx new file mode 100644 index 0000000000000..c8304ea5f0d1e --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_empty_edit_policy_extension.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { PackagePolicyEditExtensionComponentProps } from '../../../fleet/public'; + +/** + * Exports Osquery-specific package policy instructions + * for use in the Fleet app edit package policy + */ +const OsqueryManagedEmptyEditPolicyExtensionComponent = () => <>; + +OsqueryManagedEmptyEditPolicyExtensionComponent.displayName = + 'OsqueryManagedEmptyEditPolicyExtension'; + +export const OsqueryManagedEmptyEditPolicyExtension = React.memo( + OsqueryManagedEmptyEditPolicyExtensionComponent +); diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_extension.tsx new file mode 100644 index 0000000000000..09653b09365ce --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_extension.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +import { PackagePolicyCreateExtensionComponentProps } from '../../../fleet/public'; +import { ScheduledQueryInputType } from './components/input_type'; +import { ScheduledQueryPackSelector } from './components/pack_selector'; +import { ScheduledQueryQueriesTable } from './components/scheduled_queries_table'; +import { AddNewQueryFlyout } from './components/add_new_query_flyout'; + +const queryClient = new QueryClient(); + +/** + * Exports Osquery-specific package policy instructions + * for use in the Fleet app create / edit package policy + */ +export const OsqueryManagedPolicyCreateExtension = React.memo( + ({ onChange, newPolicy }) => { + const [showAddQueryFlyout, setShowAddQueryFlyout] = useState(false); + + const handleShowFlyout = useCallback(() => setShowAddQueryFlyout(true), []); + const handleHideFlyout = useCallback(() => setShowAddQueryFlyout(false), []); + + return ( + + + {newPolicy.inputs[0].config?.input_source?.value === 'pack' && ( + + )} + {newPolicy.inputs[0].streams.length && ( + // @ts-expect-error update types + + )} + {newPolicy.inputs[0].config?.input_source?.value !== 'pack' && ( + + {'Attach next query'} + + )} + {showAddQueryFlyout && ( + + )} + + ); + } +); +OsqueryManagedPolicyCreateExtension.displayName = 'OsqueryManagedPolicyCreateExtension'; diff --git a/x-pack/plugins/osquery/public/live_query/agent_results/index.tsx b/x-pack/plugins/osquery/public/live_query/agent_results/index.tsx new file mode 100644 index 0000000000000..63dbca98d648f --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/agent_results/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { useActionDetails } from '../../actions/use_action_details'; +import { ResultsTable } from '../../results/results_table'; + +const QueryAgentResultsComponent = () => { + const { actionId, agentId } = useParams<{ actionId: string; agentId: string }>(); + const { data } = useActionDetails({ actionId }); + + return ( + <> + + { + // @ts-expect-error update types + data?.actionDetails._source?.data?.query + } + + + + + ); +}; + +export const QueryAgentResults = React.memo(QueryAgentResultsComponent); diff --git a/x-pack/plugins/osquery/public/live_query/edit/index.tsx b/x-pack/plugins/osquery/public/live_query/edit/index.tsx deleted file mode 100644 index eaed2eca2c698..0000000000000 --- a/x-pack/plugins/osquery/public/live_query/edit/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { isEmpty } from 'lodash/fp'; -import { EuiSpacer } from '@elastic/eui'; -import React, { useCallback } from 'react'; -import { useParams } from 'react-router-dom'; - -import { useActionDetails } from '../../actions/use_action_details'; -import { ResultTabs } from './tabs'; -import { LiveQueryForm } from '../form'; - -const EditLiveQueryPageComponent = () => { - const { actionId } = useParams<{ actionId: string }>(); - const [loading, { actionDetails }] = useActionDetails({ actionId }); - - const handleSubmit = useCallback(() => Promise.resolve(), []); - - if (loading) { - return <>{'Loading...'}; - } - - return ( - <> - {!isEmpty(actionDetails) && ( - - )} - - - - ); -}; - -export const EditLiveQueryPage = React.memo(EditLiveQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/index.tsx b/x-pack/plugins/osquery/public/live_query/form/index.tsx index ad7e4168eda6b..4a69e2fc0e76d 100644 --- a/x-pack/plugins/osquery/public/live_query/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_query/form/index.tsx @@ -6,40 +6,34 @@ */ import { EuiButton, EuiSpacer } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import React from 'react'; import { UseField, Form, useForm } from '../../shared_imports'; import { AgentsTableField } from './agents_table_field'; -import { CodeEditorField } from './code_editor_field'; +import { LiveQueryQueryField } from './live_query_query_field'; const FORM_ID = 'liveQueryForm'; interface LiveQueryFormProps { - actionDetails?: Record; + defaultValue?: unknown; onSubmit: (payload: Record) => Promise; } -const LiveQueryFormComponent: React.FC = ({ actionDetails, onSubmit }) => { - const handleSubmit = useCallback( - (payload) => { - onSubmit(payload); - return Promise.resolve(); - }, - [onSubmit] - ); - +const LiveQueryFormComponent: React.FC = ({ defaultValue, onSubmit }) => { const { form } = useForm({ id: FORM_ID, // schema: formSchema, - onSubmit: handleSubmit, + onSubmit, options: { stripEmptyFields: false, }, - defaultValue: actionDetails, - deserializer: ({ fields, _source }) => ({ - agents: fields?.agents, - command: _source?.data?.commands[0], - }), + defaultValue: { + // @ts-expect-error update types + query: defaultValue ?? { + id: null, + query: '', + }, + }, }); const { submit } = form; @@ -48,8 +42,9 @@ const LiveQueryFormComponent: React.FC = ({ actionDetails, o
- - Send query + + + {'Send query'} ); }; diff --git a/x-pack/plugins/osquery/public/live_query/form/live_query_query_field.tsx b/x-pack/plugins/osquery/public/live_query/form/live_query_query_field.tsx new file mode 100644 index 0000000000000..bc3da3ea37209 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/live_query_query_field.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// import { find } from 'lodash/fp'; +// import { EuiCodeBlock, EuiSuperSelect, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; +// import { useQuery } from 'react-query'; + +import { FieldHook } from '../../shared_imports'; +// import { useKibana } from '../../common/lib/kibana'; +import { OsqueryEditor } from '../../editor'; + +interface LiveQueryQueryFieldProps { + field: FieldHook<{ + id: string | null; + query: string; + }>; +} + +const LiveQueryQueryFieldComponent: React.FC = ({ field }) => { + // const { http } = useKibana().services; + // const { data } = useQuery('savedQueryList', () => + // http.get('/internal/osquery/saved_query', { + // query: { + // pageIndex: 0, + // pageSize: 100, + // sortField: 'updated_at', + // sortDirection: 'desc', + // }, + // }) + // ); + + // const queryOptions = + // // @ts-expect-error update types + // data?.saved_objects.map((savedQuery) => ({ + // value: savedQuery, + // inputDisplay: savedQuery.attributes.name, + // dropdownDisplay: ( + // <> + // {savedQuery.attributes.name} + // + //

{savedQuery.attributes.description}

+ //
+ // + // {savedQuery.attributes.query} + // + // + // ), + // })) ?? []; + + const { value, setValue } = field; + + // const handleSavedQueryChange = useCallback( + // (newValue) => { + // setValue({ + // id: newValue.id, + // query: newValue.attributes.query, + // }); + // }, + // [setValue] + // ); + + const handleEditorChange = useCallback( + (newValue) => { + setValue({ + id: null, + query: newValue, + }); + }, + [setValue] + ); + + return ( + <> + {/* + */} + + + ); +}; + +export const LiveQueryQueryField = React.memo(LiveQueryQueryFieldComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/schema.ts b/x-pack/plugins/osquery/public/live_query/form/schema.ts index 64c2dd29bca80..3d0195a112fec 100644 --- a/x-pack/plugins/osquery/public/live_query/form/schema.ts +++ b/x-pack/plugins/osquery/public/live_query/form/schema.ts @@ -11,7 +11,7 @@ export const formSchema: FormSchema = { agents: { type: FIELD_TYPES.MULTI_SELECT, }, - command: { + query: { type: FIELD_TYPES.TEXTAREA, validations: [], }, diff --git a/x-pack/plugins/osquery/public/live_query/index.tsx b/x-pack/plugins/osquery/public/live_query/index.tsx index 6f593604feab5..324f9896cbd96 100644 --- a/x-pack/plugins/osquery/public/live_query/index.tsx +++ b/x-pack/plugins/osquery/public/live_query/index.tsx @@ -5,28 +5,42 @@ * 2.0. */ +import { EuiSpacer } from '@elastic/eui'; import React from 'react'; -import { Switch, Route, useRouteMatch } from 'react-router-dom'; +import { useMutation } from 'react-query'; +import { useLocation } from 'react-router-dom'; -import { QueriesPage } from './queries'; -import { NewLiveQueryPage } from './new'; -import { EditLiveQueryPage } from './edit'; +import { useKibana } from '../common/lib/kibana'; +import { LiveQueryForm } from './form'; +import { ResultTabs } from '../queries/edit/tabs'; const LiveQueryComponent = () => { - const match = useRouteMatch(); + const location = useLocation(); + const { http } = useKibana().services; + + const createActionMutation = useMutation((payload: Record) => + http.post('/internal/osquery/action', { + body: JSON.stringify(payload), + }) + ); return ( - - - - - - - - - - - + <> + { + + } + + {createActionMutation.data && ( + <> + + + + )} + ); }; diff --git a/x-pack/plugins/osquery/public/live_query/new/index.tsx b/x-pack/plugins/osquery/public/live_query/new/index.tsx deleted file mode 100644 index 7fece1b4cf3a7..0000000000000 --- a/x-pack/plugins/osquery/public/live_query/new/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback } from 'react'; -import { useHistory } from 'react-router-dom'; - -import { useKibana } from '../../common/lib/kibana'; -import { LiveQueryForm } from '../form'; - -const NewLiveQueryPageComponent = () => { - const { http } = useKibana().services; - const history = useHistory(); - - const handleSubmit = useCallback( - async (props) => { - const response = await http.post('/api/osquery/queries', { body: JSON.stringify(props) }); - const requestParamsActionId = JSON.parse(response.meta.request.params.body).action_id; - history.push(`/live_query/queries/${requestParamsActionId}`); - }, - [history, http] - ); - - return ; -}; - -export const NewLiveQueryPage = React.memo(NewLiveQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/live_query/queries/index.tsx b/x-pack/plugins/osquery/public/live_query/queries/index.tsx deleted file mode 100644 index 6b4cea5723eb9..0000000000000 --- a/x-pack/plugins/osquery/public/live_query/queries/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiSpacer, EuiTitle } from '@elastic/eui'; -import React from 'react'; - -import { ActionsTable } from '../../actions/actions_table'; - -const QueriesPageComponent = () => { - return ( - <> - -

{'Queries'}

-
- - - - ); -}; - -export const QueriesPage = React.memo(QueriesPageComponent); diff --git a/x-pack/plugins/osquery/public/osquery_action_type/example_params_fields.tsx b/x-pack/plugins/osquery/public/osquery_action_type/example_params_fields.tsx new file mode 100644 index 0000000000000..898806ea542a8 --- /dev/null +++ b/x-pack/plugins/osquery/public/osquery_action_type/example_params_fields.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react-perf/jsx-no-new-function-as-prop, react/jsx-no-bind */ + +import React, { Fragment } from 'react'; +import { EuiTextArea } from '@elastic/eui'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ActionParamsProps } from '../../../triggers_actions_ui/public/types'; + +interface ExampleActionParams { + message: string; +} + +const ExampleParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + errors, +}) => { + // console.error('actionParams', actionParams, index, errors); + const { message } = actionParams; + return ( + + 0 && message !== undefined} + name="message" + value={message || ''} + onChange={(e) => { + editAction('message', e.target.value, index); + }} + onBlur={() => { + if (!message) { + editAction('message', '', index); + } + }} + /> + + ); +}; + +// Export as default in order to support lazy loading +// eslint-disable-next-line import/no-default-export +export { ExampleParamsFields as default }; diff --git a/x-pack/plugins/osquery/public/osquery_action_type/index.tsx b/x-pack/plugins/osquery/public/osquery_action_type/index.tsx new file mode 100644 index 0000000000000..2e162b34ab96d --- /dev/null +++ b/x-pack/plugins/osquery/public/osquery_action_type/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ActionTypeModel, ValidationResult } from '../../../triggers_actions_ui/public/types'; + +interface ExampleActionParams { + message: string; +} + +export function getActionType(): ActionTypeModel { + return { + id: '.osquery', + iconClass: 'logoOsquery', + selectMessage: i18n.translate( + 'xpack.osquery.components.builtinActionTypes.exampleAction.selectMessageText', + { + defaultMessage: 'Example Action is used to show how to create new action type UI.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.osquery.components.builtinActionTypes.exampleAction.actionTypeTitle', + { + defaultMessage: 'Example Action', + } + ), + // @ts-expect-error update types + validateConnector: (action): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + someConnectorField: new Array(), + }; + validationResult.errors = errors; + if (!action.config.someConnectorField) { + errors.someConnectorField.push( + i18n.translate( + 'xpack.osquery.components.builtinActionTypes.error.requiredSomeConnectorFieldeText', + { + defaultMessage: 'SomeConnectorField is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: ExampleActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.osquery.components.builtinActionTypes.error.requiredExampleMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: lazy(() => import('./example_params_fields')), + }; +} diff --git a/x-pack/plugins/osquery/public/packs/common/add_new_pack_query_flyout.tsx b/x-pack/plugins/osquery/public/packs/common/add_new_pack_query_flyout.tsx new file mode 100644 index 0000000000000..2680b5198fadb --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/common/add_new_pack_query_flyout.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; +import React from 'react'; + +import { SavedQueryForm } from '../../queries/form'; + +// @ts-expect-error update types +const AddNewPackQueryFlyoutComponent = ({ handleClose, handleSubmit }) => ( + + + +

{'Add new Saved Query'}

+
+
+ + + +
+); + +export const AddNewPackQueryFlyout = React.memo(AddNewPackQueryFlyoutComponent); diff --git a/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx b/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx new file mode 100644 index 0000000000000..2d58e2dfe9522 --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/common/add_pack_query.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ + +import { EuiButton, EuiCodeBlock, EuiSpacer, EuiText, EuiLink, EuiPortal } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from 'react-query'; + +import { getUseField, useForm, Field, Form, FIELD_TYPES } from '../../shared_imports'; +import { useKibana } from '../../common/lib/kibana'; +import { AddNewPackQueryFlyout } from './add_new_pack_query_flyout'; + +const CommonUseField = getUseField({ component: Field }); + +// @ts-expect-error update types +const AddPackQueryFormComponent = ({ handleSubmit }) => { + const queryClient = useQueryClient(); + const [showAddQueryFlyout, setShowAddQueryFlyout] = useState(false); + + const { http } = useKibana().services; + const { data } = useQuery('savedQueryList', () => + http.get('/internal/osquery/saved_query', { + query: { + pageIndex: 0, + pageSize: 100, + sortField: 'updated_at', + sortDirection: 'desc', + }, + }) + ); + + const { form } = useForm({ + id: 'addPackQueryForm', + onSubmit: handleSubmit, + defaultValue: { + query: {}, + }, + schema: { + query: { + type: FIELD_TYPES.SUPER_SELECT, + label: 'Pick from Saved Queries', + }, + interval: { + type: FIELD_TYPES.NUMBER, + label: 'Interval in seconds', + }, + }, + }); + const { submit } = form; + + const createSavedQueryMutation = useMutation( + (payload) => http.post(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }), + { + onSuccess: () => { + queryClient.invalidateQueries('savedQueryList'); + setShowAddQueryFlyout(false); + }, + } + ); + + const queryOptions = useMemo( + () => + // @ts-expect-error update types + data?.saved_objects.map((savedQuery) => ({ + value: { + id: savedQuery.id, + attributes: savedQuery.attributes, + type: savedQuery.type, + }, + inputDisplay: savedQuery.attributes.name, + dropdownDisplay: ( + <> + {savedQuery.attributes.name} + +

{savedQuery.attributes.description}

+
+ + {savedQuery.attributes.query} + + + ), + })) ?? [], + [data?.saved_objects] + ); + + const handleShowFlyout = useCallback(() => setShowAddQueryFlyout(true), []); + const handleCloseFlyout = useCallback(() => setShowAddQueryFlyout(false), []); + + return ( + <> +
+ + {'Add new saved query'} + + } + euiFieldProps={{ + options: queryOptions, + }} + /> + + + + + {'Add query'} + + + {showAddQueryFlyout && ( + + + + )} + + ); +}; + +export const AddPackQueryForm = React.memo(AddPackQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/packs/common/pack_form.tsx b/x-pack/plugins/osquery/public/packs/common/pack_form.tsx new file mode 100644 index 0000000000000..86d4d8dff6ba6 --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/common/pack_form.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import React from 'react'; + +import { getUseField, useForm, Field, Form, FIELD_TYPES } from '../../shared_imports'; +import { PackQueriesField } from './pack_queries_field'; + +const CommonUseField = getUseField({ component: Field }); + +// @ts-expect-error update types +const PackFormComponent = ({ data, handleSubmit }) => { + const { form } = useForm({ + id: 'addPackForm', + onSubmit: (payload) => { + return handleSubmit(payload); + }, + defaultValue: data ?? { + name: '', + description: '', + queries: [], + }, + schema: { + name: { + type: FIELD_TYPES.TEXT, + label: 'Pack name', + }, + description: { + type: FIELD_TYPES.TEXTAREA, + label: 'Description', + }, + queries: { + type: FIELD_TYPES.MULTI_SELECT, + label: 'Queries', + }, + }, + }); + const { submit } = form; + + return ( +
+ + + + + + + + {'Save pack'} + + + ); +}; + +export const PackForm = React.memo(PackFormComponent); diff --git a/x-pack/plugins/osquery/public/packs/common/pack_queries_field.tsx b/x-pack/plugins/osquery/public/packs/common/pack_queries_field.tsx new file mode 100644 index 0000000000000..fa29bf54e21ff --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/common/pack_queries_field.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { reject } from 'lodash/fp'; +import { produce } from 'immer'; +import { EuiSpacer } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useQueries } from 'react-query'; + +import { useKibana } from '../../common/lib/kibana'; +import { PackQueriesTable } from '../common/pack_queries_table'; +import { AddPackQueryForm } from '../common/add_pack_query'; + +// @ts-expect-error update types +const PackQueriesFieldComponent = ({ field }) => { + const { value, setValue } = field; + const { http } = useKibana().services; + + const packQueriesData = useQueries( + // @ts-expect-error update types + value.map((query) => ({ + queryKey: ['savedQuery', { id: query.id }], + queryFn: () => http.get(`/internal/osquery/saved_query/${query.id}`), + })) ?? [] + ); + + const packQueries = useMemo( + () => + // @ts-expect-error update types + packQueriesData.reduce((acc, packQueryData) => { + if (packQueryData.data) { + return [...acc, packQueryData.data]; + } + return acc; + }, []) ?? [], + [packQueriesData] + ); + + const handleAddQuery = useCallback( + (newQuery) => + setValue( + produce((draft) => { + draft.push({ + interval: newQuery.interval, + query: newQuery.query.attributes.query, + id: newQuery.query.id, + name: newQuery.query.attributes.name, + }); + }) + ), + [setValue] + ); + + const handleRemoveQuery = useCallback( + (query) => setValue(produce((draft) => reject(['id', query.id], draft))), + [setValue] + ); + + return ( + <> + + + + + ); +}; + +export const PackQueriesField = React.memo(PackQueriesFieldComponent); diff --git a/x-pack/plugins/osquery/public/packs/common/pack_queries_table.tsx b/x-pack/plugins/osquery/public/packs/common/pack_queries_table.tsx new file mode 100644 index 0000000000000..ce30238effa2b --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/common/pack_queries_table.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/no-shadow, react-perf/jsx-no-new-object-as-prop, react/jsx-no-bind, react/display-name, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-array-as-prop */ + +import { find } from 'lodash/fp'; +import React, { useState } from 'react'; +import { + EuiBasicTable, + EuiButtonIcon, + EuiHealth, + EuiDescriptionList, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; + +// @ts-expect-error update types +const PackQueriesTableComponent = ({ items, config, handleRemoveQuery }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [sortField, setSortField] = useState('firstName'); + const [sortDirection, setSortDirection] = useState('asc'); + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState({}); + const totalItemCount = 100; + + const onTableChange = ({ page = {}, sort = {} }) => { + // @ts-expect-error update types + const { index: pageIndex, size: pageSize } = page; + + // @ts-expect-error update types + const { field: sortField, direction: sortDirection } = sort; + + setPageIndex(pageIndex); + setPageSize(pageSize); + setSortField(sortField); + setSortDirection(sortDirection); + }; + + // @ts-expect-error update types + const toggleDetails = (item) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + // @ts-expect-error update types + if (itemIdToExpandedRowMapValues[item.id]) { + // @ts-expect-error update types + delete itemIdToExpandedRowMapValues[item.id]; + } else { + const { online } = item; + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + const listItems = [ + { + title: 'Nationality', + description: `aa`, + }, + { + title: 'Online', + description: {label}, + }, + ]; + // @ts-expect-error update types + itemIdToExpandedRowMapValues[item.id] = ; + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; + + const columns = [ + { + field: 'name', + name: 'Query Name', + }, + { + name: 'Interval', + // @ts-expect-error update types + render: (query) => find(['name', query.name], config).interval, + }, + { + name: 'Actions', + actions: [ + { + name: 'Remove', + description: 'Remove this query', + type: 'icon', + icon: 'trash', + onClick: handleRemoveQuery, + }, + ], + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + // @ts-expect-error update types + render: (item) => ( + toggleDetails(item)} + // @ts-expect-error update types + aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'} + // @ts-expect-error update types + iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} + /> + ), + }, + ]; + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: [3, 5, 8], + }; + + const sorting = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + return ( + + ); +}; + +export const PackQueriesTable = React.memo(PackQueriesTableComponent); diff --git a/x-pack/plugins/osquery/public/packs/edit/index.tsx b/x-pack/plugins/osquery/public/packs/edit/index.tsx new file mode 100644 index 0000000000000..478152bb8b4a3 --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/edit/index.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ + +import React from 'react'; +import { useMutation, useQuery } from 'react-query'; + +import { PackForm } from '../common/pack_form'; +import { useKibana } from '../../common/lib/kibana'; + +interface EditPackPageProps { + onSuccess: () => void; + packId: string; +} + +const EditPackPageComponent: React.FC = ({ onSuccess, packId }) => { + const { http } = useKibana().services; + + const { + data = { + queries: [], + }, + } = useQuery(['pack', { id: packId }], ({ queryKey }) => { + return http.get(`/internal/osquery/pack/${queryKey[1].id}`); + }); + + const updatePackMutation = useMutation( + (payload) => + http.put(`/internal/osquery/pack/${packId}`, { + body: JSON.stringify({ + ...data, + // @ts-expect-error update types + ...payload, + }), + }), + { + onSuccess, + } + ); + + if (!data.id) { + return <>{'Loading...'}; + } + + return ; +}; + +export const EditPackPage = React.memo(EditPackPageComponent); diff --git a/x-pack/plugins/osquery/public/packs/index.tsx b/x-pack/plugins/osquery/public/packs/index.tsx new file mode 100644 index 0000000000000..afd44f1b88955 --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/index.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; + +import { PacksPage } from './list'; +import { NewPackPage } from './new'; +import { EditPackPage } from './edit'; + +const PacksComponent = () => { + const [showNewPackForm, setShowNewPackForm] = useState(false); + const [editPackId, setEditPackId] = useState(null); + + const goBack = useCallback(() => { + setShowNewPackForm(false); + setEditPackId(null); + }, []); + + const handleNewQueryClick = useCallback(() => setShowNewPackForm(true), []); + + if (showNewPackForm) { + return ; + } + + if (editPackId?.length) { + return ; + } + + return ; +}; + +export const Packs = React.memo(PacksComponent); diff --git a/x-pack/plugins/osquery/public/packs/list/index.tsx b/x-pack/plugins/osquery/public/packs/list/index.tsx new file mode 100644 index 0000000000000..d7a80cb295496 --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/list/index.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { map } from 'lodash/fp'; +import { + EuiBasicTable, + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useQuery, useQueryClient, useMutation } from 'react-query'; + +import { PackTableQueriesTable } from './pack_table_queries_table'; +import { useKibana } from '../../common/lib/kibana'; + +interface PacksPageProps { + onEditClick: (packId: string) => void; + onNewClick: () => void; +} + +const PacksPageComponent: React.FC = ({ onNewClick, onEditClick }) => { + const queryClient = useQueryClient(); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState('updated_at'); + const [sortDirection, setSortDirection] = useState('desc'); + const [selectedItems, setSelectedItems] = useState([]); + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>({}); + const { http } = useKibana().services; + + const deletePacksMutation = useMutation( + (payload) => http.delete(`/internal/osquery/pack`, { body: JSON.stringify(payload) }), + { + onSuccess: () => queryClient.invalidateQueries('packList'), + } + ); + + const { data = {} } = useQuery( + ['packList', { pageIndex, pageSize, sortField, sortDirection }], + () => + http.get('/internal/osquery/pack', { + query: { + pageIndex, + pageSize, + sortField, + sortDirection, + }, + }), + { + keepPreviousData: true, + // Refetch the data every 10 seconds + refetchInterval: 5000, + } + ); + const { total = 0, saved_objects: packs } = data; + + const toggleDetails = useCallback( + (item) => () => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[item.id]) { + delete itemIdToExpandedRowMapValues[item.id]; + } else { + itemIdToExpandedRowMapValues[item.id] = ( + <> + + + + ); + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }, + [itemIdToExpandedRowMap] + ); + + const renderExtendedItemToggle = useCallback( + (item) => ( + + ), + [itemIdToExpandedRowMap, toggleDetails] + ); + + const handleEditClick = useCallback((item) => onEditClick(item.id), [onEditClick]); + + const columns = useMemo( + () => [ + { + field: 'name', + name: 'Pack name', + sortable: true, + truncateText: true, + }, + { + field: 'description', + name: 'Description', + sortable: true, + truncateText: true, + }, + { + field: 'queries', + name: 'Queries', + sortable: false, + // @ts-expect-error update types + render: (queries) => queries.length, + }, + { + field: 'updated_at', + name: 'Last updated at', + sortable: true, + truncateText: true, + }, + { + name: 'Actions', + actions: [ + { + name: 'Edit', + description: 'Edit or run this query', + type: 'icon', + icon: 'documentEdit', + onClick: handleEditClick, + }, + ], + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: renderExtendedItemToggle, + }, + ], + [handleEditClick, renderExtendedItemToggle] + ); + + const onTableChange = useCallback(({ page = {}, sort = {} }) => { + setPageIndex(page.index); + setPageSize(page.size); + setSortField(sort.field); + setSortDirection(sort.direction); + }, []); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: total, + pageSizeOptions: [3, 5, 8], + }), + [total, pageIndex, pageSize] + ); + + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const selection = useMemo( + () => ({ + selectable: () => true, + onSelectionChange: setSelectedItems, + initialSelected: [], + }), + [] + ); + + const handleDeleteClick = useCallback(() => { + const selectedItemsIds = map('id', selectedItems); + // @ts-expect-error update types + deletePacksMutation.mutate({ packIds: selectedItemsIds }); + }, [deletePacksMutation, selectedItems]); + + return ( +
+ + + {!selectedItems.length ? ( + + {'New pack'} + + ) : ( + + {`Delete ${selectedItems.length} packs`} + + )} + + + + + + {packs && ( + + )} +
+ ); +}; + +export const PacksPage = React.memo(PacksPageComponent); diff --git a/x-pack/plugins/osquery/public/packs/list/pack_table_queries_table.tsx b/x-pack/plugins/osquery/public/packs/list/pack_table_queries_table.tsx new file mode 100644 index 0000000000000..5470dc6ba569c --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/list/pack_table_queries_table.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBasicTable, EuiCodeBlock } from '@elastic/eui'; +import React from 'react'; + +const columns = [ + { + field: 'id', + name: 'ID', + }, + { + field: 'name', + name: 'Query name', + }, + { + field: 'interval', + name: 'Query interval', + }, + { + field: 'query', + name: 'Query', + // eslint-disable-next-line react/display-name + render: (query: string) => ( + + {query} + + ), + }, +]; + +// @ts-expect-error update types +const PackTableQueriesTableComponent = ({ items }) => { + return ; +}; + +export const PackTableQueriesTable = React.memo(PackTableQueriesTableComponent); diff --git a/x-pack/plugins/osquery/public/packs/new/index.tsx b/x-pack/plugins/osquery/public/packs/new/index.tsx new file mode 100644 index 0000000000000..2b60e8942bbf9 --- /dev/null +++ b/x-pack/plugins/osquery/public/packs/new/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useMutation } from 'react-query'; + +import { PackForm } from '../common/pack_form'; +import { useKibana } from '../../common/lib/kibana'; + +interface NewPackPageProps { + onSuccess: () => void; +} + +const NewPackPageComponent: React.FC = ({ onSuccess }) => { + const { http } = useKibana().services; + + const addPackMutation = useMutation( + (payload) => + http.post(`/internal/osquery/pack`, { + body: JSON.stringify(payload), + }), + { + onSuccess, + } + ); + + // @ts-expect-error update types + return ; +}; + +export const NewPackPage = React.memo(NewPackPageComponent); diff --git a/x-pack/plugins/osquery/public/plugin.ts b/x-pack/plugins/osquery/public/plugin.ts index cd25f4e86c754..b807e93236df6 100644 --- a/x-pack/plugins/osquery/public/plugin.ts +++ b/x-pack/plugins/osquery/public/plugin.ts @@ -5,18 +5,47 @@ * 2.0. */ +import { BehaviorSubject, Subject } from 'rxjs'; import { AppMountParameters, CoreSetup, Plugin, PluginInitializerContext, CoreStart, -} from 'src/core/public'; + DEFAULT_APP_CATEGORIES, + AppStatus, + AppUpdater, +} from '../../../../src/core/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; -import { OsqueryPluginSetup, OsqueryPluginStart, AppPluginStartDependencies } from './types'; +import { + OsqueryPluginSetup, + OsqueryPluginStart, + // SetupPlugins, + StartPlugins, + AppPluginStartDependencies, +} from './types'; import { PLUGIN_NAME } from '../common'; +import { + LazyOsqueryManagedEmptyCreatePolicyExtension, + LazyOsqueryManagedEmptyEditPolicyExtension, +} from './fleet_integration'; +// import { getActionType } from './osquery_action_type'; + +export function toggleOsqueryPlugin(updater$: Subject, http: CoreStart['http']) { + http.fetch('/api/fleet/epm/packages', { query: { experimental: true } }).then(({ response }) => { + const installed = response.find( + // @ts-expect-error update types + (integration) => + integration?.name === 'osquery_elastic_managed' && integration?.status === 'installed' + ); + updater$.next(() => ({ + status: installed ? AppStatus.accessible : AppStatus.inaccessible, + })); + }); +} export class OsqueryPlugin implements Plugin { + private readonly appUpdater$ = new BehaviorSubject(() => ({})); private kibanaVersion: string; private storage = new Storage(localStorage); @@ -24,7 +53,10 @@ export class OsqueryPlugin implements Plugin(); if (!config.enabled) { @@ -37,6 +69,9 @@ export class OsqueryPlugin implements Plugin(); + + if (!config.enabled) { + return {}; + } + + if (plugins.fleet) { + const { registerExtension } = plugins.fleet; + + toggleOsqueryPlugin(this.appUpdater$, core.http); + + registerExtension({ + package: 'osquery_elastic_managed', + view: 'package-policy-create', + component: LazyOsqueryManagedEmptyCreatePolicyExtension, + }); + + registerExtension({ + package: 'osquery_elastic_managed', + view: 'package-policy-edit', + component: LazyOsqueryManagedEmptyEditPolicyExtension, + }); + + // registerExtension({ + // package: 'osquery_elastic_managed', + // view: 'package-detail-custom', + // component: LazyOsqueryManagedCustomExtension, + // }); + } else { + this.appUpdater$.next(() => ({ + status: AppStatus.inaccessible, + })); + } + return {}; } + // eslint-disable-next-line @typescript-eslint/no-empty-function public stop() {} } diff --git a/x-pack/plugins/osquery/public/queries/edit/index.tsx b/x-pack/plugins/osquery/public/queries/edit/index.tsx new file mode 100644 index 0000000000000..61094b2d07940 --- /dev/null +++ b/x-pack/plugins/osquery/public/queries/edit/index.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash/fp'; +import React from 'react'; +import { useMutation, useQuery } from 'react-query'; + +import { SavedQueryForm } from '../form'; +import { useKibana } from '../../common/lib/kibana'; + +interface EditSavedQueryPageProps { + onSuccess: () => void; + savedQueryId: string; +} + +const EditSavedQueryPageComponent: React.FC = ({ + onSuccess, + savedQueryId, +}) => { + const { http } = useKibana().services; + + const { isLoading, data: savedQueryDetails } = useQuery(['savedQuery', { savedQueryId }], () => + http.get(`/internal/osquery/saved_query/${savedQueryId}`) + ); + const updateSavedQueryMutation = useMutation( + (payload) => + http.put(`/internal/osquery/saved_query/${savedQueryId}`, { body: JSON.stringify(payload) }), + { onSuccess } + ); + + if (isLoading) { + return <>{'Loading...'}; + } + + return ( + <> + {!isEmpty(savedQueryDetails) && ( + + )} + + ); +}; + +export const EditSavedQueryPage = React.memo(EditSavedQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/live_query/edit/tabs.tsx b/x-pack/plugins/osquery/public/queries/edit/tabs.tsx similarity index 75% rename from x-pack/plugins/osquery/public/live_query/edit/tabs.tsx rename to x-pack/plugins/osquery/public/queries/edit/tabs.tsx index 3cc86c9765443..4aa9d20d11123 100644 --- a/x-pack/plugins/osquery/public/live_query/edit/tabs.tsx +++ b/x-pack/plugins/osquery/public/queries/edit/tabs.tsx @@ -6,14 +6,16 @@ */ import { EuiTabbedContent, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; +import React, { useMemo } from 'react'; import { ResultsTable } from '../../results/results_table'; import { ActionResultsTable } from '../../action_results/action_results_table'; -const ResultTabsComponent = () => { - const { actionId } = useParams<{ actionId: string }>(); +interface ResultTabsProps { + actionId: string; +} + +const ResultTabsComponent: React.FC = ({ actionId }) => { const tabs = useMemo( () => [ { @@ -40,17 +42,12 @@ const ResultTabsComponent = () => { [actionId] ); - const handleTabClick = useCallback((tab) => { - // eslint-disable-next-line no-console - console.log('clicked tab', tab); - }, []); - return ( ); }; diff --git a/x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx b/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx similarity index 64% rename from x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx rename to x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx index b9e5dcde13b12..5a564af987562 100644 --- a/x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx +++ b/x-pack/plugins/osquery/public/queries/form/code_editor_field.tsx @@ -5,28 +5,19 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React from 'react'; import { OsqueryEditor } from '../../editor'; import { FieldHook } from '../../shared_imports'; interface CodeEditorFieldProps { - field: FieldHook<{ query: string }>; + field: FieldHook; } const CodeEditorFieldComponent: React.FC = ({ field }) => { const { value, setValue } = field; - const handleChange = useCallback( - (newQuery) => { - setValue({ - ...value, - query: newQuery, - }); - }, - [value, setValue] - ); - return ; + return ; }; export const CodeEditorField = React.memo(CodeEditorFieldComponent); diff --git a/x-pack/plugins/osquery/public/queries/form/index.tsx b/x-pack/plugins/osquery/public/queries/form/index.tsx new file mode 100644 index 0000000000000..02468fbfde228 --- /dev/null +++ b/x-pack/plugins/osquery/public/queries/form/index.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import React from 'react'; + +import { Field, getUseField, useForm, UseField, Form } from '../../shared_imports'; +import { CodeEditorField } from './code_editor_field'; +import { formSchema } from './schema'; + +export const CommonUseField = getUseField({ component: Field }); + +const SAVED_QUERY_FORM_ID = 'savedQueryForm'; + +interface SavedQueryFormProps { + defaultValue?: unknown; + handleSubmit: () => Promise; + type?: string; +} + +const SavedQueryFormComponent: React.FC = ({ + defaultValue, + handleSubmit, + type, +}) => { + const { form } = useForm({ + // @ts-expect-error update types + id: defaultValue ? SAVED_QUERY_FORM_ID + defaultValue.id : SAVED_QUERY_FORM_ID, + schema: formSchema, + onSubmit: handleSubmit, + options: { + stripEmptyFields: false, + }, + // @ts-expect-error update types + defaultValue, + }); + + const { submit } = form; + + return ( +
+ + + + + + + + + {type === 'edit' ? 'Update' : 'Save'} + + ); +}; + +export const SavedQueryForm = React.memo(SavedQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/queries/form/schema.ts b/x-pack/plugins/osquery/public/queries/form/schema.ts new file mode 100644 index 0000000000000..33200e45dc8e3 --- /dev/null +++ b/x-pack/plugins/osquery/public/queries/form/schema.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FIELD_TYPES, FormSchema } from '../../shared_imports'; + +export const formSchema: FormSchema = { + name: { + type: FIELD_TYPES.TEXT, + label: 'Query name', + }, + description: { + type: FIELD_TYPES.TEXTAREA, + label: 'Description', + validations: [], + }, + platform: { + type: FIELD_TYPES.SELECT, + label: 'Platform', + defaultValue: 'all', + }, + query: { + label: 'Query', + type: FIELD_TYPES.TEXTAREA, + validations: [], + }, +}; diff --git a/x-pack/plugins/osquery/public/queries/index.tsx b/x-pack/plugins/osquery/public/queries/index.tsx new file mode 100644 index 0000000000000..7ecce3cfb22f4 --- /dev/null +++ b/x-pack/plugins/osquery/public/queries/index.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; + +import { QueriesPage } from './queries'; +import { NewSavedQueryPage } from './new'; +import { EditSavedQueryPage } from './edit'; + +const QueriesComponent = () => { + const [showNewSavedQueryForm, setShowNewSavedQueryForm] = useState(false); + const [editSavedQueryId, setEditSavedQueryId] = useState(null); + + const goBack = useCallback(() => { + setShowNewSavedQueryForm(false); + setEditSavedQueryId(null); + }, []); + + const handleNewQueryClick = useCallback(() => setShowNewSavedQueryForm(true), []); + + if (showNewSavedQueryForm) { + return ; + } + + if (editSavedQueryId?.length) { + return ; + } + + return ; +}; + +export const Queries = React.memo(QueriesComponent); diff --git a/x-pack/plugins/osquery/public/queries/new/index.tsx b/x-pack/plugins/osquery/public/queries/new/index.tsx new file mode 100644 index 0000000000000..2682db126ea09 --- /dev/null +++ b/x-pack/plugins/osquery/public/queries/new/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useMutation } from 'react-query'; + +import { useKibana } from '../../common/lib/kibana'; +import { SavedQueryForm } from '../form'; + +interface NewSavedQueryPageProps { + onSuccess: () => void; +} + +const NewSavedQueryPageComponent: React.FC = ({ onSuccess }) => { + const { http } = useKibana().services; + + const createSavedQueryMutation = useMutation( + (payload) => http.post(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }), + { + onSuccess, + } + ); + + // @ts-expect-error update types + return ; +}; + +export const NewSavedQueryPage = React.memo(NewSavedQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/queries/queries/index.tsx b/x-pack/plugins/osquery/public/queries/queries/index.tsx new file mode 100644 index 0000000000000..bf766a15a44a3 --- /dev/null +++ b/x-pack/plugins/osquery/public/queries/queries/index.tsx @@ -0,0 +1,244 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { map } from 'lodash/fp'; +import { + EuiBasicTable, + EuiButton, + EuiButtonIcon, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useQuery, useQueryClient, useMutation } from 'react-query'; +import { useHistory } from 'react-router-dom'; +import qs from 'query-string'; + +import { useKibana } from '../../common/lib/kibana'; + +interface QueriesPageProps { + onEditClick: (savedQueryId: string) => void; + onNewClick: () => void; +} + +const QueriesPageComponent: React.FC = ({ onEditClick, onNewClick }) => { + const { push } = useHistory(); + const queryClient = useQueryClient(); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState('updated_at'); + const [sortDirection, setSortDirection] = useState('desc'); + const [selectedItems, setSelectedItems] = useState([]); + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>({}); + const { http } = useKibana().services; + + const deleteSavedQueriesMutation = useMutation( + (payload) => http.delete(`/internal/osquery/saved_query`, { body: JSON.stringify(payload) }), + { + onSuccess: () => queryClient.invalidateQueries('savedQueryList'), + } + ); + + const { data = {} } = useQuery( + ['savedQueryList', { pageIndex, pageSize, sortField, sortDirection }], + () => + http.get('/internal/osquery/saved_query', { + query: { + pageIndex, + pageSize, + sortField, + sortDirection, + }, + }), + { + keepPreviousData: true, + // Refetch the data every 10 seconds + refetchInterval: 5000, + } + ); + const { total = 0, saved_objects: savedQueries } = data; + + const toggleDetails = useCallback( + (item) => () => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[item.id]) { + delete itemIdToExpandedRowMapValues[item.id]; + } else { + itemIdToExpandedRowMapValues[item.id] = ( + + {item.attributes.query} + + ); + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }, + [itemIdToExpandedRowMap] + ); + + const renderExtendedItemToggle = useCallback( + (item) => ( + + ), + [itemIdToExpandedRowMap, toggleDetails] + ); + + const handleEditClick = useCallback((item) => onEditClick(item.id), [onEditClick]); + + const handlePlayClick = useCallback( + (item) => + push({ + search: qs.stringify({ + tab: 'live_query', + }), + state: { + query: { + id: item.id, + query: item.attributes.query, + }, + }, + }), + [push] + ); + + const columns = useMemo( + () => [ + { + field: 'attributes.name', + name: 'Query name', + sortable: true, + truncateText: true, + }, + { + field: 'attributes.description', + name: 'Description', + sortable: true, + truncateText: true, + }, + { + field: 'updated_at', + name: 'Last updated at', + sortable: true, + truncateText: true, + }, + { + name: 'Actions', + actions: [ + { + name: 'Live query', + description: 'Run live query', + type: 'icon', + icon: 'play', + onClick: handlePlayClick, + }, + { + name: 'Edit', + description: 'Edit or run this query', + type: 'icon', + icon: 'documentEdit', + onClick: handleEditClick, + }, + ], + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: renderExtendedItemToggle, + }, + ], + [handleEditClick, handlePlayClick, renderExtendedItemToggle] + ); + + const onTableChange = useCallback(({ page = {}, sort = {} }) => { + setPageIndex(page.index); + setPageSize(page.size); + setSortField(sort.field); + setSortDirection(sort.direction); + }, []); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: total, + pageSizeOptions: [3, 5, 8], + }), + [total, pageIndex, pageSize] + ); + + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const selection = useMemo( + () => ({ + selectable: () => true, + onSelectionChange: setSelectedItems, + initialSelected: [], + }), + [] + ); + + const handleDeleteClick = useCallback(() => { + const selectedItemsIds = map('id', selectedItems); + // @ts-expect-error update types + deleteSavedQueriesMutation.mutate({ savedQueryIds: selectedItemsIds }); + }, [deleteSavedQueriesMutation, selectedItems]); + + return ( +
+ + + {!selectedItems.length ? ( + + {'New query'} + + ) : ( + + {`Delete ${selectedItems.length} Queries`} + + )} + + + + + + {savedQueries && ( + + )} +
+ ); +}; + +export const QueriesPage = React.memo(QueriesPageComponent); diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index 71aed5743647b..4c2048148f745 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -6,20 +6,22 @@ */ import { isEmpty, isEqual, keys, map } from 'lodash/fp'; -import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn } from '@elastic/eui'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiLink } from '@elastic/eui'; import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; import { EuiDataGridSorting } from '@elastic/eui'; import { useAllResults } from './use_all_results'; import { Direction, ResultEdges } from '../../common/search_strategy'; +import { useRouterNavigate } from '../common/lib/kibana'; const DataContext = createContext([]); interface ResultsTableComponentProps { actionId: string; + agentId?: string; } -const ResultsTableComponent: React.FC = ({ actionId }) => { +const ResultsTableComponent: React.FC = ({ actionId, agentId }) => { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); const onChangeItemsPerPage = useCallback( (pageSize) => @@ -46,8 +48,9 @@ const ResultsTableComponent: React.FC = ({ actionId [setSortingColumns] ); - const [, { results, totalCount }] = useAllResults({ + const { data: allResultsData = [] } = useAllResults({ actionId, + agentId, activePage: pagination.pageIndex, limit: pagination.pageSize, direction: Direction.asc, @@ -61,15 +64,22 @@ const ResultsTableComponent: React.FC = ({ actionId ]); const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo( - () => ({ rowIndex, columnId, setCellProps }) => { + () => ({ rowIndex, columnId }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const data = useContext(DataContext); const value = data[rowIndex].fields[columnId]; + if (columnId === 'agent.name') { + const agentIdValue = data[rowIndex].fields['agent.id']; + // eslint-disable-next-line react-hooks/rules-of-hooks + const linkProps = useRouterNavigate(`/live_query/${actionId}/results/${agentIdValue}`); + return {value}; + } + return !isEmpty(value) ? value : '-'; }, - [] + [actionId] ); const tableSorting = useMemo(() => ({ columns: sortingColumns, onSort }), [ @@ -88,30 +98,59 @@ const ResultsTableComponent: React.FC = ({ actionId ); useEffect(() => { - const newColumns: EuiDataGridColumn[] = keys(results[0]?.fields) + // @ts-expect-error update types + if (!allResultsData?.results) { + return; + } + // @ts-expect-error update types + const newColumns = keys(allResultsData?.results[0]?.fields) .sort() - .map((fieldName) => ({ - id: fieldName, - displayAsText: fieldName.split('.')[1], - defaultSortDirection: 'asc', - })); + .reduce((acc, fieldName) => { + if (fieldName === 'agent.name') { + return [ + ...acc, + { + id: fieldName, + displayAsText: 'agent', + defaultSortDirection: Direction.asc, + }, + ]; + } + + if (fieldName.startsWith('osquery.')) { + return [ + ...acc, + { + id: fieldName, + displayAsText: fieldName.split('.')[1], + defaultSortDirection: Direction.asc, + }, + ]; + } + + return acc; + }, [] as EuiDataGridColumn[]); if (!isEqual(columns, newColumns)) { setColumns(newColumns); setVisibleColumns(map('id', newColumns)); } - }, [columns, results]); + // @ts-expect-error update types + }, [columns, allResultsData?.results]); return ( - + // @ts-expect-error update types + ); diff --git a/x-pack/plugins/osquery/public/results/use_all_results.ts b/x-pack/plugins/osquery/public/results/use_all_results.ts index 69e6e874d4dfc..5727edf1bf4c3 100644 --- a/x-pack/plugins/osquery/public/results/use_all_results.ts +++ b/x-pack/plugins/osquery/public/results/use_all_results.ts @@ -6,14 +6,14 @@ */ import deepEqual from 'fast-deep-equal'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useQuery } from 'react-query'; import { createFilter } from '../common/helpers'; import { useKibana } from '../common/lib/kibana'; import { ResultEdges, PageInfoPaginated, - DocValueFields, OsqueryQueries, ResultsRequestOptions, ResultsStrategyResponse, @@ -21,13 +21,8 @@ import { } from '../../common/search_strategy'; import { ESTermQuery } from '../../common/typed_json'; -import * as i18n from './translations'; -import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; -import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; -const ID = 'resultsAllQuery'; - export interface ResultsArgs { results: ResultEdges; id: string; @@ -40,10 +35,10 @@ export interface ResultsArgs { interface UseAllResults { actionId: string; activePage: number; + agentId?: string; direction: Direction; limit: number; sortField: string; - docValueFields?: DocValueFields[]; filterQuery?: ESTermQuery | string; skip?: boolean; } @@ -51,89 +46,38 @@ interface UseAllResults { export const useAllResults = ({ actionId, activePage, + agentId, direction, limit, sortField, - docValueFields, filterQuery, skip = false, -}: UseAllResults): [boolean, ResultsArgs] => { - const { data, notifications } = useKibana().services; +}: UseAllResults) => { + const { data } = useKibana().services; - const abortCtrl = useRef(new AbortController()); - const [loading, setLoading] = useState(false); const [resultsRequest, setHostRequest] = useState(null); - const [resultsResponse, setResultsResponse] = useState({ - results: [], - id: ID, - inspect: { - dsl: [], - response: [], - }, - isInspected: false, - pageInfo: { - activePage: 0, - fakeTotalCount: 0, - showMorePagesIndicator: false, - }, - totalCount: -1, - }); - - const resultsSearch = useCallback( - (request: ResultsRequestOptions | null) => { - if (request == null || skip) { - return; - } + const response = useQuery( + ['allActionResults', { actionId, activePage, direction, limit, sortField }], + async () => { + if (!resultsRequest) return Promise.resolve(); - let didCancel = false; - const asyncSearch = async () => { - abortCtrl.current = new AbortController(); - setLoading(true); + const responseData = await data.search + .search(resultsRequest, { + strategy: 'osquerySearchStrategy', + }) + .toPromise(); - const searchSubscription$ = data.search - .search(request, { - strategy: 'osquerySearchStrategy', - abortSignal: abortCtrl.current.signal, - }) - .subscribe({ - next: (response) => { - if (isCompleteResponse(response)) { - if (!didCancel) { - setLoading(false); - setResultsResponse((prevResponse) => ({ - ...prevResponse, - results: response.edges, - inspect: getInspectResponse(response, prevResponse.inspect), - pageInfo: response.pageInfo, - totalCount: response.totalCount, - })); - } - searchSubscription$.unsubscribe(); - } else if (isErrorResponse(response)) { - if (!didCancel) { - setLoading(false); - } - // TODO: Make response error status clearer - notifications.toasts.addWarning(i18n.ERROR_ALL_RESULTS); - searchSubscription$.unsubscribe(); - } - }, - error: (msg) => { - if (!(msg instanceof AbortError)) { - notifications.toasts.addDanger({ title: i18n.FAIL_ALL_RESULTS, text: msg.message }); - } - }, - }); - }; - abortCtrl.current.abort(); - asyncSearch(); - return () => { - didCancel = true; - abortCtrl.current.abort(); + return { + ...responseData, + results: responseData.edges, + inspect: getInspectResponse(responseData, {} as InspectResponse), }; }, - [data.search, notifications.toasts, skip] + { + refetchInterval: 1000, + enabled: !skip && !!resultsRequest, + } ); useEffect(() => { @@ -141,7 +85,7 @@ export const useAllResults = ({ const myRequest = { ...(prevRequest ?? {}), actionId, - docValueFields: docValueFields ?? [], + agentId, factoryQueryType: OsqueryQueries.results, filterQuery: createFilter(filterQuery), pagination: generateTablePaginationOptions(activePage, limit), @@ -155,11 +99,7 @@ export const useAllResults = ({ } return prevRequest; }); - }, [actionId, activePage, direction, docValueFields, filterQuery, limit, sortField]); - - useEffect(() => { - resultsSearch(resultsRequest); - }, [resultsRequest, resultsSearch]); + }, [actionId, activePage, agentId, direction, filterQuery, limit, sortField]); - return [loading, resultsResponse]; + return response; }; diff --git a/x-pack/plugins/osquery/public/routes/index.tsx b/x-pack/plugins/osquery/public/routes/index.tsx new file mode 100644 index 0000000000000..18ba0abec5696 --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Switch, Redirect, Route } from 'react-router-dom'; + +import { LiveQueries } from './live_query'; + +const OsqueryAppRoutesComponent = () => ( + + {/* + + + + + + + + */} + + + + + +); + +export const OsqueryAppRoutes = React.memo(OsqueryAppRoutesComponent); diff --git a/x-pack/plugins/osquery/public/routes/live_query/agent_details/index.tsx b/x-pack/plugins/osquery/public/routes/live_query/agent_details/index.tsx new file mode 100644 index 0000000000000..266847a803c0d --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/live_query/agent_details/index.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiCodeBlock, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { WithHeaderLayout } from '../../../components/layouts'; +import { useActionDetails } from '../../../actions/use_action_details'; +import { ResultsTable } from '../../../results/results_table'; + +const LiveQueryAgentDetailsPageComponent = () => { + const { actionId, agentId } = useParams<{ actionId: string; agentId: string }>(); + const { data } = useActionDetails({ actionId }); + const liveQueryListProps = useRouterNavigate(`live_query/${actionId}`); + + const LeftColumn = useMemo( + () => ( + + + + + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+ ), + [agentId, liveQueryListProps] + ); + + return ( + + + { + // @ts-expect-error update types + data?.actionDetails._source?.data?.query + } + + + + + ); +}; + +export const LiveQueryAgentDetailsPage = React.memo(LiveQueryAgentDetailsPageComponent); diff --git a/x-pack/plugins/osquery/public/routes/live_query/details/actions_menu.tsx b/x-pack/plugins/osquery/public/routes/live_query/details/actions_menu.tsx new file mode 100644 index 0000000000000..677b917e047b4 --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/live_query/details/actions_menu.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { useKibana } from '../../../common/lib/kibana'; + +interface LiveQueryDetailsActionsMenuProps { + actionId: string; +} + +const LiveQueryDetailsActionsMenuComponent: React.FC = ({ + actionId, +}) => { + const services = useKibana().services; + const [isPopoverOpen, setPopover] = useState(false); + + const discoverLinkHref = services?.application?.getUrlForApp('discover', { + path: `#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-24h,to:now))&_a=(columns:!(),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logs-*',key:action_id,negate:!f,params:(query:'${actionId}'),type:phrase),query:(match_phrase:(action_id:'${actionId}')))),index:'logs-*',interval:auto,query:(language:kuery,query:''),sort:!(!('@timestamp',desc)))`, + }); + + const onButtonClick = useCallback(() => { + setPopover((currentIsPopoverOpen) => !currentIsPopoverOpen); + }, []); + + const closePopover = useCallback(() => { + setPopover(false); + }, []); + + const items = useMemo( + () => [ + + Check results in Discover + , + ], + [discoverLinkHref] + ); + + const button = ( + + Actions + + ); + + return ( + + + + ); +}; + +export const LiveQueryDetailsActionsMenu = React.memo(LiveQueryDetailsActionsMenuComponent); diff --git a/x-pack/plugins/osquery/public/routes/live_query/details/index.tsx b/x-pack/plugins/osquery/public/routes/live_query/details/index.tsx new file mode 100644 index 0000000000000..11665bede97c5 --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/live_query/details/index.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButtonEmpty, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiCodeBlock, + EuiSpacer, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + +import { Direction } from '../../../../common/search_strategy'; +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { WithHeaderLayout } from '../../../components/layouts'; +import { useActionResults } from '../../../action_results/use_action_results'; +import { useActionDetails } from '../../../actions/use_action_details'; +import { ResultTabs } from '../../../queries/edit/tabs'; +import { LiveQueryDetailsActionsMenu } from './actions_menu'; + +const Divider = styled.div` + width: 0; + height: 100%; + border-left: ${({ theme }) => theme.eui.euiBorderThin}; +`; + +const LiveQueryDetailsPageComponent = () => { + const { actionId } = useParams<{ actionId: string }>(); + const liveQueryListProps = useRouterNavigate('live_query'); + + const { data } = useActionDetails({ actionId }); + const { data: actionResultsData } = useActionResults({ + actionId, + activePage: 0, + limit: 0, + direction: Direction.asc, + sortField: '@timestamp', + }); + + const LeftColumn = useMemo( + () => ( + + + + + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+ ), + [liveQueryListProps] + ); + + const RightColumn = useMemo( + () => ( + + + <> + + + + + + {/* eslint-disable-next-line react-perf/jsx-no-new-object-as-prop */} + + + + + + { + // @ts-expect-error update types + data?.actionDetails?.fields?.agents?.length ?? '0' + } + + + + + + + + {/* eslint-disable-next-line react-perf/jsx-no-new-object-as-prop */} + + + + + + { + // @ts-expect-error update types + actionResultsData?.rawResponse?.aggregations?.responses?.buckets.find( + // @ts-expect-error update types + (bucket) => bucket.key === 'error' + )?.doc_count ?? '0' + } + + + + + + + + + + + ), + [ + actionId, + // @ts-expect-error update types + actionResultsData?.rawResponse?.aggregations?.responses?.buckets, + // @ts-expect-error update types + data?.actionDetails?.fields?.agents?.length, + ] + ); + + return ( + + + { + // @ts-expect-error update types + data?.actionDetails._source?.data?.query + } + + + + + ); +}; + +export const LiveQueryDetailsPage = React.memo(LiveQueryDetailsPageComponent); diff --git a/x-pack/plugins/osquery/public/routes/live_query/index.tsx b/x-pack/plugins/osquery/public/routes/live_query/index.tsx new file mode 100644 index 0000000000000..738f96087545c --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/live_query/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Switch, Route, useRouteMatch } from 'react-router-dom'; + +import { LiveQueriesPage } from './list'; +import { NewLiveQueryPage } from './new'; +import { LiveQueryDetailsPage } from './details'; +import { LiveQueryAgentDetailsPage } from './agent_details'; + +const LiveQueriesComponent = () => { + const match = useRouteMatch(); + + return ( + + + + + + + + + + + + + + + ); +}; + +export const LiveQueries = React.memo(LiveQueriesComponent); diff --git a/x-pack/plugins/osquery/public/routes/live_query/list/index.tsx b/x-pack/plugins/osquery/public/routes/live_query/list/index.tsx new file mode 100644 index 0000000000000..ed72fe704294d --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/live_query/list/index.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useMemo } from 'react'; + +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { ActionsTable } from '../../../actions/actions_table'; +import { WithHeaderLayout } from '../../../components/layouts'; + +const LiveQueriesPageComponent = () => { + const newQueryLinkProps = useRouterNavigate('live_query/new'); + + const LeftColumn = useMemo( + () => ( + + + +

+ +

+
+
+ + +

+ +

+
+
+
+ ), + [] + ); + + const RightColumn = useMemo( + () => ( + + {'New live query'} + + ), + [newQueryLinkProps] + ); + + return ( + + + + ); +}; + +export const LiveQueriesPage = React.memo(LiveQueriesPageComponent); diff --git a/x-pack/plugins/osquery/public/routes/live_query/new/index.tsx b/x-pack/plugins/osquery/public/routes/live_query/new/index.tsx new file mode 100644 index 0000000000000..0aeb46da2a897 --- /dev/null +++ b/x-pack/plugins/osquery/public/routes/live_query/new/index.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useMemo } from 'react'; + +import { WithHeaderLayout } from '../../../components/layouts'; +import { useRouterNavigate } from '../../../common/lib/kibana'; +import { LiveQuery } from '../../../live_query'; + +const NewLiveQueryPageComponent = () => { + const liveQueryListProps = useRouterNavigate('live_query'); + + const LeftColumn = useMemo( + () => ( + + + + + + + + +

+ +

+
+
+ + +

+ +

+
+
+
+ ), + [liveQueryListProps] + ); + + return ( + + + + ); +}; + +export const NewLiveQueryPage = React.memo(NewLiveQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query/common/osquery_stream_field.tsx b/x-pack/plugins/osquery/public/scheduled_query/common/osquery_stream_field.tsx new file mode 100644 index 0000000000000..6f589f6f64b13 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/common/osquery_stream_field.tsx @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { find } from 'lodash/fp'; +import { + EuiButtonIcon, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiSwitch, + EuiHorizontalRule, +} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useQuery } from 'react-query'; + +import { useKibana } from '../../common/lib/kibana'; + +// @ts-expect-error update types +const OsqueryStreamFieldComponent = ({ field, removeItem }) => { + const { http } = useKibana().services; + const { data: { saved_objects: savedQueries } = {} } = useQuery(['savedQueryList'], () => + http.get('/internal/osquery/saved_query', { + query: { pageIndex: 0, pageSize: 100, sortField: 'updated_at', sortDirection: 'desc' }, + }) + ); + + const { setValue } = field; + + const savedQueriesOptions = useMemo( + () => + // @ts-expect-error update types + (savedQueries ?? []).map((savedQuery) => ({ + text: savedQuery.attributes.name, + value: savedQuery.id, + })), + [savedQueries] + ); + + const handleSavedQueryChange = useCallback( + (event) => { + event.persist(); + const savedQueryId = event.target.value; + const savedQuery = find(['id', savedQueryId], savedQueries); + + if (savedQuery) { + // @ts-expect-error update types + setValue((prev) => ({ + ...prev, + vars: { + ...prev.vars, + id: { + ...prev.vars.id, + value: savedQuery.id, + }, + query: { + ...prev.vars.query, + value: savedQuery.attributes.query, + }, + }, + })); + } + }, + [savedQueries, setValue] + ); + + const handleEnabledChange = useCallback(() => { + // @ts-expect-error update types + setValue((prev) => ({ + ...prev, + enabled: !prev.enabled, + })); + }, [setValue]); + + const handleQueryChange = useCallback( + (event) => { + event.persist(); + // @ts-expect-error update types + setValue((prev) => ({ + ...prev, + vars: { + ...prev.vars, + query: { + ...prev.vars.query, + value: event.target.value, + }, + }, + })); + }, + [setValue] + ); + + const handleIntervalChange = useCallback( + (event) => { + event.persist(); + // @ts-expect-error update types + setValue((prev) => ({ + ...prev, + vars: { + ...prev.vars, + interval: { + ...prev.vars.interval, + value: event.target.value, + }, + }, + })); + }, + [setValue] + ); + + const handleIdChange = useCallback( + (event) => { + event.persist(); + // @ts-expect-error update types + setValue((prev) => ({ + ...prev, + vars: { + ...prev.vars, + id: { + ...prev.vars.id, + value: event.target.value, + }, + }, + })); + }, + [setValue] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const OsqueryStreamField = React.memo(OsqueryStreamFieldComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query/edit/form.tsx b/x-pack/plugins/osquery/public/scheduled_query/edit/form.tsx new file mode 100644 index 0000000000000..3e0e2b33efdae --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/edit/form.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import produce from 'immer'; +import { get, omit } from 'lodash/fp'; +import { EuiButton, EuiButtonEmpty, EuiSpacer, EuiHorizontalRule } from '@elastic/eui'; +import uuid from 'uuid'; +import React, { useMemo } from 'react'; + +import { + UseField, + useForm, + UseArray, + getUseField, + Field, + ToggleField, + Form, +} from '../../shared_imports'; + +import { OsqueryStreamField } from '../common/osquery_stream_field'; +import { schema } from './schema'; + +const CommonUseField = getUseField({ component: Field }); + +const EDIT_SCHEDULED_QUERY_FORM_ID = 'editScheduledQueryForm'; + +interface EditScheduledQueryFormProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agentPolicies: Array>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: Array>; + handleSubmit: () => Promise; +} + +const EditScheduledQueryFormComponent: React.FC = ({ + agentPolicies, + data, + handleSubmit, +}) => { + const agentPoliciesOptions = useMemo( + () => + agentPolicies.map((policy) => ({ + value: policy.id, + text: policy.name, + })), + [agentPolicies] + ); + + const { form } = useForm({ + schema, + id: EDIT_SCHEDULED_QUERY_FORM_ID, + onSubmit: handleSubmit, + defaultValue: data, + // @ts-expect-error update types + deserializer: (payload) => { + const deserialized = produce(payload, (draft) => { + // @ts-expect-error update types + draft.inputs[0].streams.forEach((stream) => { + delete stream.compiled_stream; + }); + }); + + return deserialized; + }, + // @ts-expect-error update types + serializer: (payload) => + omit(['id', 'revision', 'created_at', 'created_by', 'updated_at', 'updated_by', 'version'], { + ...data, + ...payload, + // @ts-expect-error update types + inputs: [{ type: 'osquery', ...((payload.inputs && payload.inputs[0]) ?? {}) }], + }), + }); + + const { submit } = form; + + const policyIdComponentProps = useMemo( + () => ({ + euiFieldProps: { + disabled: true, + options: agentPoliciesOptions, + }, + }), + [agentPoliciesOptions] + ); + + return ( +
+ + + + + + + + + + + {({ items, addItem, removeItem }) => ( + <> + {items.map((item) => ( + removeItem(item.id)} + defaultValue={ + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + get(item.path, form.getFormData()) ?? { + data_stream: { + type: 'logs', + dataset: 'osquery_elastic_managed.osquery', + }, + vars: { + query: { + type: 'text', + value: 'select * from uptime', + }, + interval: { + type: 'text', + value: '120', + }, + id: { + type: 'text', + value: uuid.v4(), + }, + }, + enabled: true, + } + } + /> + ))} + + {'Add query'} + + + )} + + + + + Save + + + ); +}; + +export const EditScheduledQueryForm = React.memo(EditScheduledQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query/edit/index.tsx b/x-pack/plugins/osquery/public/scheduled_query/edit/index.tsx new file mode 100644 index 0000000000000..65dec2e467b35 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/edit/index.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { useMutation, useQuery } from 'react-query'; + +import { useKibana } from '../../common/lib/kibana'; +import { EditScheduledQueryForm } from './form'; + +const EditScheduledQueryPageComponent = () => { + const { http } = useKibana().services; + const { scheduledQueryId } = useParams<{ scheduledQueryId: string }>(); + + const { data } = useQuery(['scheduledQuery', { scheduledQueryId }], () => + http.get(`/internal/osquery/scheduled_query/${scheduledQueryId}`) + ); + + const { data: agentPolicies } = useQuery( + ['agentPolicy'], + () => http.get(`/api/fleet/agent_policies`), + { initialData: { items: [] } } + ); + + const updateScheduledQueryMutation = useMutation((payload) => + http.put(`/api/fleet/package_policies/${scheduledQueryId}`, { body: JSON.stringify(payload) }) + ); + + if (data) { + return ( + + ); + } + + return
Loading
; +}; + +export const EditScheduledQueryPage = React.memo(EditScheduledQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query/edit/schema.ts b/x-pack/plugins/osquery/public/scheduled_query/edit/schema.ts new file mode 100644 index 0000000000000..75a6d955c62ec --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/edit/schema.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FIELD_TYPES } from '../../shared_imports'; + +export const schema = { + policy_id: { + type: FIELD_TYPES.SELECT, + label: 'Policy', + }, + name: { + type: FIELD_TYPES.TEXT, + label: 'Name', + }, + description: { + type: FIELD_TYPES.TEXT, + label: 'Description', + }, + streams: { + type: FIELD_TYPES.MULTI_SELECT, + }, +}; diff --git a/x-pack/plugins/osquery/public/scheduled_query/index.tsx b/x-pack/plugins/osquery/public/scheduled_query/index.tsx new file mode 100644 index 0000000000000..205c87b3a0d50 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/index.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Switch, Route, useRouteMatch } from 'react-router-dom'; + +import { ScheduledQueriesPage } from './queries'; +import { NewScheduledQueryPage } from './new'; +import { EditScheduledQueryPage } from './edit'; +// import { QueryAgentResults } from './agent_results'; +// import { SavedQueriesPage } from './saved_query'; + +const ScheduledQueriesComponent = () => { + const match = useRouteMatch(); + + return ( + + + + + {/* + + */} + + + + + + + + ); +}; + +export const ScheduledQueries = React.memo(ScheduledQueriesComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query/new/form.tsx b/x-pack/plugins/osquery/public/scheduled_query/new/form.tsx new file mode 100644 index 0000000000000..186e74d190c6d --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/new/form.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; +import deepmerge from 'deepmerge'; +import React, { useCallback } from 'react'; + +import { useForm, UseArray, UseField, getUseField, Field, Form } from '../../shared_imports'; + +import { OsqueryStreamField } from '../common/osquery_stream_field'; +import { defaultValue, schema } from './schema'; +import { combineMerge } from './utils'; + +const CommonUseField = getUseField({ component: Field }); + +const NEW_SCHEDULED_QUERY_FORM_ID = 'newScheduledQueryForm'; + +interface NewScheduledQueryFormProps { + handleSubmit: () => Promise; +} + +const NewScheduledQueryFormComponent: React.FC = ({ handleSubmit }) => { + const { form } = useForm({ + schema, + id: NEW_SCHEDULED_QUERY_FORM_ID, + options: { + stripEmptyFields: false, + }, + onSubmit: handleSubmit, + // @ts-expect-error update types + defaultValue, + serializer: (payload) => + deepmerge(defaultValue, payload, { + arrayMerge: combineMerge, + }), + }); + const { submit } = form; + + const StreamsContent = useCallback( + ({ items, addItem, removeItem }) => ( + <> + { + // @ts-expect-error update types + items.map((item) => ( + removeItem(item.id)} + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + defaultValue={{ + data_stream: { + type: 'logs', + dataset: 'osquery_elastic_managed.osquery', + }, + vars: { + query: { + type: 'text', + value: '', + }, + interval: { + type: 'text', + value: '', + }, + id: { + type: 'text', + value: '', + }, + }, + enabled: true, + }} + /> + )) + } + + {'Add query'} + + + ), + [] + ); + + return ( +
+ + + + + + {StreamsContent} + + + + {'Save'} + + + ); +}; + +export const NewScheduledQueryForm = React.memo(NewScheduledQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query/new/index.tsx b/x-pack/plugins/osquery/public/scheduled_query/new/index.tsx new file mode 100644 index 0000000000000..bb4ae6f113de2 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/new/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { useMutation } from 'react-query'; + +import { useKibana } from '../../common/lib/kibana'; +import { NewScheduledQueryForm } from './form'; + +const NewScheduledQueryPageComponent = () => { + const { http } = useKibana().services; + const history = useHistory(); + + const createScheduledQueryMutation = useMutation( + (payload) => http.post(`/api/fleet/package_policies`, { body: JSON.stringify(payload) }), + { + onSuccess: (data) => { + history.push(`/scheduled_queries/${data.item.id}`); + }, + } + ); + + // @ts-expect-error update types + return ; +}; + +export const NewScheduledQueryPage = React.memo(NewScheduledQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/scheduled_query/new/schema.ts b/x-pack/plugins/osquery/public/scheduled_query/new/schema.ts new file mode 100644 index 0000000000000..aef33e57f6f30 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/new/schema.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FIELD_TYPES } from '../../shared_imports'; + +export const defaultValue = { + name: '', + description: '', + namespace: 'default', + enabled: true, + policy_id: '1e2bb670-686c-11eb-84b4-81282a213fcf', + output_id: '', + package: { + name: 'osquery_elastic_managed', + title: 'OSquery Elastic Managed', + version: '0.1.2', + }, + inputs: [ + { + type: 'osquery', + enabled: true, + streams: [], + }, + ], +}; + +export const schema = { + name: { + type: FIELD_TYPES.TEXT, + label: 'Name', + }, + description: { + type: FIELD_TYPES.TEXT, + label: 'Description', + }, + namespace: { + type: FIELD_TYPES.TEXT, + }, + enabled: { + type: FIELD_TYPES.TOGGLE, + }, + policy_id: { + type: FIELD_TYPES.TEXT, + }, + inputs: { + enabled: { + type: FIELD_TYPES.TOGGLE, + }, + streams: { + type: FIELD_TYPES.MULTI_SELECT, + vars: { + query: { + type: { + type: FIELD_TYPES.TEXT, + }, + value: { + type: FIELD_TYPES.TEXT, + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/osquery/public/scheduled_query/new/utils.ts b/x-pack/plugins/osquery/public/scheduled_query/new/utils.ts new file mode 100644 index 0000000000000..2de5c90f19c0e --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/new/utils.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import deepmerge from 'deepmerge'; + +// @ts-expect-error update types +export const combineMerge = (target, source, options) => { + const destination = target.slice(); + + // @ts-expect-error update types + source.forEach((item, index) => { + if (typeof destination[index] === 'undefined') { + destination[index] = options.cloneUnlessOtherwiseSpecified(item, options); + } else if (options.isMergeableObject(item)) { + destination[index] = deepmerge(target[index], item, options); + } else if (target.indexOf(item) === -1) { + destination.push(item); + } + }); + return destination; +}; diff --git a/x-pack/plugins/osquery/public/scheduled_query/queries/index.tsx b/x-pack/plugins/osquery/public/scheduled_query/queries/index.tsx new file mode 100644 index 0000000000000..24a78320e30d2 --- /dev/null +++ b/x-pack/plugins/osquery/public/scheduled_query/queries/index.tsx @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBasicTable, + EuiButton, + EuiButtonIcon, + EuiCodeBlock, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useHistory } from 'react-router-dom'; + +import { Direction } from '../../../common/search_strategy'; +import { useKibana, useRouterNavigate } from '../../common/lib/kibana'; + +const ScheduledQueriesPageComponent = () => { + const { push } = useHistory(); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState('updated_at'); + const [sortDirection, setSortDirection] = useState(Direction.desc); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>({}); + const { http } = useKibana().services; + const newQueryLinkProps = useRouterNavigate('scheduled_queries/new'); + + const { data = {} } = useQuery( + ['scheduledQueryList', { pageIndex, pageSize, sortField, sortDirection }], + () => + http.get('/internal/osquery/scheduled_query', { + query: { + pageIndex, + pageSize, + sortField, + sortDirection, + }, + }), + { + keepPreviousData: true, + // Refetch the data every 5 seconds + refetchInterval: 5000, + } + ); + const { total = 0, items: savedQueries } = data; + + const toggleDetails = useCallback( + (item) => () => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[item.id]) { + delete itemIdToExpandedRowMapValues[item.id]; + } else { + // @ts-expect-error update types + itemIdToExpandedRowMapValues[item.id] = item.inputs[0].streams.map((stream) => ( + + {`${stream.vars.query.value} every ${stream.vars.interval.value}s`} + + )); + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }, + [itemIdToExpandedRowMap] + ); + + const renderExtendedItemToggle = useCallback( + (item) => ( + + ), + [itemIdToExpandedRowMap, toggleDetails] + ); + + const handleEditClick = useCallback((item) => push(`/scheduled_queries/${item.id}`), [push]); + + const columns = useMemo( + () => [ + { + field: 'name', + name: 'Query name', + sortable: true, + truncateText: true, + }, + { + field: 'enabled', + name: 'Active', + sortable: true, + truncateText: true, + }, + { + field: 'updated_at', + name: 'Last updated at', + sortable: true, + truncateText: true, + }, + { + name: 'Actions', + actions: [ + { + name: 'Edit', + description: 'Edit or run this query', + type: 'icon', + icon: 'documentEdit', + onClick: handleEditClick, + }, + ], + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: renderExtendedItemToggle, + }, + ], + [handleEditClick, renderExtendedItemToggle] + ); + + const onTableChange = useCallback(({ page = {}, sort = {} }) => { + setPageIndex(page.index); + setPageSize(page.size); + setSortField(sort.field); + setSortDirection(sort.direction); + }, []); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: total, + pageSizeOptions: [3, 5, 8], + }), + [total, pageIndex, pageSize] + ); + + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const selection = useMemo( + () => ({ + selectable: () => true, + initialSelected: [], + }), + [] + ); + + return ( +
+ + {'New query'} + + + {savedQueries && ( + + )} +
+ ); +}; + +export const ScheduledQueriesPage = React.memo(ScheduledQueriesPageComponent); diff --git a/x-pack/plugins/osquery/public/shared_imports.ts b/x-pack/plugins/osquery/public/shared_imports.ts index ef5ba42010de7..42e82b25d1b8f 100644 --- a/x-pack/plugins/osquery/public/shared_imports.ts +++ b/x-pack/plugins/osquery/public/shared_imports.ts @@ -16,6 +16,7 @@ export { FormDataProvider, FormHook, FormSchema, + UseArray, UseField, UseMultiFields, useForm, @@ -25,6 +26,10 @@ export { ValidationFunc, VALIDATION_TYPES, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -export { Field, SelectField } from '../../../../src/plugins/es_ui_shared/static/forms/components'; +export { + Field, + ToggleField, + SelectField, +} from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; export { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/plugins/osquery/public/types.ts b/x-pack/plugins/osquery/public/types.ts index d1a00db30b733..02b5fc9c7a5d6 100644 --- a/x-pack/plugins/osquery/public/types.ts +++ b/x-pack/plugins/osquery/public/types.ts @@ -9,6 +9,10 @@ import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { FleetStart } from '../../fleet/public'; import { CoreStart } from '../../../../src/core/public'; import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '../../triggers_actions_ui/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OsqueryPluginSetup {} @@ -22,6 +26,11 @@ export interface AppPluginStartDependencies { export interface StartPlugins { data: DataPublicPluginStart; fleet?: FleetStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; +} + +export interface SetupPlugins { + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; } export type StartServices = CoreStart & StartPlugins; diff --git a/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts b/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts new file mode 100644 index 0000000000000..ffe2a772ecb7f --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger, LoggerFactory } from 'src/core/server'; +import { + AgentService, + FleetStartContract, + PackageService, + AgentPolicyServiceInterface, + PackagePolicyServiceInterface, +} from '../../../fleet/server'; +import { ConfigType } from '../config'; + +export type OsqueryAppContextServiceStartContract = Partial< + Pick< + FleetStartContract, + 'agentService' | 'packageService' | 'packagePolicyService' | 'agentPolicyService' + > +> & { + logger: Logger; + config: ConfigType; + registerIngestCallback?: FleetStartContract['registerExternalCallback']; +}; + +/** + * A singleton that holds shared services that are initialized during the start up phase + * of the plugin lifecycle. And stop during the stop phase, if needed. + */ +export class OsqueryAppContextService { + private agentService: AgentService | undefined; + private packageService: PackageService | undefined; + private packagePolicyService: PackagePolicyServiceInterface | undefined; + private agentPolicyService: AgentPolicyServiceInterface | undefined; + + public start(dependencies: OsqueryAppContextServiceStartContract) { + this.agentService = dependencies.agentService; + this.packageService = dependencies.packageService; + this.packagePolicyService = dependencies.packagePolicyService; + this.agentPolicyService = dependencies.agentPolicyService; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public stop() {} + + public getAgentService(): AgentService | undefined { + return this.agentService; + } + + public getPackageService(): PackageService | undefined { + return this.packageService; + } + + public getPackagePolicyService(): PackagePolicyServiceInterface | undefined { + return this.packagePolicyService; + } + + public getAgentPolicyService(): AgentPolicyServiceInterface | undefined { + return this.agentPolicyService; + } +} + +/** + * The context for Osquery app. + */ +export interface OsqueryAppContext { + logFactory: LoggerFactory; + config(): Promise; + + /** + * Object readiness is tied to plugin start method + */ + service: OsqueryAppContextService; +} diff --git a/x-pack/plugins/osquery/server/lib/saved_query/saved_object_mappings.ts b/x-pack/plugins/osquery/server/lib/saved_query/saved_object_mappings.ts new file mode 100644 index 0000000000000..dadcea6e2fd4d --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/saved_query/saved_object_mappings.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsType } from '../../../../../../src/core/server'; + +import { savedQuerySavedObjectType, packSavedObjectType } from '../../../common/types'; + +export const savedQuerySavedObjectMappings: SavedObjectsType['mappings'] = { + properties: { + description: { + type: 'text', + }, + name: { + type: 'text', + }, + query: { + type: 'text', + }, + created: { + type: 'date', + }, + createdBy: { + type: 'text', + }, + platform: { + type: 'keyword', + }, + updated: { + type: 'date', + }, + updatedBy: { + type: 'text', + }, + }, +}; + +export const savedQueryType: SavedObjectsType = { + name: savedQuerySavedObjectType, + hidden: false, + namespaceType: 'single', + mappings: savedQuerySavedObjectMappings, +}; + +export const packSavedObjectMappings: SavedObjectsType['mappings'] = { + properties: { + description: { + type: 'text', + }, + name: { + type: 'text', + }, + created: { + type: 'date', + }, + createdBy: { + type: 'text', + }, + updated: { + type: 'date', + }, + updatedBy: { + type: 'text', + }, + queries: { + properties: { + name: { + type: 'keyword', + }, + interval: { + type: 'text', + }, + }, + }, + }, +}; + +export const packType: SavedObjectsType = { + name: packSavedObjectType, + hidden: false, + namespaceType: 'single', + mappings: packSavedObjectMappings, +}; diff --git a/x-pack/plugins/osquery/server/plugin.ts b/x-pack/plugins/osquery/server/plugin.ts index c30f4ac057ec0..ce6e8d51d9b52 100644 --- a/x-pack/plugins/osquery/server/plugin.ts +++ b/x-pack/plugins/osquery/server/plugin.ts @@ -5,6 +5,8 @@ * 2.0. */ +// import { curry } from 'lodash'; +// import { ActionTypeExecutorResult } from '../../actions/server/types'; import { PluginInitializerContext, CoreSetup, @@ -17,12 +19,18 @@ import { createConfig } from './create_config'; import { OsqueryPluginSetup, OsqueryPluginStart, SetupPlugins, StartPlugins } from './types'; import { defineRoutes } from './routes'; import { osquerySearchStrategyProvider } from './search_strategy/osquery'; +// import { initSavedObjects } from './saved_objects'; +import { OsqueryAppContext, OsqueryAppContextService } from './lib/osquery_app_context_services'; +import { ConfigType } from './config'; export class OsqueryPlugin implements Plugin { private readonly logger: Logger; + private context: PluginInitializerContext; + private readonly osqueryAppContextService = new OsqueryAppContextService(); constructor(private readonly initializerContext: PluginInitializerContext) { - this.logger = this.initializerContext.logger.get(); + this.context = initializerContext; + this.logger = initializerContext.logger.get(); } public setup(core: CoreSetup, plugins: SetupPlugins) { @@ -35,10 +43,23 @@ export class OsqueryPlugin implements Plugin => Promise.resolve(config), + }; - core.getStartServices().then(([_, depsStart]) => { + // initSavedObjects(core.savedObjects); + defineRoutes(router, osqueryContext); + + // plugins.actions.registerType({ + // id: '.osquery', + // name: 'Osquery', + // minimumLicenseRequired: 'gold', + // executor: curry(executor)({}), + // }); + + core.getStartServices().then(([, depsStart]) => { const osquerySearchStrategy = osquerySearchStrategyProvider(depsStart.data); plugins.data.search.registerSearchStrategy('osquerySearchStrategy', osquerySearchStrategy); @@ -47,10 +68,28 @@ export class OsqueryPlugin implements Plugin> { +// return { status: 'ok', data: {}, actionId: execOptions.actionId }; +// } diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts new file mode 100644 index 0000000000000..25212bc3bf5cc --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { find } from 'lodash/fp'; +import uuid from 'uuid'; +import { schema } from '@kbn/config-schema'; +import moment from 'moment'; + +import { IRouter } from '../../../../../../src/core/server'; +import { packSavedObjectType, savedQuerySavedObjectType } from '../../../common/types'; + +export const createActionRoute = (router: IRouter) => { + router.post( + { + path: '/internal/osquery/action', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const esClient = context.core.elasticsearch.client.asInternalUser; + + // @ts-expect-error update validation + if (request.body.pack_id) { + const savedObjectsClient = context.core.savedObjects.client; + const { attributes, references, ...rest } = await savedObjectsClient.get<{ + title: string; + description: string; + queries: Array<{ name: string; interval: string }>; + }>( + packSavedObjectType, + // @ts-expect-error update types + request.body.pack_id + ); + + const pack = { + ...rest, + ...attributes, + queries: + attributes.queries?.map((packQuery) => { + const queryReference = find(['name', packQuery.name], references); + + if (queryReference) { + return { + ...packQuery, + id: queryReference?.id, + }; + } + + return packQuery; + }) ?? [], + }; + + const { saved_objects: queriesSavedObjects } = await savedObjectsClient.bulkGet( + pack.queries.map((packQuery) => ({ + // @ts-expect-error update validation + id: packQuery.id, + type: savedQuerySavedObjectType, + })) + ); + + const actionId = uuid.v4(); + + const actions = queriesSavedObjects.map((query) => ({ + action_id: actionId, + '@timestamp': moment().toISOString(), + expiration: moment().add(2, 'days').toISOString(), + type: 'INPUT_ACTION', + input_type: 'osquery', + // @ts-expect-error update validation + agents: request.body.agents, + data: { + id: query.id, + // @ts-expect-error update validation + query: query.attributes.query, + }, + })); + + const query = await esClient.bulk<{}>({ + index: '.fleet-actions', + // @ts-expect-error update validation + body: actions.reduce((acc, action) => { + return [...acc, { create: { _index: '.fleet-actions' } }, action]; + }, []), + }); + + return response.ok({ + body: { + actions, + query, + }, + }); + } + + const action = { + action_id: uuid.v4(), + '@timestamp': moment().toISOString(), + expiration: moment().add(2, 'days').toISOString(), + type: 'INPUT_ACTION', + input_type: 'osquery', + // @ts-expect-error update validation + agents: request.body.agents, + data: { + // @ts-expect-error update validation + id: request.body.query.id ?? uuid.v4(), + // @ts-expect-error update validation + query: request.body.query.query, + }, + }; + const query = await esClient.index<{}, {}>({ + index: '.fleet-actions', + body: action, + }); + + return response.ok({ + body: { + response: query, + action, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/action/index.ts b/x-pack/plugins/osquery/server/routes/action/index.ts new file mode 100644 index 0000000000000..37e04fac5b986 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/action/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from '../../../../../../src/core/server'; +import { createActionRoute } from './create_action_route'; + +export const initActionRoutes = (router: IRouter) => { + createActionRoute(router); +}; diff --git a/x-pack/plugins/osquery/server/routes/index.ts b/x-pack/plugins/osquery/server/routes/index.ts index a25bf5888b93d..29df227583992 100644 --- a/x-pack/plugins/osquery/server/routes/index.ts +++ b/x-pack/plugins/osquery/server/routes/index.ts @@ -5,47 +5,16 @@ * 2.0. */ -import uuid from 'uuid'; -import { schema } from '@kbn/config-schema'; -import moment from 'moment'; - import { IRouter } from '../../../../../src/core/server'; +import { initSavedQueryRoutes } from './saved_query'; +import { initScheduledQueryRoutes } from './scheduled_query'; +import { initActionRoutes } from './action'; +import { OsqueryAppContext } from '../lib/osquery_app_context_services'; +import { initPackRoutes } from './pack'; -export function defineRoutes(router: IRouter) { - router.post( - { - path: '/api/osquery/queries', - validate: { - params: schema.object({}, { unknowns: 'allow' }), - body: schema.object({}, { unknowns: 'allow' }), - }, - }, - async (context, request, response) => { - const esClient = context.core.elasticsearch.client.asInternalUser; - const query = await esClient.index<{}, {}>({ - index: '.fleet-actions-new', - body: { - action_id: uuid.v4(), - '@timestamp': moment().toISOString(), - expiration: moment().add(2, 'days').toISOString(), - type: 'APP_ACTION', - input_id: 'osquery', - // @ts-expect-error - agents: request.body.agents, - data: { - commands: [ - { - id: uuid.v4(), - // @ts-expect-error - query: request.body.command.query, - }, - ], - }, - }, - }); - return response.ok({ - body: query, - }); - } - ); -} +export const defineRoutes = (router: IRouter, context: OsqueryAppContext) => { + initActionRoutes(router); + initPackRoutes(router); + initSavedQueryRoutes(router); + initScheduledQueryRoutes(router, context); +}; diff --git a/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts new file mode 100644 index 0000000000000..d067ffb0f4c7c --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../../../../../src/core/server'; + +import { packSavedObjectType, savedQuerySavedObjectType } from '../../../common/types'; + +export const createPackRoute = (router: IRouter) => { + router.post( + { + path: '/internal/osquery/pack', + validate: { + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + // @ts-expect-error update types + const { name, description, queries } = request.body; + + // @ts-expect-error update types + const references = queries.map((savedQuery) => ({ + type: savedQuerySavedObjectType, + id: savedQuery.id, + name: savedQuery.name, + })); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { attributes, references: _, ...restSO } = await savedObjectsClient.create( + packSavedObjectType, + { + name, + description, + // @ts-expect-error update types + // eslint-disable-next-line @typescript-eslint/no-unused-vars + queries: queries.map(({ id, query, ...rest }) => rest), + }, + { + references, + } + ); + + return response.ok({ + body: { + ...restSO, + ...attributes, + queries, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/pack/delete_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/delete_pack_route.ts new file mode 100644 index 0000000000000..3e1cf5bfaed02 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/pack/delete_pack_route.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { packSavedObjectType } from '../../../common/types'; + +export const deletePackRoute = (router: IRouter) => { + router.delete( + { + path: '/internal/osquery/pack', + validate: { + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + // @ts-expect-error update types + const { packIds } = request.body; + + await Promise.all( + packIds.map( + // @ts-expect-error update types + async (packId) => + await savedObjectsClient.delete(packSavedObjectType, packId, { + refresh: 'wait_for', + }) + ) + ); + + return response.ok({ + body: packIds, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/pack/find_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/find_pack_route.ts new file mode 100644 index 0000000000000..d4f4adfc24e3e --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/pack/find_pack_route.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { find, map, uniq } from 'lodash/fp'; +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { packSavedObjectType, savedQuerySavedObjectType } from '../../../common/types'; + +export const findPackRoute = (router: IRouter) => { + router.get( + { + path: '/internal/osquery/pack', + validate: { + query: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + const soClientResponse = await savedObjectsClient.find<{ + name: string; + description: string; + queries: Array<{ name: string; interval: string }>; + }>({ + type: packSavedObjectType, + // @ts-expect-error update types + page: parseInt(request.query.pageIndex ?? 0, 10) + 1, + // @ts-expect-error update types + perPage: request.query.pageSize ?? 20, + // @ts-expect-error update types + sortField: request.query.sortField ?? 'updated_at', + // @ts-expect-error update types + sortOrder: request.query.sortDirection ?? 'desc', + }); + + const packs = soClientResponse.saved_objects.map(({ attributes, references, ...rest }) => ({ + ...rest, + ...attributes, + queries: + attributes.queries?.map((packQuery) => { + const queryReference = find(['name', packQuery.name], references); + + if (queryReference) { + return { + ...packQuery, + id: queryReference?.id, + }; + } + + return packQuery; + }) ?? [], + })); + + const savedQueriesIds = uniq( + // @ts-expect-error update types + packs.reduce((acc, savedQuery) => [...acc, ...map('id', savedQuery.queries)], []) + ); + + const { saved_objects: savedQueries } = await savedObjectsClient.bulkGet( + savedQueriesIds.map((queryId) => ({ + type: savedQuerySavedObjectType, + id: queryId, + })) + ); + + const packsWithSavedQueriesQueries = packs.map((pack) => ({ + ...pack, + // @ts-expect-error update types + queries: pack.queries.reduce((acc, packQuery) => { + // @ts-expect-error update types + const savedQuerySO = find(['id', packQuery.id], savedQueries); + + // @ts-expect-error update types + if (savedQuerySO?.attributes?.query) { + return [ + ...acc, + { + ...packQuery, + // @ts-expect-error update types + query: find(['id', packQuery.id], savedQueries).attributes.query, + }, + ]; + } + + return acc; + }, []), + })); + + return response.ok({ + body: { + ...soClientResponse, + saved_objects: packsWithSavedQueriesQueries, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/pack/index.ts b/x-pack/plugins/osquery/server/routes/pack/index.ts new file mode 100644 index 0000000000000..6df7ce6c71f70 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/pack/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from '../../../../../../src/core/server'; + +import { createPackRoute } from './create_pack_route'; +import { deletePackRoute } from './delete_pack_route'; +import { findPackRoute } from './find_pack_route'; +import { readPackRoute } from './read_pack_route'; +import { updatePackRoute } from './update_pack_route'; + +export const initPackRoutes = (router: IRouter) => { + createPackRoute(router); + deletePackRoute(router); + findPackRoute(router); + readPackRoute(router); + updatePackRoute(router); +}; diff --git a/x-pack/plugins/osquery/server/routes/pack/read_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/read_pack_route.ts new file mode 100644 index 0000000000000..82cc44dc39487 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/pack/read_pack_route.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { find, map } from 'lodash/fp'; +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { savedQuerySavedObjectType, packSavedObjectType } from '../../../common/types'; + +export const readPackRoute = (router: IRouter) => { + router.get( + { + path: '/internal/osquery/pack/{id}', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + const { attributes, references, ...rest } = await savedObjectsClient.get<{ + name: string; + description: string; + queries: Array<{ name: string; interval: string }>; + }>( + packSavedObjectType, + // @ts-expect-error update types + request.params.id + ); + + const queries = + attributes.queries?.map((packQuery) => { + const queryReference = find(['name', packQuery.name], references); + + if (queryReference) { + return { + ...packQuery, + id: queryReference?.id, + }; + } + + return packQuery; + }) ?? []; + + const queriesIds = map('id', queries); + + const { saved_objects: savedQueries } = await savedObjectsClient.bulkGet<{}>( + queriesIds.map((queryId) => ({ + type: savedQuerySavedObjectType, + id: queryId, + })) + ); + + // @ts-expect-error update types + const queriesWithQueries = queries.reduce((acc, query) => { + // @ts-expect-error update types + const querySavedObject = find(['id', query.id], savedQueries); + // @ts-expect-error update types + if (querySavedObject?.attributes?.query) { + return [ + ...acc, + { + ...query, + // @ts-expect-error update types + query: querySavedObject.attributes.query, + }, + ]; + } + + return acc; + }, []); + + return response.ok({ + body: { + ...rest, + ...attributes, + queries: queriesWithQueries, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts new file mode 100644 index 0000000000000..edf0cfc2f2f0c --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { packSavedObjectType, savedQuerySavedObjectType } from '../../../common/types'; + +export const updatePackRoute = (router: IRouter) => { + router.put( + { + path: '/internal/osquery/pack/{id}', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + // @ts-expect-error update types + const { name, description, queries } = request.body; + + // @ts-expect-error update types + const updatedReferences = queries.map((savedQuery) => ({ + type: savedQuerySavedObjectType, + id: savedQuery.id, + name: savedQuery.name, + })); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { attributes, references, ...restSO } = await savedObjectsClient.update( + packSavedObjectType, + // @ts-expect-error update types + request.params.id, + { + name, + description, + // @ts-expect-error update types + // eslint-disable-next-line @typescript-eslint/no-unused-vars + queries: queries.map(({ id, query, ...rest }) => rest), + }, + { + references: updatedReferences, + } + ); + + return response.ok({ + body: { + ...restSO, + ...attributes, + // @ts-expect-error update types + // eslint-disable-next-line @typescript-eslint/no-unused-vars + queries: queries.map(({ id, ...rest }) => rest), + }, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/saved_query/create_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/create_saved_query_route.ts new file mode 100644 index 0000000000000..5eb7147d46607 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/saved_query/create_saved_query_route.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from '../../../../../../src/core/server'; + +import { + createSavedQueryRequestSchema, + CreateSavedQueryRequestSchemaDecoded, +} from '../../../common/schemas/routes/saved_query/create_saved_query_request_schema'; +import { savedQuerySavedObjectType } from '../../../common/types'; +import { buildRouteValidation } from '../../utils/build_validation/route_validation'; + +export const createSavedQueryRoute = (router: IRouter) => { + router.post( + { + path: '/internal/osquery/saved_query', + validate: { + body: buildRouteValidation< + typeof createSavedQueryRequestSchema, + CreateSavedQueryRequestSchemaDecoded + >(createSavedQueryRequestSchema), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + const { name, description, platform, query } = request.body; + + const savedQuerySO = await savedObjectsClient.create(savedQuerySavedObjectType, { + name, + description, + query, + platform, + }); + + return response.ok({ + body: savedQuerySO, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/saved_query/delete_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/delete_saved_query_route.ts new file mode 100644 index 0000000000000..5b8e231ba61ec --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/saved_query/delete_saved_query_route.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { savedQuerySavedObjectType } from '../../../common/types'; + +export const deleteSavedQueryRoute = (router: IRouter) => { + router.delete( + { + path: '/internal/osquery/saved_query', + validate: { + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + // @ts-expect-error update types + const { savedQueryIds } = request.body; + + await Promise.all( + savedQueryIds.map( + // @ts-expect-error update types + async (savedQueryId) => + await savedObjectsClient.delete(savedQuerySavedObjectType, savedQueryId, { + refresh: 'wait_for', + }) + ) + ); + + return response.ok({ + body: savedQueryIds, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/saved_query/find_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/find_saved_query_route.ts new file mode 100644 index 0000000000000..6d737ba0d0220 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/saved_query/find_saved_query_route.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { savedQuerySavedObjectType } from '../../../common/types'; + +export const findSavedQueryRoute = (router: IRouter) => { + router.get( + { + path: '/internal/osquery/saved_query', + validate: { + query: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + const savedQueries = await savedObjectsClient.find({ + type: savedQuerySavedObjectType, + // @ts-expect-error update types + page: parseInt(request.query.pageIndex, 10) + 1, + // @ts-expect-error update types + perPage: request.query.pageSize, + // @ts-expect-error update types + sortField: request.query.sortField, + // @ts-expect-error update types + sortOrder: request.query.sortDirection, + }); + + return response.ok({ + body: savedQueries, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/saved_query/index.ts b/x-pack/plugins/osquery/server/routes/saved_query/index.ts new file mode 100644 index 0000000000000..fa905c37387dd --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/saved_query/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from '../../../../../../src/core/server'; + +import { createSavedQueryRoute } from './create_saved_query_route'; +import { deleteSavedQueryRoute } from './delete_saved_query_route'; +import { findSavedQueryRoute } from './find_saved_query_route'; +import { readSavedQueryRoute } from './read_saved_query_route'; +import { updateSavedQueryRoute } from './update_saved_query_route'; + +export const initSavedQueryRoutes = (router: IRouter) => { + createSavedQueryRoute(router); + deleteSavedQueryRoute(router); + findSavedQueryRoute(router); + readSavedQueryRoute(router); + updateSavedQueryRoute(router); +}; diff --git a/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts new file mode 100644 index 0000000000000..8be4c6c50d821 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/saved_query/read_saved_query_route.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { savedQuerySavedObjectType } from '../../../common/types'; + +export const readSavedQueryRoute = (router: IRouter) => { + router.get( + { + path: '/internal/osquery/saved_query/{id}', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + const { attributes, ...savedQuery } = await savedObjectsClient.get( + savedQuerySavedObjectType, + // @ts-expect-error update types + request.params.id + ); + + return response.ok({ + body: { + ...savedQuery, + // @ts-expect-error update types + ...attributes, + }, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts b/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.ts new file mode 100644 index 0000000000000..579cd9b654cc0 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/saved_query/update_saved_query_route.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { savedQuerySavedObjectType } from '../../../common/types'; + +export const updateSavedQueryRoute = (router: IRouter) => { + router.put( + { + path: '/internal/osquery/saved_query/{id}', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + // @ts-expect-error update types + const { name, description, platform, query } = request.body; + + const savedQuerySO = await savedObjectsClient.update( + savedQuerySavedObjectType, + // @ts-expect-error update types + request.params.id, + { + name, + description, + platform, + query, + } + ); + + return response.ok({ + body: savedQuerySO, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/scheduled_query/create_scheduled_query_route.ts b/x-pack/plugins/osquery/server/routes/scheduled_query/create_scheduled_query_route.ts new file mode 100644 index 0000000000000..a3b882392989f --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/scheduled_query/create_scheduled_query_route.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../../../../../src/core/server'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; + +export const createScheduledQueryRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { + router.post( + { + path: '/internal/osquery/scheduled', + validate: { + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const savedObjectsClient = context.core.savedObjects.client; + const packagePolicyService = osqueryContext.service.getPackagePolicyService(); + const integration = await packagePolicyService?.create( + savedObjectsClient, + esClient, + // @ts-expect-error update types + request.body + ); + + return response.ok({ + body: integration, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/scheduled_query/delete_scheduled_query_route.ts b/x-pack/plugins/osquery/server/routes/scheduled_query/delete_scheduled_query_route.ts new file mode 100644 index 0000000000000..5b8e231ba61ec --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/scheduled_query/delete_scheduled_query_route.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { savedQuerySavedObjectType } from '../../../common/types'; + +export const deleteSavedQueryRoute = (router: IRouter) => { + router.delete( + { + path: '/internal/osquery/saved_query', + validate: { + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + // @ts-expect-error update types + const { savedQueryIds } = request.body; + + await Promise.all( + savedQueryIds.map( + // @ts-expect-error update types + async (savedQueryId) => + await savedObjectsClient.delete(savedQuerySavedObjectType, savedQueryId, { + refresh: 'wait_for', + }) + ) + ); + + return response.ok({ + body: savedQueryIds, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/scheduled_query/find_scheduled_query_route.ts b/x-pack/plugins/osquery/server/routes/scheduled_query/find_scheduled_query_route.ts new file mode 100644 index 0000000000000..b9058a2868763 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/scheduled_query/find_scheduled_query_route.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; + +export const findScheduledQueryRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { + router.get( + { + path: '/internal/osquery/scheduled_query', + validate: { + query: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const kuery = 'ingest-package-policies.attributes.package.name: osquery_elastic_managed'; + const packagePolicyService = osqueryContext.service.getPackagePolicyService(); + const policies = await packagePolicyService?.list(context.core.savedObjects.client, { + kuery, + }); + + return response.ok({ + body: policies, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/scheduled_query/index.ts b/x-pack/plugins/osquery/server/routes/scheduled_query/index.ts new file mode 100644 index 0000000000000..706bc38397296 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/scheduled_query/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from '../../../../../../src/core/server'; + +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; +// import { createScheduledQueryRoute } from './create_scheduled_query_route'; +// import { deleteScheduledQueryRoute } from './delete_scheduled_query_route'; +import { findScheduledQueryRoute } from './find_scheduled_query_route'; +import { readScheduledQueryRoute } from './read_scheduled_query_route'; +// import { updateScheduledQueryRoute } from './update_scheduled_query_route'; + +export const initScheduledQueryRoutes = (router: IRouter, context: OsqueryAppContext) => { + // createScheduledQueryRoute(router); + // deleteScheduledQueryRoute(router); + findScheduledQueryRoute(router, context); + readScheduledQueryRoute(router, context); + // updateScheduledQueryRoute(router); +}; diff --git a/x-pack/plugins/osquery/server/routes/scheduled_query/read_scheduled_query_route.ts b/x-pack/plugins/osquery/server/routes/scheduled_query/read_scheduled_query_route.ts new file mode 100644 index 0000000000000..009374f6a2e9e --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/scheduled_query/read_scheduled_query_route.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; + +export const readScheduledQueryRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { + router.get( + { + path: '/internal/osquery/scheduled_query/{id}', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + const packagePolicyService = osqueryContext.service.getPackagePolicyService(); + + // @ts-expect-error update types + const scheduledQuery = await packagePolicyService?.get(savedObjectsClient, request.params.id); + + return response.ok({ + // @ts-expect-error update types + body: scheduledQuery, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/routes/scheduled_query/update_scheduled_query_route.ts b/x-pack/plugins/osquery/server/routes/scheduled_query/update_scheduled_query_route.ts new file mode 100644 index 0000000000000..efb4f2990e942 --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/scheduled_query/update_scheduled_query_route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../../../../../src/core/server'; +import { savedQuerySavedObjectType } from '../../../common/types'; + +export const updateSavedQueryRoute = (router: IRouter) => { + router.put( + { + path: '/internal/osquery/saved_query/{id}', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + + // @ts-expect-error update types + const { name, description, query } = request.body; + + const savedQuerySO = await savedObjectsClient.update( + savedQuerySavedObjectType, + // @ts-expect-error update types + request.params.id, + { + name, + description, + query, + } + ); + + return response.ok({ + body: savedQuerySO, + }); + } + ); +}; diff --git a/x-pack/plugins/osquery/server/saved_objects.ts b/x-pack/plugins/osquery/server/saved_objects.ts new file mode 100644 index 0000000000000..15a0f8e2be0da --- /dev/null +++ b/x-pack/plugins/osquery/server/saved_objects.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup } from '../../../../src/core/server'; + +import { savedQueryType, packType } from './lib/saved_query/saved_object_mappings'; + +const types = [savedQueryType, packType]; + +export const savedObjectTypes = types.map((type) => type.name); + +export const initSavedObjects = (savedObjects: CoreSetup['savedObjects']) => { + types.forEach((type) => savedObjects.registerType(type)); +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts index b4539e06d98b7..63b1b207f02e3 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts @@ -7,26 +7,40 @@ import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; import { AgentsRequestOptions } from '../../../../../../common/search_strategy'; -import { createQueryFilterClauses } from '../../../../../../common/utils/build_query'; +// import { createQueryFilterClauses } from '../../../../../../common/utils/build_query'; export const buildActionsQuery = ({ - docValueFields, + // eslint-disable-next-line @typescript-eslint/no-unused-vars filterQuery, - pagination: { activePage, querySize }, sort, + pagination: { cursorStart, querySize }, }: AgentsRequestOptions): ISearchRequestParams => { - const filter = [...createQueryFilterClauses(filterQuery)]; + // const filter = [...createQueryFilterClauses(filterQuery)]; const dslQuery = { allowNoIndices: true, index: '.fleet-actions', ignoreUnavailable: true, body: { - query: { bool: { filter } }, - from: activePage * querySize, + // query: { bool: { filter } }, + query: { + term: { + type: { + value: 'INPUT_ACTION', + }, + }, + }, + from: cursorStart, size: querySize, track_total_hits: true, fields: ['*'], + sort: [ + { + [sort.field]: { + order: sort.direction, + }, + }, + ], }, }; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts index 3a47c0252456d..bc9d63e619338 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts @@ -11,7 +11,6 @@ import { createQueryFilterClauses } from '../../../../../../common/utils/build_q export const buildActionDetailsQuery = ({ actionId, - docValueFields, filterQuery, }: ActionDetailsRequestOptions): ISearchRequestParams => { const filter = [ diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts index bcf9cd5c269e7..75e6201545a8e 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts @@ -11,10 +11,9 @@ import { createQueryFilterClauses } from '../../../../../../common/utils/build_q export const buildActionResultsQuery = ({ actionId, - docValueFields, filterQuery, - pagination: { activePage, querySize }, sort, + pagination: { activePage, querySize }, }: ActionResultsRequestOptions): ISearchRequestParams => { const filter = [ ...createQueryFilterClauses(filterQuery), @@ -27,14 +26,31 @@ export const buildActionResultsQuery = ({ const dslQuery = { allowNoIndices: true, - index: '.fleet-actions-results', + index: '.fleet-actions-results*', ignoreUnavailable: true, body: { + aggs: { + responses: { + terms: { + script: { + lang: 'painless', + source: "if (doc['error'].size()==0) { return 'success' } else { return 'error' }", + }, + }, + }, + }, query: { bool: { filter } }, from: activePage * querySize, size: querySize, track_total_hits: true, fields: ['*'], + sort: [ + { + [sort.field]: { + order: sort.direction, + }, + }, + ], }, }; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts index a28daa6be0124..4ad6022017966 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts @@ -5,27 +5,40 @@ * 2.0. */ -import { isEmpty } from 'lodash/fp'; import { ISearchRequestParams } from '../../../../../../../../src/plugins/data/common'; import { AgentsRequestOptions } from '../../../../../common/search_strategy'; -import { createQueryFilterClauses } from '../../../../../common/utils/build_query'; +// import { createQueryFilterClauses } from '../../../../../common/utils/build_query'; export const buildAgentsQuery = ({ - docValueFields, + // eslint-disable-next-line @typescript-eslint/no-unused-vars filterQuery, - pagination: { querySize }, + pagination: { cursorStart, querySize }, sort, }: AgentsRequestOptions): ISearchRequestParams => { - const filter = [...createQueryFilterClauses(filterQuery)]; + // const filter = [...createQueryFilterClauses(filterQuery)]; const dslQuery = { allowNoIndices: true, index: '.fleet-agents', ignoreUnavailable: true, body: { - ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), - query: { bool: { filter } }, + query: { + term: { + active: { + value: 'true', + }, + }, + }, track_total_hits: true, + sort: [ + { + [sort.field]: { + order: sort.direction, + }, + }, + ], + size: querySize, + from: cursorStart, }, }; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts index 171a6be102435..04ba05532cd0d 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts @@ -11,9 +11,10 @@ import { createQueryFilterClauses } from '../../../../../common/utils/build_quer export const buildResultsQuery = ({ actionId, + agentId, filterQuery, + // sort, pagination: { activePage, querySize }, - sort, }: ResultsRequestOptions): ISearchRequestParams => { const filter = [ ...createQueryFilterClauses(filterQuery), @@ -22,6 +23,15 @@ export const buildResultsQuery = ({ action_id: actionId, }, }, + ...(agentId + ? [ + { + match_phrase: { + 'agent.id': agentId, + }, + }, + ] + : []), ]; const dslQuery = { @@ -33,7 +43,14 @@ export const buildResultsQuery = ({ from: activePage * querySize, size: querySize, track_total_hits: true, - fields: ['agent.*', 'osquery.*'], + fields: agentId ? ['osquery.*'] : ['agent.*', 'osquery.*'], + // sort: [ + // { + // [sort.field]: { + // order: [sort.direction], + // }, + // }, + // ], }, }; diff --git a/x-pack/plugins/osquery/server/types.ts b/x-pack/plugins/osquery/server/types.ts index badf67e2d7daa..dd9d45b2c3cc6 100644 --- a/x-pack/plugins/osquery/server/types.ts +++ b/x-pack/plugins/osquery/server/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ActionsPlugin } from '../../actions/server'; import { PluginSetup as DataPluginSetup, PluginStart as DataPluginStart, @@ -17,10 +18,12 @@ export interface OsqueryPluginSetup {} export interface OsqueryPluginStart {} export interface SetupPlugins { + actions: ActionsPlugin['setup']; data: DataPluginSetup; } export interface StartPlugins { + actions: ActionsPlugin['start']; data: DataPluginStart; fleet?: FleetStartContract; } diff --git a/x-pack/plugins/osquery/server/utils/build_validation/route_validation.test.ts b/x-pack/plugins/osquery/server/utils/build_validation/route_validation.test.ts new file mode 100644 index 0000000000000..0d5b7ecfa94f0 --- /dev/null +++ b/x-pack/plugins/osquery/server/utils/build_validation/route_validation.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { RouteValidationResultFactory } from 'src/core/server'; + +import { buildRouteValidation, buildRouteValidationWithExcess } from './route_validation'; + +describe('Route Validation with ', () => { + describe('buildRouteValidation', () => { + const schema = rt.exact( + rt.type({ + ids: rt.array(rt.string), + }) + ); + type Schema = rt.TypeOf; + + /** + * If your schema is using exact all the way down then the validation will + * catch any additional keys that should not be present within the validation + * when the route_validation uses the exact check. + */ + const deepSchema = rt.exact( + rt.type({ + topLevel: rt.exact( + rt.type({ + secondLevel: rt.exact( + rt.type({ + thirdLevel: rt.string, + }) + ), + }) + ), + }) + ); + type DeepSchema = rt.TypeOf; + + const validationResult: RouteValidationResultFactory = { + ok: jest.fn().mockImplementation((validatedInput) => validatedInput), + badRequest: jest.fn().mockImplementation((e) => e), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('return validation error', () => { + const input: Omit & { id: string } = { id: 'someId' }; + const result = buildRouteValidation(schema)(input, validationResult); + + expect(result).toEqual('Invalid value "undefined" supplied to "ids"'); + }); + + test('return validated input', () => { + const input: Schema = { ids: ['someId'] }; + const result = buildRouteValidation(schema)(input, validationResult); + + expect(result).toEqual(input); + }); + + test('returns validation error if given extra keys on input for an array', () => { + const input: Schema & { somethingExtra: string } = { + ids: ['someId'], + somethingExtra: 'hello', + }; + const result = buildRouteValidation(schema)(input, validationResult); + expect(result).toEqual('invalid keys "somethingExtra"'); + }); + + test('return validation input for a deep 3rd level object', () => { + const input: DeepSchema = { topLevel: { secondLevel: { thirdLevel: 'hello' } } }; + const result = buildRouteValidation(deepSchema)(input, validationResult); + expect(result).toEqual(input); + }); + + test('return validation error for a deep 3rd level object that has an extra key value of "somethingElse"', () => { + const input: DeepSchema & { + topLevel: { secondLevel: { thirdLevel: string; somethingElse: string } }; + } = { + topLevel: { secondLevel: { thirdLevel: 'hello', somethingElse: 'extraKey' } }, + }; + const result = buildRouteValidation(deepSchema)(input, validationResult); + expect(result).toEqual('invalid keys "somethingElse"'); + }); + }); + + describe('buildRouteValidationwithExcess', () => { + const schema = rt.type({ + ids: rt.array(rt.string), + }); + type Schema = rt.TypeOf; + + /** + * If your schema is using exact all the way down then the validation will + * catch any additional keys that should not be present within the validation + * when the route_validation uses the exact check. + */ + const deepSchema = rt.type({ + topLevel: rt.type({ + secondLevel: rt.type({ + thirdLevel: rt.string, + }), + }), + }); + type DeepSchema = rt.TypeOf; + + const validationResult: RouteValidationResultFactory = { + ok: jest.fn().mockImplementation((validatedInput) => validatedInput), + badRequest: jest.fn().mockImplementation((e) => e), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('return validation error', () => { + const input: Omit & { id: string } = { id: 'someId' }; + const result = buildRouteValidationWithExcess(schema)(input, validationResult); + + expect(result).toEqual('Invalid value {"id":"someId"}, excess properties: ["id"]'); + }); + + test('return validation error with intersection', () => { + const schemaI = rt.intersection([ + rt.type({ + ids: rt.array(rt.string), + }), + rt.partial({ + valid: rt.array(rt.string), + }), + ]); + type SchemaI = rt.TypeOf; + const input: Omit & { id: string } = { id: 'someId', valid: ['yes'] }; + const result = buildRouteValidationWithExcess(schemaI)(input, validationResult); + + expect(result).toEqual( + 'Invalid value {"id":"someId","valid":["yes"]}, excess properties: ["id"]' + ); + }); + + test('return NO validation error with a partial intersection', () => { + const schemaI = rt.intersection([ + rt.type({ + id: rt.array(rt.string), + }), + rt.partial({ + valid: rt.array(rt.string), + }), + ]); + const input = { id: ['someId'] }; + const result = buildRouteValidationWithExcess(schemaI)(input, validationResult); + + expect(result).toEqual({ id: ['someId'] }); + }); + + test('return validated input', () => { + const input: Schema = { ids: ['someId'] }; + const result = buildRouteValidationWithExcess(schema)(input, validationResult); + + expect(result).toEqual(input); + }); + + test('returns validation error if given extra keys on input for an array', () => { + const input: Schema & { somethingExtra: string } = { + ids: ['someId'], + somethingExtra: 'hello', + }; + const result = buildRouteValidationWithExcess(schema)(input, validationResult); + expect(result).toEqual( + 'Invalid value {"ids":["someId"],"somethingExtra":"hello"}, excess properties: ["somethingExtra"]' + ); + }); + + test('return validation input for a deep 3rd level object', () => { + const input: DeepSchema = { topLevel: { secondLevel: { thirdLevel: 'hello' } } }; + const result = buildRouteValidationWithExcess(deepSchema)(input, validationResult); + expect(result).toEqual(input); + }); + + test('return validation error for a deep 3rd level object that has an extra key value of "somethingElse"', () => { + const input: DeepSchema & { + topLevel: { secondLevel: { thirdLevel: string; somethingElse: string } }; + } = { + topLevel: { secondLevel: { thirdLevel: 'hello', somethingElse: 'extraKey' } }, + }; + const result = buildRouteValidationWithExcess(deepSchema)(input, validationResult); + expect(result).toEqual( + 'Invalid value {"topLevel":{"secondLevel":{"thirdLevel":"hello","somethingElse":"extraKey"}}}, excess properties: ["somethingElse"]' + ); + }); + }); +}); diff --git a/x-pack/plugins/osquery/server/utils/build_validation/route_validation.ts b/x-pack/plugins/osquery/server/utils/build_validation/route_validation.ts new file mode 100644 index 0000000000000..caba0eb9f0152 --- /dev/null +++ b/x-pack/plugins/osquery/server/utils/build_validation/route_validation.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { formatErrors } from '../../../common/format_errors'; +import { exactCheck } from '../../../common/exact_check'; +import { + RouteValidationFunction, + RouteValidationResultFactory, + RouteValidationError, +} from '../../../../../../src/core/server'; +import { excess, GenericIntersectionC } from '../runtime_types'; + +type RequestValidationResult = + | { + value: T; + error?: undefined; + } + | { + value?: undefined; + error: RouteValidationError; + }; + +export const buildRouteValidation = >( + schema: T +): RouteValidationFunction
=> ( + inputValue: unknown, + validationResult: RouteValidationResultFactory +) => + pipe( + schema.decode(inputValue), + (decoded) => exactCheck(inputValue, decoded), + fold>( + (errors: rt.Errors) => validationResult.badRequest(formatErrors(errors).join()), + (validatedInput: A) => validationResult.ok(validatedInput) + ) + ); + +export const buildRouteValidationWithExcess = < + T extends rt.InterfaceType | GenericIntersectionC | rt.PartialType, + A = rt.TypeOf +>( + schema: T +): RouteValidationFunction => ( + inputValue: unknown, + validationResult: RouteValidationResultFactory +) => + pipe( + excess(schema).decode(inputValue), + fold>( + (errors: rt.Errors) => validationResult.badRequest(formatErrors(errors).join()), + (validatedInput: A) => validationResult.ok(validatedInput) + ) + ); diff --git a/x-pack/plugins/osquery/server/utils/runtime_types.ts b/x-pack/plugins/osquery/server/utils/runtime_types.ts new file mode 100644 index 0000000000000..08e6345e0751f --- /dev/null +++ b/x-pack/plugins/osquery/server/utils/runtime_types.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { either, fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { failure } from 'io-ts/lib/PathReporter'; +import get from 'lodash/get'; + +type ErrorFactory = (message: string) => Error; + +export type GenericIntersectionC = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | rt.IntersectionC<[any, any]> + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | rt.IntersectionC<[any, any, any]> + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | rt.IntersectionC<[any, any, any, any]> + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | rt.IntersectionC<[any, any, any, any, any]>; + +export const createPlainError = (message: string) => new Error(message); + +export const throwErrors = (createError: ErrorFactory) => (errors: rt.Errors) => { + throw createError(failure(errors).join('\n')); +}; + +export const decodeOrThrow = ( + runtimeType: rt.Type, + createError: ErrorFactory = createPlainError +) => (inputValue: I) => + pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); + +const getProps = ( + codec: + | rt.HasProps + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | rt.RecordC + | GenericIntersectionC +): rt.Props | null => { + if (codec == null) { + return null; + } + switch (codec._tag) { + case 'DictionaryType': { + if (codec.codomain.props != null) { + return codec.codomain.props; + } + const dTypes: rt.HasProps[] = codec.codomain.types; + return dTypes.reduce((props, type) => Object.assign(props, getProps(type)), {}); + } + case 'RefinementType': + case 'ReadonlyType': + return getProps(codec.type); + case 'InterfaceType': + case 'StrictType': + case 'PartialType': + return codec.props; + case 'IntersectionType': { + const iTypes = codec.types as rt.HasProps[]; + return iTypes.reduce((props, type) => { + return Object.assign(props, getProps(type) as rt.Props); + }, {} as rt.Props) as rt.Props; + } + default: + return null; + } +}; + +const getExcessProps = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props: rt.Props | rt.RecordC, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + r: any +): string[] => { + return Object.keys(r).reduce((acc, k) => { + const codecChildren = get(props, [k]); + const childrenProps = getProps(codecChildren); + const childrenObject = r[k] as Record; + if (codecChildren != null && childrenProps != null && codecChildren._tag === 'DictionaryType') { + const keys = Object.keys(childrenObject); + return [ + ...acc, + ...keys.reduce( + (kAcc, i) => [...kAcc, ...getExcessProps(childrenProps, childrenObject[i])], + [] + ), + ]; + } + if (codecChildren != null && childrenProps != null) { + return [...acc, ...getExcessProps(childrenProps, childrenObject)]; + } else if (codecChildren == null) { + return [...acc, k]; + } + return acc; + }, []); +}; + +export const excess = < + C extends rt.InterfaceType | GenericIntersectionC | rt.PartialType +>( + codec: C +): C => { + const codecProps = getProps(codec); + + const r = new rt.InterfaceType( + codec.name, + codec.is, + (i, c) => + either.chain(rt.UnknownRecord.validate(i, c), (s) => { + if (codecProps == null) { + return rt.failure(i, c, 'unknown codec'); + } + const ex = getExcessProps(codecProps, s); + + return ex.length > 0 + ? rt.failure( + i, + c, + `Invalid value ${JSON.stringify(i)}, excess properties: ${JSON.stringify(ex)}` + ) + : codec.validate(i, c); + }), + codec.encode, + codecProps + ); + return r as C; +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1e2fea7ef5d59..37020fb18b4cc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16796,7 +16796,6 @@ "xpack.osquery.actions.failSearchDescription": "アクションを取得できませんでした", "xpack.osquery.agents.errorSearchDescription": "すべてのエージェント検索でエラーが発生しました", "xpack.osquery.agents.failSearchDescription": "エージェントを取得できませんでした", - "xpack.osquery.helloWorldText": "{name}", "xpack.osquery.results.errorSearchDescription": "すべての結果検索でエラーが発生しました", "xpack.osquery.results.failSearchDescription": "結果を取得できませんでした", "xpack.painlessLab.apiReferenceButtonLabel": "API リファレンス", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5a1beb4560765..f89755355d19f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17019,7 +17019,6 @@ "xpack.osquery.actions.failSearchDescription": "无法获取操作", "xpack.osquery.agents.errorSearchDescription": "搜索所有代理时发生错误", "xpack.osquery.agents.failSearchDescription": "无法获取代理", - "xpack.osquery.helloWorldText": "{name}", "xpack.osquery.results.errorSearchDescription": "搜索所有结果时发生错误", "xpack.osquery.results.failSearchDescription": "无法获取结果", "xpack.painlessLab.apiReferenceButtonLabel": "API 参考", diff --git a/yarn.lock b/yarn.lock index cd52f84dec463..8c40d95136f11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8571,6 +8571,19 @@ broadcast-channel@^3.0.3: rimraf "3.0.0" unload "2.2.0" +broadcast-channel@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.4.1.tgz#65b63068d0a5216026a19905c9b2d5e9adf0928a" + integrity sha512-VXYivSkuBeQY+pL5hNQQNvBdKKQINBAROm4G8lAbWQfOZ7Yn4TMcgLNlJyEqlkxy5G8JJBsI3VJ1u8FUTOROcg== + dependencies: + "@babel/runtime" "^7.7.2" + detect-node "^2.0.4" + js-sha3 "0.8.0" + microseconds "0.2.0" + nano-time "1.0.0" + rimraf "3.0.2" + unload "2.2.0" + brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" @@ -19540,6 +19553,14 @@ markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" +match-sorter@^6.0.2: + version "6.1.0" + resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.1.0.tgz#7fec6808d94311a35fef7fd842a11634f2361bd7" + integrity sha512-sKPMf4kbF7Dm5Crx0bbfLpokK68PUJ/0STUIOPa1ZmTZEA3lCaPK3gapQR573oLmvdkTfGojzySkIwuq6Z6xRQ== + dependencies: + "@babel/runtime" "^7.12.5" + remove-accents "0.4.2" + matchdep@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/matchdep/-/matchdep-2.0.0.tgz#c6f34834a0d8dbc3b37c27ee8bbcb27c7775582e" @@ -19906,6 +19927,11 @@ microseconds@0.1.0: resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.1.0.tgz#47dc7bcf62171b8030e2152fd82f12a6894a7119" integrity sha1-R9x7z2IXG4Aw4hUv2C8SpolKcRk= +microseconds@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.2.0.tgz#233b25f50c62a65d861f978a4a4f8ec18797dc39" + integrity sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA== + miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" @@ -23506,6 +23532,15 @@ react-popper@^1.3.7: typed-styles "^0.0.7" warning "^4.0.2" +react-query@^3.12.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.12.0.tgz#a2082a167f3e394e84dfd3cec0f8c7503abf33dc" + integrity sha512-WJYECeZ6xT2oxIlgqXUjLNLWRvJbeelXscVnAFfyUFgO21OYEYHMWPG61V9W57EUUqrXioQsNPsU9XyddfEvXQ== + dependencies: + "@babel/runtime" "^7.5.5" + broadcast-channel "^3.4.1" + match-sorter "^6.0.2" + react-redux@^7.1.0, react-redux@^7.1.1, react-redux@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.0.tgz#f970f62192b3981642fec46fd0db18a074fe879d" @@ -24480,6 +24515,11 @@ remedial@^1.0.7: resolved "https://registry.yarnpkg.com/remedial/-/remedial-1.0.8.tgz#a5e4fd52a0e4956adbaf62da63a5a46a78c578a0" integrity sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg== +remove-accents@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" + integrity sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U= + remove-bom-buffer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz#c2bf1e377520d324f623892e33c10cac2c252b53" @@ -24876,6 +24916,13 @@ rimraf@3.0.0: dependencies: glob "^7.1.3" +rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2, rimraf@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + rimraf@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" @@ -24883,13 +24930,6 @@ rimraf@^2.7.1: dependencies: glob "^7.1.3" -rimraf@^3.0.0, rimraf@^3.0.2, rimraf@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - rimraf@~2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.0.3.tgz#f50a2965e7144e9afd998982f15df706730f56a9"