From b65ec4e07d0c47e86f930e9e72f5c3fd030aac46 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Tue, 28 Jul 2020 19:44:30 -0400 Subject: [PATCH 01/10] Get branch name from platform vs disk (#73534) https://github.com/elastic/kibana/blob/fa93a81ba67f5177024f1ab3b4ac68919a7824dc/src/core/server/plugins/types.ts#L280 & https://github.com/elastic/kibana/blob/27dbcb27964353619f686066c6ba8f25954a0881/src/core/server/config/types.ts#L25 Co-authored-by: Elastic Machine --- x-pack/plugins/ingest_manager/server/plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 5664a87501016..e7495df254a09 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -15,7 +15,6 @@ import { HttpServiceSetup, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import packageJSON from '../../../../package.json'; import { LicensingPluginSetup, ILicense } from '../../licensing/server'; import { EncryptedSavedObjectsPluginStart, @@ -155,7 +154,7 @@ export class IngestManagerPlugin this.config$ = this.initializerContext.config.create(); this.isProductionMode = this.initializerContext.env.mode.prod; this.kibanaVersion = this.initializerContext.env.packageInfo.version; - this.kibanaBranch = packageJSON.branch; + this.kibanaBranch = this.initializerContext.env.packageInfo.branch; this.logger = this.initializerContext.logger.get(); } From b399fb03d1677bba0cd61cb5de4e0957fa1910fc Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 28 Jul 2020 17:47:41 -0600 Subject: [PATCH 02/10] [SIEM][Detection Engine][Lists] Adds the ability to change the timeout limits from 10 seconds for loads for imports (#73103) ## Summary By default the upload time limit for payloads is 10 seconds. This is really too short and we were getting internal QA bug reports that uploads are timing out on large value list importing. This PR adds the plumbing and unit tests to make the timeout configurable for routes. * Adds a single timeout option for routes and then normalizes that through Hapi for the socket, payload, and server timeouts. * Adds unit tests which test the various options * Adds integration tests which test the various options * Adds some NOTES about where there are odd behaviors/bugs within Hapi around validations and the timeouts * Adds a configurable 5 minute timeout to the large value lists route **Manual testing of the feature** You can manually test this by adding a configurable option to your chrome network throttle like so below where you throttle upload by some configurable amount. I chose to use 300 kbs/s upload Screen Shot 2020-07-23 at 11 26 01 AM And then run an import of large value lists using a large enough file that it will exceed 5 minutes: ![screen-shot-upload](https://user-images.githubusercontent.com/1151048/88318584-28ef6000-ccd8-11ea-90a1-8ca4aafabcb4.png) After 5 minutes you should see this message within your server side messages if you have configured your kibana.dev.yml to allow for these messages: ```ts server respons [10:52:31.377] [access:lists-all] POST /api/lists/items/_import?type=keyword 408 318292ms - 9.0B ``` Note that it should show you that it is trying to return a `408` after `318292ms` the timeout period. Sometimes you will get the 408 in the browser and sometimes the browser actually will not respect the 408 and continue staying in a pending state forever. This seems to be browser side issue and not a client/user land issue. If you get the browser message it will be this error toaster ![timeout-message](https://user-images.githubusercontent.com/1151048/88318760-74a20980-ccd8-11ea-9b7b-0d27f8eb6bce.png) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [x] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) --- ...a-plugin-core-server.routeconfigoptions.md | 1 + ...-core-server.routeconfigoptions.timeout.md | 13 ++ src/core/server/http/http_server.test.ts | 127 ++++++++++++++++++ src/core/server/http/http_server.ts | 22 ++- .../http/integration_tests/router.test.ts | 124 +++++++++++++++++ src/core/server/http/router/request.ts | 2 + src/core/server/http/router/route.ts | 6 + src/core/server/server.api.md | 1 + x-pack/plugins/lists/common/constants.mock.ts | 4 +- x-pack/plugins/lists/server/config.mock.ts | 2 + x-pack/plugins/lists/server/config.test.ts | 22 +++ x-pack/plugins/lists/server/config.ts | 10 ++ .../server/routes/import_list_item_route.ts | 1 + .../server/services/lists/list_client.mock.ts | 2 + 14 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md index 5a6103dfc57d4..fee6124f8d866 100644 --- a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md @@ -19,5 +19,6 @@ export interface RouteConfigOptions | [authRequired](./kibana-plugin-core-server.routeconfigoptions.authrequired.md) | boolean | 'optional' | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.Defaults to true if an auth mechanism is registered. | | [body](./kibana-plugin-core-server.routeconfigoptions.body.md) | Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody | Additional body options [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md). | | [tags](./kibana-plugin-core-server.routeconfigoptions.tags.md) | readonly string[] | Additional metadata tag strings to attach to the route. | +| [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) | number | Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes | | [xsrfRequired](./kibana-plugin-core-server.routeconfigoptions.xsrfrequired.md) | Method extends 'get' ? never : boolean | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain kbn-xsrf header. - false. Disables xsrf protection.Set to true by default | diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md new file mode 100644 index 0000000000000..479fcf883ec4d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) > [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) + +## RouteConfigOptions.timeout property + +Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes + +Signature: + +```typescript +timeout?: number; +``` diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 601eba835a54e..007d75a69b955 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -992,6 +992,133 @@ describe('body options', () => { }); }); +describe('timeout options', () => { + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a POST', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.post( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a GET', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.get( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).get('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a DELETE', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.delete( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).delete('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PUT', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.put( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).put('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PATCH', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.patch( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).patch('/').expect(200, { + timeout: 300000, + }); + }); +}); + test('should return a stream in the body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 9c16162d69334..4b70f58deba99 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -161,8 +161,10 @@ export class HttpServer { this.log.debug(`registering route handler for [${route.path}]`); // Hapi does not allow payload validation to be specified for 'head' or 'get' requests const validate = isSafeMethod(route.method) ? undefined : { payload: true }; - const { authRequired, tags, body = {} } = route.options; + const { authRequired, tags, body = {}, timeout } = route.options; const { accepts: allow, maxBytes, output, parse } = body; + // Hapi does not allow timeouts on payloads to be specified for 'head' or 'get' requests + const payloadTimeout = isSafeMethod(route.method) || timeout == null ? undefined : timeout; const kibanaRouteState: KibanaRouteState = { xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), @@ -181,9 +183,23 @@ export class HttpServer { // validation applied in ./http_tools#getServerOptions // (All NP routes are already required to specify their own validation in order to access the payload) validate, - payload: [allow, maxBytes, output, parse].some((v) => typeof v !== 'undefined') - ? { allow, maxBytes, output, parse } + payload: [allow, maxBytes, output, parse, payloadTimeout].some( + (v) => typeof v !== 'undefined' + ) + ? { + allow, + maxBytes, + output, + parse, + timeout: payloadTimeout, + } : undefined, + timeout: + timeout != null + ? { + socket: timeout + 1, // Hapi server requires the socket to be greater than payload settings so we add 1 millisecond + } + : undefined, }, }); } diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index bb36fefa96611..434e22e3cf6f5 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -302,6 +302,130 @@ describe('Options', () => { }); }); }); + + describe('timeout', () => { + it('should timeout if configured with a small timeout value for a POST', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.post({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).post('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a PUT', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.put( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.put({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + + expect(supertest(innerServer.listener).put('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).put('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a DELETE', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.delete( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.delete({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + expect(supertest(innerServer.listener).delete('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).delete('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a GET', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + // Note: There is a bug within Hapi Server where it cannot set the payload timeout for a GET call but it also cannot configure a timeout less than the payload body + // so the least amount of possible time to configure the timeout is 10 seconds. + { path: '/a', validate: false, options: { timeout: 100000 } }, + async (context, req, res) => { + // Cause a wait of 20 seconds to cause the socket hangup + await new Promise((resolve) => setTimeout(resolve, 200000)); + return res.ok({}); + } + ); + router.get({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + + expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).get('/b').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a POST', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).post('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a PUT', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.put( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + + await supertest(innerServer.listener).put('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a DELETE', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.delete( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).delete('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a GET', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).get('/a').expect(200, {}); + }); + }); }); describe('Cache-Control', () => { diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index fefd75ad9710e..0e73431fe7c6d 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -197,12 +197,14 @@ export class KibanaRequest< private getRouteInfo(request: Request): KibanaRequestRoute { const method = request.method as Method; const { parse, maxBytes, allow, output } = request.route.settings.payload || {}; + const timeout = request.route.settings.timeout?.socket; const options = ({ authRequired: this.getAuthRequired(request), // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8 xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true, tags: request.route.settings.tags || [], + timeout: typeof timeout === 'number' ? timeout - 1 : undefined, // We are forced to have the timeout be 1 millisecond greater than the server and payload so we subtract one here to give the user consist settings body: isSafeMethod(method) ? undefined : { diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 9789d266587af..676c494bec522 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -144,6 +144,12 @@ export interface RouteConfigOptions { * Additional body options {@link RouteConfigOptionsBody}. */ body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; + + /** + * Timeouts for processing durations. Response timeout is in milliseconds. + * Default value: 2 minutes + */ + timeout?: number; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index bb4f2f30ac18f..c94151f8cee17 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1859,6 +1859,7 @@ export interface RouteConfigOptions { authRequired?: boolean | 'optional'; body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; tags?: readonly string[]; + timeout?: number; xsrfRequired?: Method extends 'get' ? never : boolean; } diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 22706890e2020..b7609b5a3602a 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -3,8 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EntriesArray } from './schemas/types'; +import moment from 'moment'; +import { EntriesArray } from './schemas/types'; export const DATE_NOW = '2020-04-20T15:25:31.830Z'; export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z'; export const USER = 'some user'; @@ -64,3 +65,4 @@ export const CURSOR = 'c29tZXN0cmluZ2ZvcnlvdQ=='; export const _VERSION = 'WzI5NywxXQ=='; export const VERSION = 1; export const IMMUTABLE = false; +export const IMPORT_TIMEOUT = moment.duration(5, 'minutes'); diff --git a/x-pack/plugins/lists/server/config.mock.ts b/x-pack/plugins/lists/server/config.mock.ts index 3cf5040c73675..b272f18c4e809 100644 --- a/x-pack/plugins/lists/server/config.mock.ts +++ b/x-pack/plugins/lists/server/config.mock.ts @@ -6,6 +6,7 @@ import { IMPORT_BUFFER_SIZE, + IMPORT_TIMEOUT, LIST_INDEX, LIST_ITEM_INDEX, MAX_IMPORT_PAYLOAD_BYTES, @@ -21,6 +22,7 @@ export const getConfigMock = (): Partial => ({ export const getConfigMockDecoded = (): ConfigType => ({ enabled: true, importBufferSize: IMPORT_BUFFER_SIZE, + importTimeout: IMPORT_TIMEOUT, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES, diff --git a/x-pack/plugins/lists/server/config.test.ts b/x-pack/plugins/lists/server/config.test.ts index 60501322dcfa2..40b04edd4c007 100644 --- a/x-pack/plugins/lists/server/config.test.ts +++ b/x-pack/plugins/lists/server/config.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; + import { ConfigSchema, ConfigType } from './config'; import { getConfigMock, getConfigMockDecoded } from './config.mock'; @@ -61,4 +63,24 @@ describe('config_schema', () => { '[importBufferSize]: Value must be equal to or greater than [1].' ); }); + + test('it throws if the "importTimeout" value is less than 2 minutes', () => { + const mock: ConfigType = { + ...getConfigMockDecoded(), + importTimeout: moment.duration(2, 'minutes').subtract(1, 'second'), + }; + expect(() => ConfigSchema.validate(mock)).toThrow( + '[importTimeout]: duration cannot be less than 2 minutes' + ); + }); + + test('it throws if the "importTimeout" value is greater than 1 hour', () => { + const mock: ConfigType = { + ...getConfigMockDecoded(), + importTimeout: moment.duration(1, 'hour').add(1, 'second'), + }; + expect(() => ConfigSchema.validate(mock)).toThrow( + '[importTimeout]: duration cannot be greater than 30 minutes' + ); + }); }); diff --git a/x-pack/plugins/lists/server/config.ts b/x-pack/plugins/lists/server/config.ts index 394f85ecfb642..6e36d135abfcc 100644 --- a/x-pack/plugins/lists/server/config.ts +++ b/x-pack/plugins/lists/server/config.ts @@ -9,6 +9,16 @@ import { TypeOf, schema } from '@kbn/config-schema'; export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), importBufferSize: schema.number({ defaultValue: 1000, min: 1 }), + importTimeout: schema.duration({ + defaultValue: '5m', + validate: (value) => { + if (value.asMinutes() < 2) { + throw new Error('duration cannot be less than 2 minutes'); + } else if (value.asMinutes() > 30) { + throw new Error('duration cannot be greater than 30 minutes'); + } + }, + }), listIndex: schema.string({ defaultValue: '.lists' }), listItemIndex: schema.string({ defaultValue: '.items' }), maxImportPayloadBytes: schema.number({ defaultValue: 9000000, min: 1 }), diff --git a/x-pack/plugins/lists/server/routes/import_list_item_route.ts b/x-pack/plugins/lists/server/routes/import_list_item_route.ts index 1003a0c52a794..e162e7829e456 100644 --- a/x-pack/plugins/lists/server/routes/import_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/import_list_item_route.ts @@ -27,6 +27,7 @@ export const importListItemRoute = (router: IRouter, config: ConfigType): void = parse: false, }, tags: ['access:lists-all'], + timeout: config.importTimeout.asMilliseconds(), }, path: `${LIST_ITEM_URL}/_import`, validate: { diff --git a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts index e5036d561ddc6..a2959a024f292 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts @@ -11,6 +11,7 @@ import { getListResponseMock } from '../../../common/schemas/response/list_schem import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { IMPORT_BUFFER_SIZE, + IMPORT_TIMEOUT, LIST_INDEX, LIST_ITEM_INDEX, MAX_IMPORT_PAYLOAD_BYTES, @@ -65,6 +66,7 @@ export const getListClientMock = (): ListClient => { config: { enabled: true, importBufferSize: IMPORT_BUFFER_SIZE, + importTimeout: IMPORT_TIMEOUT, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES, From 89a392bd7cc1e6010c7518abcf948232ce855b79 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Tue, 28 Jul 2020 19:55:13 -0400 Subject: [PATCH 03/10] [Ingest Manager] API sends 404 when package config id is missing (#73212) * Add test to confirm missing config responds w/ 404 Currently failing with a 500 as in https://github.com/elastic/kibana/issues/66388 * Use after() to remove items added by test. The test initally failed with a 500 when the `after` was added. Debugging narrowed it down to a missing default config. getDefaultAgentConfigId errors if there isn't a default config. The config is added by `setupIngestManager` which _was_ always called during plugin#start but is no longer. We could add the setup call to the test/suite, but instead I changed AgentConfigService.delete to use ensureDefaultAgentConfig instead of getDefaultAgentConfigId. ensureDefaultAgentConfig adds one if it's missing. The check in delete is to make sure we don't delete the default config. We can still do that and now we add a config if it wasn't already there (which seems like A Good Thing) * Fix package config path in OpenApi spec * Return 404 if package config id is invalid/missing * Change test for error displayed text Co-authored-by: Elastic Machine --- .../common/openapi/spec_oas3.json | 4 +- .../server/routes/package_config/handlers.ts | 23 +++-- .../server/services/agent_config.ts | 2 +- .../apis/index.js | 1 + .../apis/package_config/get.ts | 89 +++++++++++++++++++ .../apps/endpoint/policy_details.ts | 2 +- 6 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 x-pack/test/ingest_manager_api_integration/apis/package_config/get.ts diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index e16edac5ddb7a..cfae2c450c824 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -922,7 +922,7 @@ }, "parameters": [] }, - "/packageConfigs": { + "/package_configs": { "get": { "summary": "PackageConfigs - List", "tags": [], @@ -1237,7 +1237,7 @@ ] } }, - "/packageConfigs/{packageConfigId}": { + "/package_configs/{packageConfigId}": { "get": { "summary": "PackageConfigs - Info", "tags": [], diff --git a/x-pack/plugins/ingest_manager/server/routes/package_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/package_config/handlers.ts index 6b0c2fe9c2ff7..d2820cdbeb6c8 100644 --- a/x-pack/plugins/ingest_manager/server/routes/package_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/package_config/handlers.ts @@ -5,7 +5,7 @@ */ import { TypeOf } from '@kbn/config-schema'; import Boom from 'boom'; -import { RequestHandler } from 'src/core/server'; +import { RequestHandler, SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; import { appContextService, packageConfigService } from '../../services'; import { getPackageInfo } from '../../services/epm/packages'; import { @@ -49,8 +49,12 @@ export const getOnePackageConfigHandler: RequestHandler> = async (context, request, response) => { const soClient = context.core.savedObjects.client; + const { packageConfigId } = request.params; + const notFoundResponse = () => + response.notFound({ body: { message: `Package config ${packageConfigId} not found` } }); + try { - const packageConfig = await packageConfigService.get(soClient, request.params.packageConfigId); + const packageConfig = await packageConfigService.get(soClient, packageConfigId); if (packageConfig) { return response.ok({ body: { @@ -58,17 +62,18 @@ export const getOnePackageConfigHandler: RequestHandler {} here + // because `this` has to point to the Mocha context + // see https://mochajs.org/#arrow-functions + + describe('Package Config - get by id', async function () { + skipIfNoDockerRegistry(providerContext); + let agentConfigId: string; + let packageConfigId: string; + + before(async function () { + if (!server.enabled) { + return; + } + const { body: agentConfigResponse } = await supertest + .post(`/api/ingest_manager/agent_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test config', + namespace: 'default', + }); + agentConfigId = agentConfigResponse.item.id; + + const { body: packageConfigResponse } = await supertest + .post(`/api/ingest_manager/package_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }); + packageConfigId = packageConfigResponse.item.id; + }); + + after(async function () { + if (!server.enabled) { + return; + } + + await supertest + .post(`/api/ingest_manager/agent_configs/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ agentConfigId }) + .expect(200); + + await supertest + .post(`/api/ingest_manager/package_configs/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packageConfigIds: [packageConfigId] }) + .expect(200); + }); + + it('should succeed with a valid id', async function () { + const { body: apiResponse } = await supertest + .get(`/api/ingest_manager/package_configs/${packageConfigId}`) + .expect(200); + + expect(apiResponse.success).to.be(true); + }); + + it('should return a 404 with an invalid id', async function () { + await supertest.get(`/api/ingest_manager/package_configs/IS_NOT_PRESENT`).expect(404); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index cf76f297d83be..8b197a6c69a30 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -27,7 +27,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.policy.navigateToPolicyDetails('invalid-id'); await testSubjects.existOrFail('policyDetailsIdNotFoundMessage'); expect(await testSubjects.getVisibleText('policyDetailsIdNotFoundMessage')).to.equal( - 'Saved object [ingest-package-configs/invalid-id] not found' + 'Package config invalid-id not found' ); }); }); From 14b2cbb15575be3d488ce36dac25244629b180a6 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Tue, 28 Jul 2020 19:58:26 -0400 Subject: [PATCH 04/10] [Security Solution][Resolver] Handle iso time strings (#73551) Co-authored-by: Elastic Machine --- .../public/resolver/lib/date.test.ts | 56 ++++++++++++++----- .../public/resolver/lib/date.ts | 29 ++++++++-- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts index 7a48245fcfc41..5555578e44f7b 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts @@ -3,38 +3,64 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { getFriendlyElapsedTime } from './date'; +import { getFriendlyElapsedTime, getUnixTime } from './date'; describe('date', () => { - describe('getFriendlyElapsedTime', () => { - const second = 1000; - const minute = second * 60; - const hour = minute * 60; - const day = hour * 24; - const week = day * 7; - const month = day * 30; - const year = day * 365; + const second = 1000; + const minute = second * 60; + const hour = minute * 60; + const day = hour * 24; + const week = day * 7; + const month = day * 30; + const year = day * 365; - const initialTime = new Date('6/1/2020').getTime(); + describe('getUnixTime', () => { + const unixTime = new Date('6/1/2020').getTime(); + const unixStringTime = String(unixTime); + const isoTime = new Date('6/1/2020').toISOString(); + const notATime = 'imLate'; + + it('should return the time if already a unix timestamp', () => { + expect(getUnixTime(unixTime)).toEqual(unixTime); + }); + + it('should properly convert a unix timestamp string to a number', () => { + expect(getUnixTime(unixStringTime)).toEqual(unixTime); + }); + + it('should properly convert an ISO string to a unix timestamp', () => { + expect(getUnixTime(isoTime)).toEqual(unixTime); + }); - const oneMillisecond = new Date(initialTime + 1).getTime(); + it('should return NaN if an invalid time is provided', () => { + expect(getUnixTime(notATime)).toBeNaN(); + }); + }); + + describe('getFriendlyElapsedTime', () => { + const initialTime = new Date('6/1/2020').getTime(); + const oneMillisecond = new Date(initialTime + 1).toISOString(); const oneSecond = new Date(initialTime + 1 * second).getTime(); const oneMinute = new Date(initialTime + 1 * minute).getTime(); - const oneHour = new Date(initialTime + 1 * hour).getTime(); + const oneHour = new Date(initialTime + 1 * hour).toISOString(); const oneDay = new Date(initialTime + 1 * day).getTime(); - const oneWeek = new Date(initialTime + 1 * week).getTime(); + const oneWeek = `${new Date(initialTime + 1 * week).getTime()}`; const oneMonth = new Date(initialTime + 1 * month).getTime(); const oneYear = new Date(initialTime + 1 * year).getTime(); const almostASecond = new Date(initialTime + 999).getTime(); - const almostAMinute = new Date(initialTime + 59.9 * second).getTime(); + const almostAMinute = new Date(initialTime + 59.9 * second).toISOString(); const almostAnHour = new Date(initialTime + 59.9 * minute).getTime(); const almostADay = new Date(initialTime + 23.9 * hour).getTime(); - const almostAWeek = new Date(initialTime + 6.9 * day).getTime(); + const almostAWeek = new Date(initialTime + 6.9 * day).toISOString(); const almostAMonth = new Date(initialTime + 3.9 * week).getTime(); const almostAYear = new Date(initialTime + 11.9 * month).getTime(); const threeYears = new Date(initialTime + 3 * year).getTime(); + it('should return null if invalid times are given', () => { + expect(getFriendlyElapsedTime(initialTime, 'ImTimeless')).toEqual(null); + }); + it('should return the correct singular relative time', () => { expect(getFriendlyElapsedTime(initialTime, initialTime)).toEqual({ duration: '<1', diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.ts index a5e07e6a02a88..3cd0c910f46f3 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.ts @@ -6,6 +6,26 @@ import { DurationDetails, DurationTypes } from '../types'; +/** + * Given a time, it will convert it to a unix timestamp if not one already. If it is unable to do so, it will return NaN + */ +export const getUnixTime = (time: number | string): number | typeof NaN => { + if (!time) { + return NaN; + } + if (typeof time === 'number') { + return time; + } + // If it's a date string just get the time in MS + let unixTime = Date.parse(time); + if (Number.isNaN(unixTime)) { + // If not an ISO date string, last check will be if it's a unix timestamp string + unixTime = parseInt(time, 10); + } + + return unixTime; +}; + /* * Given two unix timestamps, it will return an object containing the time difference and properly pluralized friendly version of the time difference. * i.e. a time difference of 1000ms will yield => { duration: 1, durationType: 'second' } and 10000ms will yield => { duration: 10, durationType: 'seconds' } @@ -15,12 +35,13 @@ export const getFriendlyElapsedTime = ( from: number | string, to: number | string ): DurationDetails | null => { - const startTime = typeof from === 'number' ? from : parseInt(from, 10); - const endTime = typeof to === 'number' ? to : parseInt(to, 10); - const elapsedTimeInMs = endTime - startTime; - if (Number.isNaN(elapsedTimeInMs)) { + const startTime = getUnixTime(from); + const endTime = getUnixTime(to); + + if (Number.isNaN(startTime) || Number.isNaN(endTime)) { return null; } + const elapsedTimeInMs = endTime - startTime; const second = 1000; const minute = second * 60; From 17ec168c287733253345854eff2e75f9b151a932 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Tue, 28 Jul 2020 19:58:58 -0400 Subject: [PATCH 05/10] [Security Solution][Resolver] Undo origin panel update (#73501) Co-authored-by: Elastic Machine --- .../public/resolver/view/map.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 19c403f1257be..0ca71c5bf60ce 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -8,7 +8,7 @@ /* eslint-disable react/display-name */ -import React, { useContext, useEffect } from 'react'; +import React, { useContext } from 'react'; import { useSelector } from 'react-redux'; import { useEffectOnce } from 'react-use'; import { EuiLoadingSpinner } from '@elastic/eui'; @@ -68,25 +68,12 @@ export const ResolverMap = React.memo(function ({ const hasError = useSelector(selectors.hasError); const activeDescendantId = useSelector(selectors.ariaActiveDescendant); const { colorMap } = useResolverTheme(); - const { - cleanUpQueryParams, - queryParams: { crumbId }, - pushToQueryParams, - } = useResolverQueryParams(); + const { cleanUpQueryParams } = useResolverQueryParams(); useEffectOnce(() => { return () => cleanUpQueryParams(); }); - useEffect(() => { - // When you refresh the page after selecting a process in the table view (not the timeline view) - // The old crumbId still exists in the query string even though a resolver is no longer visible - // This just makes sure the activeDescendant and crumbId are in sync on load for that view as well as the timeline - if (activeDescendantId && crumbId !== activeDescendantId) { - pushToQueryParams({ crumbId: activeDescendantId, crumbEvent: '' }); - } - }, [crumbId, activeDescendantId, pushToQueryParams]); - return ( {isLoading ? ( From 774d3591c083e14bdd56339f2a93eb27e1d531f5 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Wed, 29 Jul 2020 01:03:45 +0100 Subject: [PATCH 06/10] [Security Solution] Update security overview splash (#73050) ## Summary https://github.com/elastic/endpoint-app-team/issues/591 How to verify: 1. go to: x-pack/test/security_solution_cypress/runner.ts 2. comment line 20 (await esArchiver.load('auditbeat');) 3. in line 25 change cypress:run for cypress:open 4. then in our directory run yarn cypress:run-as-ci when the cypress is open, 5. you can access the Kibana instance in port 5620 with username elastic and password changeme Screenshot 2020-07-23 at 14 48 34 ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] ~[Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~ - [ ] ~[Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios~ - [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~ - [ ] ~This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)~ - [ ] ~This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)~ ### For maintainers - [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ --- .../public/doc_links/doc_links_service.ts | 2 +- .../pages/saved_object_no_permissions.tsx | 18 +- .../__snapshots__/index.test.tsx.snap | 24 ++- .../components/empty_page/index.test.tsx | 14 +- .../common/components/empty_page/index.tsx | 157 +++++++++++------- .../public/common/translations.ts | 39 ++++- .../detection_engine.test.tsx | 3 + .../detection_engine_no_signal_index.tsx | 19 ++- .../detection_engine_user_unauthenticated.tsx | 19 ++- .../pages/detection_engine/translations.ts | 4 +- .../components/overview_empty/index.test.tsx | 83 +++++++++ .../components/overview_empty/index.tsx | 53 ++++-- .../public/overview/pages/overview.test.tsx | 4 +- .../public/overview/pages/overview.tsx | 2 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- 16 files changed, 331 insertions(+), 118 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 70b25cb78787a..eb54983d0be13 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -112,7 +112,7 @@ export class DocLinksService { kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`, siem: { guide: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, - gettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/install-siem.html`, + gettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, }, query: { luceneQuerySyntax: `${ELASTICSEARCH_DOCS}query-dsl-query-string-query.html#query-string-syntax`, diff --git a/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx b/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx index 7129aa04bdf69..c61ff6d18caab 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EmptyPage } from '../../common/components/empty_page'; import * as i18n from './translations'; @@ -12,13 +12,21 @@ import { useKibana } from '../../common/lib/kibana'; export const CaseSavedObjectNoPermissions = React.memo(() => { const docLinks = useKibana().services.docLinks; + const actions = useMemo( + () => ({ + savedObject: { + icon: 'documents', + label: i18n.GO_TO_DOCUMENTATION, + url: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/security/${docLinks.DOC_LINK_VERSION}s`, + target: '_blank', + }, + }), + [docLinks] + ); return ( - - Do Something - + + Do Something + + } + title={false} + /> } diff --git a/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx index 6a14c12cee0f8..8e025faefeabe 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx @@ -10,12 +10,12 @@ import React from 'react'; import { EmptyPage } from './index'; test('renders correctly', () => { - const EmptyComponent = shallow( - - ); + const actions = { + actions: { + label: 'Do Something', + url: 'my/url/from/nowwhere', + }, + }; + const EmptyComponent = shallow(); expect(EmptyComponent).toMatchSnapshot(); }); diff --git a/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx index f6d6752729b6d..89f4b125e930c 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx @@ -4,84 +4,123 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, IconType } from '@elastic/eui'; -import React, { MouseEventHandler, ReactNode } from 'react'; +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + IconType, + EuiCard, +} from '@elastic/eui'; +import React, { MouseEventHandler, ReactNode, useMemo } from 'react'; import styled from 'styled-components'; const EmptyPrompt = styled(EuiEmptyPrompt)` align-self: center; /* Corrects horizontal centering in IE11 */ + max-width: 60em; `; EmptyPrompt.displayName = 'EmptyPrompt'; +interface EmptyPageActions { + icon?: IconType; + label: string; + target?: string; + url: string; + descriptionTitle?: string; + description?: string; + fill?: boolean; + onClick?: MouseEventHandler; +} + +export type EmptyPageActionsProps = Record; + interface EmptyPageProps { - actionPrimaryIcon?: IconType; - actionPrimaryLabel: string; - actionPrimaryTarget?: string; - actionPrimaryUrl: string; - actionPrimaryFill?: boolean; - actionSecondaryIcon?: IconType; - actionSecondaryLabel?: string; - actionSecondaryTarget?: string; - actionSecondaryUrl?: string; - actionSecondaryOnClick?: MouseEventHandler; + actions: EmptyPageActionsProps; 'data-test-subj'?: string; message?: ReactNode; title: string; } -export const EmptyPage = React.memo( - ({ - actionPrimaryIcon, - actionPrimaryLabel, - actionPrimaryTarget, - actionPrimaryUrl, - actionPrimaryFill = true, - actionSecondaryIcon, - actionSecondaryLabel, - actionSecondaryTarget, - actionSecondaryUrl, - actionSecondaryOnClick, - message, - title, - ...rest - }) => ( +const EmptyPageComponent = React.memo(({ actions, message, title, ...rest }) => { + const titles = Object.keys(actions); + const maxItemWidth = 283; + const renderActions = useMemo( + () => + Object.values(actions) + .filter((a) => a.label && a.url) + .map( + ( + { + icon, + label, + target, + url, + descriptionTitle = false, + description = false, + onClick, + fill = true, + }, + idx + ) => + descriptionTitle != null || description != null ? ( + + + {label} + + } + /> + + ) : ( + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {label} + + + ) + ), + [actions, titles] + ); + + return ( {title}} body={message &&

{message}

} - actions={ - - - - {actionPrimaryLabel} - - - - {actionSecondaryLabel && actionSecondaryUrl && ( - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - - {actionSecondaryLabel} - - - )} - - } + actions={{renderActions}} {...rest} /> - ) -); + ); +}); + +EmptyPageComponent.displayName = 'EmptyPageComponent'; +export const EmptyPage = React.memo(EmptyPageComponent); EmptyPage.displayName = 'EmptyPage'; diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts index 413119fb40f14..3b94ac8959496 100644 --- a/x-pack/plugins/security_solution/public/common/translations.ts +++ b/x-pack/plugins/security_solution/public/common/translations.ts @@ -7,16 +7,39 @@ import { i18n } from '@kbn/i18n'; export const EMPTY_TITLE = i18n.translate('xpack.securitySolution.pages.common.emptyTitle', { - defaultMessage: 'Welcome to Security Solution. Let’s get you started.', + defaultMessage: 'Welcome to Elastic Security. Let’s get you started.', }); -export const EMPTY_ACTION_PRIMARY = i18n.translate( - 'xpack.securitySolution.pages.common.emptyActionPrimary', +export const EMPTY_ACTION_ELASTIC_AGENT = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionElasticAgent', + { + defaultMessage: 'Add data with Elastic Agent', + } +); + +export const EMPTY_ACTION_ELASTIC_AGENT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionElasticAgentDescription', + { + defaultMessage: + 'The Elastic Agent provides a simple, unified way to add monitoring to your hosts.', + } +); + +export const EMPTY_ACTION_BEATS = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionBeats', { defaultMessage: 'Add data with Beats', } ); +export const EMPTY_ACTION_BEATS_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionBeatsDescription', + { + defaultMessage: + 'Lightweight Beats can send data from hundreds or thousands of machines and systems', + } +); + export const EMPTY_ACTION_SECONDARY = i18n.translate( 'xpack.securitySolution.pages.common.emptyActionSecondary', { @@ -27,6 +50,14 @@ export const EMPTY_ACTION_SECONDARY = i18n.translate( export const EMPTY_ACTION_ENDPOINT = i18n.translate( 'xpack.securitySolution.pages.common.emptyActionEndpoint', { - defaultMessage: 'Add data with Elastic Agent (Beta)', + defaultMessage: 'Add Elastic Endpoint Security', + } +); + +export const EMPTY_ACTION_ENDPOINT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionEndpointDescription', + { + defaultMessage: + 'Protect your hosts with threat prevention, detection, and deep security data visibility.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index 110620fad7eba..982712cbe9797 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -53,6 +53,9 @@ jest.mock('react-router-dom', () => { useHistory: jest.fn(), }; }); +jest.mock('../../components/alerts_info', () => ({ + useAlertInfo: jest.fn().mockReturnValue([]), +})); const state: State = { ...mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx index 32ae585aec191..c315361b294c7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EmptyPage } from '../../../common/components/empty_page'; import * as i18n from './translations'; @@ -12,12 +12,21 @@ import { useKibana } from '../../../common/lib/kibana'; export const DetectionEngineNoIndex = React.memo(() => { const docLinks = useKibana().services.docLinks; + const actions = useMemo( + () => ({ + detections: { + icon: 'documents', + label: i18n.GO_TO_DOCUMENTATION, + url: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/security/${docLinks.DOC_LINK_VERSION}/detection-engine-overview.html#detections-permissions`, + target: '_blank', + }, + }), + [docLinks] + ); + return ( { const docLinks = useKibana().services.docLinks; - + const actions = useMemo( + () => ({ + detectionUnauthenticated: { + icon: 'documents', + label: i18n.GO_TO_DOCUMENTATION, + url: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/security/${docLinks.DOC_LINK_VERSION}/detection-engine-overview.html#detections-permissions`, + target: '_blank', + }, + }), + [docLinks] + ); return ( ({ + useIngestUrl: jest + .fn() + .mockReturnValue({ appId: 'ingestAppId', appPath: 'ingestPath', url: 'ingestUrl' }), +})); + +jest.mock('../../../common/hooks/endpoint/ingest_enabled', () => ({ + useIngestEnabledCheck: jest.fn().mockReturnValue({ allEnabled: true }), +})); + +jest.mock('../../../common/hooks/endpoint/use_navigate_to_app_event_handler', () => ({ + useNavigateToAppEventHandler: jest.fn(), +})); + +describe('OverviewEmpty', () => { + describe('When isIngestEnabled = true', () => { + let wrapper: ShallowWrapper; + beforeAll(() => { + wrapper = shallow(); + }); + + afterAll(() => { + (useIngestEnabledCheck as jest.Mock).mockReset(); + }); + + test('render with correct actions ', () => { + expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ + beats: { + description: + 'Lightweight Beats can send data from hundreds or thousands of machines and systems', + fill: false, + label: 'Add data with Beats', + url: '/app/home#/tutorial_directory/security', + }, + elasticAgent: { + description: + 'The Elastic Agent provides a simple, unified way to add monitoring to your hosts.', + fill: false, + label: 'Add data with Elastic Agent', + url: 'ingestUrl', + }, + endpoint: { + description: + 'Protect your hosts with threat prevention, detection, and deep security data visibility.', + fill: false, + label: 'Add Elastic Endpoint Security', + onClick: undefined, + url: '/app/home#/tutorial_directory/security', + }, + }); + }); + }); + + describe('When isIngestEnabled = false', () => { + let wrapper: ShallowWrapper; + beforeAll(() => { + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: false }); + wrapper = shallow(); + }); + + test('render with correct actions ', () => { + expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ + beats: { + description: + 'Lightweight Beats can send data from hundreds or thousands of machines and systems', + fill: false, + label: 'Add data with Beats', + url: '/app/home#/tutorial_directory/security', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 33413be10079e..1d2c6889213f1 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; +import { omit } from 'lodash/fp'; + import { FormattedMessage } from '@kbn/i18n/react'; import { EuiLink } from '@elastic/eui'; import * as i18nCommon from '../../../common/translations'; -import { EmptyPage } from '../../../common/components/empty_page'; +import { EmptyPage, EmptyPageActionsProps } from '../../../common/components/empty_page'; import { useKibana } from '../../../common/lib/kibana'; import { ADD_DATA_PATH } from '../../../../common/constants'; import { useIngestUrl } from '../../../management/pages/endpoint_hosts/view/hooks'; @@ -23,23 +25,45 @@ const OverviewEmptyComponent: React.FC = () => { ); const handleOnClick = useNavigateToAppEventHandler(ingestAppId, { path: ingestPath }); const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); + const emptyPageActions: EmptyPageActionsProps = useMemo( + () => ({ + elasticAgent: { + label: i18nCommon.EMPTY_ACTION_ELASTIC_AGENT, + url: ingestUrl, + description: i18nCommon.EMPTY_ACTION_ELASTIC_AGENT_DESCRIPTION, + fill: false, + }, + beats: { + label: i18nCommon.EMPTY_ACTION_BEATS, + url: `${basePath}${ADD_DATA_PATH}`, + description: i18nCommon.EMPTY_ACTION_BEATS_DESCRIPTION, + fill: false, + }, + endpoint: { + label: i18nCommon.EMPTY_ACTION_ENDPOINT, + url: `${basePath}${ADD_DATA_PATH}`, + description: i18nCommon.EMPTY_ACTION_ENDPOINT_DESCRIPTION, + onClick: handleOnClick, + fill: false, + }, + }), + [basePath, ingestUrl, handleOnClick] + ); + + const emptyPageIngestDisabledActions = useMemo( + () => omit(['elasticAgent', 'endpoint'], emptyPageActions), + [emptyPageActions] + ); return isIngestEnabled === true ? ( {i18nCommon.EMPTY_ACTION_SECONDARY} @@ -50,16 +74,13 @@ const OverviewEmptyComponent: React.FC = () => { /> ) : ( {i18nCommon.EMPTY_ACTION_SECONDARY} diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index 286cc870378e1..74225c4e4f823 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -83,7 +83,7 @@ describe('Overview', () => { ); - expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="empty-page-endpoint-action"]').exists()).toBe(false); }); it('shows Endpoint get ready button when ingest is enabled', () => { @@ -95,7 +95,7 @@ describe('Overview', () => { ); - expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="empty-page-endpoint-action"]').exists()).toBe(true); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 423aa597a0129..1b743c259555a 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -17,6 +17,7 @@ import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { useWithSource } from '../../common/containers/source'; + import { EventsByDataset } from '../components/events_by_dataset'; import { EventCounts } from '../components/event_counts'; import { OverviewEmpty } from '../components/overview_empty'; @@ -66,7 +67,6 @@ const OverviewComponent: React.FC = ({ addMessage('management', 'dismissEndpointNotice'); }, [addMessage]); const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); - return ( <> {indicesExist ? ( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b6aaa2065c795..8fbcb3f1122cc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13572,7 +13572,7 @@ "xpack.securitySolution.detectionEngine.editRule.errorMsgDescription": "申し訳ありません", "xpack.securitySolution.detectionEngine.editRule.pageTitle": "ルール設定の編集", "xpack.securitySolution.detectionEngine.editRule.saveChangeTitle": "変更を保存", - "xpack.securitySolution.detectionEngine.emptyActionPrimary": "セットアップの手順を表示", + "xpack.securitySolution.detectionEngine.emptyActionBeats": "セットアップの手順を表示", "xpack.securitySolution.detectionEngine.emptyActionSecondary": "ドキュメントに移動", "xpack.securitySolution.detectionEngine.emptyTitle": "Securityアプリケーションの検出エンジンに関連したインデックスがないようです", "xpack.securitySolution.detectionEngine.goToDocumentationButton": "ドキュメンテーションを表示", @@ -14349,7 +14349,7 @@ "xpack.securitySolution.overview.viewEventsButtonLabel": "イベントを表示", "xpack.securitySolution.overview.winlogbeatMWSysmonOperational": "Microsoft-Windows-Sysmon/Operational", "xpack.securitySolution.overview.winlogbeatSecurityTitle": "セキュリティ", - "xpack.securitySolution.pages.common.emptyActionPrimary": "Beatsでデータを表示", + "xpack.securitySolution.pages.common.emptyActionBeats": "Beatsでデータを表示", "xpack.securitySolution.pages.common.emptyActionSecondary": "入門ガイドを表示", "xpack.securitySolution.pages.common.emptyTitle": "SIEMへようこそ。始めましょう。", "xpack.securitySolution.pages.fourohfour.noContentFoundDescription": "コンテンツがありません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index dcbcda120587f..b69763449a06f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13578,7 +13578,7 @@ "xpack.securitySolution.detectionEngine.editRule.errorMsgDescription": "抱歉", "xpack.securitySolution.detectionEngine.editRule.pageTitle": "编辑规则设置", "xpack.securitySolution.detectionEngine.editRule.saveChangeTitle": "保存更改", - "xpack.securitySolution.detectionEngine.emptyActionPrimary": "查看设置说明", + "xpack.securitySolution.detectionEngine.emptyActionBeats": "查看设置说明", "xpack.securitySolution.detectionEngine.emptyActionSecondary": "前往文档", "xpack.securitySolution.detectionEngine.emptyTitle": "似乎您没有与 Security 应用程序的检测引擎相关的索引", "xpack.securitySolution.detectionEngine.goToDocumentationButton": "查看文档", @@ -14355,7 +14355,7 @@ "xpack.securitySolution.overview.viewEventsButtonLabel": "查看事件", "xpack.securitySolution.overview.winlogbeatMWSysmonOperational": "Microsoft-Windows-Sysmon/Operational", "xpack.securitySolution.overview.winlogbeatSecurityTitle": "安全", - "xpack.securitySolution.pages.common.emptyActionPrimary": "使用 Beats 添加数据", + "xpack.securitySolution.pages.common.emptyActionBeats": "使用 Beats 添加数据", "xpack.securitySolution.pages.common.emptyActionSecondary": "查看入门指南", "xpack.securitySolution.pages.common.emptyTitle": "欢迎使用 SIEM。让我们教您如何入门。", "xpack.securitySolution.pages.fourohfour.noContentFoundDescription": "未找到任何内容", From f1c08939ead12341ccb2dabfe2bb9f8681de4e65 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Tue, 28 Jul 2020 20:21:11 -0400 Subject: [PATCH 07/10] [SECURITY_SOLUTION][Administration] Task/remove policy tab (#73352) --- .../components/management_empty_state.tsx | 63 ++----------------- .../components/management_page_view.tsx | 45 +------------ .../view/details/host_details.tsx | 6 +- .../endpoint_hosts/view/details/index.tsx | 4 +- .../pages/endpoint_hosts/view/index.test.tsx | 4 +- .../pages/endpoint_hosts/view/index.tsx | 4 +- .../public/management/pages/policy/index.tsx | 8 +-- .../configure_package_config.tsx | 58 +++++++---------- .../pages/policy/view/policy_details.test.tsx | 14 ++--- .../pages/policy/view/policy_details.tsx | 19 +++--- .../pages/policy/view/policy_list.test.tsx | 3 +- .../endpoint_overview/translations.ts | 4 +- .../apps/endpoint/endpoint_list.ts | 10 +-- .../apps/endpoint/index.ts | 1 - .../apps/endpoint/policy_details.ts | 4 +- 15 files changed, 68 insertions(+), 179 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index fb9f97f3f7570..a4518d1a1f493 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -77,57 +77,6 @@ const PolicyEmptyState = React.memo<{ /> - - - - - - - - -

- -

-
-
-
- - - - -
- - - - - - - -

- -

-
-
-
- - - - -
-
- [ { title: i18n.translate('xpack.securitySolution.endpoint.hostList.stepOneTitle', { - defaultMessage: 'Select the policy you want to use to protect your hosts', + defaultMessage: 'Select the integration you want to use', }), children: ( <> @@ -203,7 +152,7 @@ const HostsEmptyState = React.memo<{ ) : selectionOptions.length ? ( @@ -211,7 +160,7 @@ const HostsEmptyState = React.memo<{ ) : ( ); }} @@ -263,13 +212,13 @@ const HostsEmptyState = React.memo<{ headerComponent={ } bodyComponent={ } /> diff --git a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx index 42341b524362d..aa562b9a20201 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx @@ -4,52 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { useParams } from 'react-router-dom'; +import React, { memo } from 'react'; import { PageView, PageViewProps } from '../../common/components/endpoint/page_view'; -import { AdministrationSubTab } from '../types'; -import { SecurityPageName } from '../../app/types'; -import { useFormatUrl } from '../../common/components/link_to'; -import { getHostListPath, getPoliciesPath } from '../common/routing'; -import { useNavigateByRouterEventHandler } from '../../common/hooks/endpoint/use_navigate_by_router_event_handler'; export const ManagementPageView = memo>((options) => { - const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); - const { tabName } = useParams<{ tabName: AdministrationSubTab }>(); - - const goToEndpoint = useNavigateByRouterEventHandler( - getHostListPath({ name: 'hostList' }, search) - ); - - const goToPolicies = useNavigateByRouterEventHandler(getPoliciesPath(search)); - - const tabs = useMemo((): PageViewProps['tabs'] | undefined => { - if (options.viewType === 'details') { - return undefined; - } - return [ - { - name: i18n.translate('xpack.securitySolution.managementTabs.hosts', { - defaultMessage: 'Hosts', - }), - id: AdministrationSubTab.hosts, - isSelected: tabName === AdministrationSubTab.hosts, - href: formatUrl(getHostListPath({ name: 'hostList' })), - onClick: goToEndpoint, - }, - { - name: i18n.translate('xpack.securitySolution.managementTabs.policies', { - defaultMessage: 'Policies', - }), - id: AdministrationSubTab.policies, - isSelected: tabName === AdministrationSubTab.policies, - href: formatUrl(getPoliciesPath()), - onClick: goToPolicies, - }, - ]; - }, [formatUrl, goToEndpoint, goToPolicies, options.viewType, tabName]); - return ; + return ; }); ManagementPageView.displayName = 'ManagementPageView'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index cea66acbef8ca..109392cb7a929 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -121,7 +121,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { return [ { title: i18n.translate('xpack.securitySolution.endpoint.host.details.policy', { - defaultMessage: 'Policy', + defaultMessage: 'Integration', }), description: ( <> @@ -136,7 +136,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { }, { title: i18n.translate('xpack.securitySolution.endpoint.host.details.policyStatus', { - defaultMessage: 'Policy Status', + defaultMessage: 'Configuration response', }), description: ( { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 71b3885308558..212c8977a8852 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -158,7 +158,7 @@ const PolicyResponseFlyoutPanel = memo<{

@@ -167,7 +167,7 @@ const PolicyResponseFlyoutPanel = memo<{ title={ } /> diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 47227244b7066..9d49c8705affe 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -431,7 +431,7 @@ describe('when on the hosts page', () => { const renderResult = render(); const linkToReassign = await renderResult.findByTestId('hostDetailsLinkToIngest'); expect(linkToReassign).not.toBeNull(); - expect(linkToReassign.textContent).toEqual('Reassign Policy'); + expect(linkToReassign.textContent).toEqual('Reassign Configuration'); expect(linkToReassign.getAttribute('href')).toEqual( `/app/ingestManager#/fleet/agents/${agentId}/activity?openReassignFlyout=true` ); @@ -492,7 +492,7 @@ describe('when on the hosts page', () => { it('should include the sub-panel title', async () => { expect( (await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutTitle')).textContent - ).toBe('Policy Response'); + ).toBe('Configuration Response'); }); it('should show a configuration section for each protection', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index e38ef1bd5fe86..2692f7791b7c0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -237,7 +237,7 @@ export const HostList = () => { { field: 'metadata.Endpoint.policy.applied', name: i18n.translate('xpack.securitySolution.endpointList.policy', { - defaultMessage: 'Policy', + defaultMessage: 'Integration', }), truncateText: true, // eslint-disable-next-line react/display-name @@ -256,7 +256,7 @@ export const HostList = () => { { field: 'metadata.Endpoint.policy.applied', name: i18n.translate('xpack.securitySolution.endpointList.policyStatus', { - defaultMessage: 'Policy Status', + defaultMessage: 'Configuration Status', }), // eslint-disable-next-line react/display-name render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx index 5122bbcd5d55d..681f1f1430926 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx @@ -6,17 +6,13 @@ import React, { memo } from 'react'; import { Route, Switch } from 'react-router-dom'; -import { PolicyDetails, PolicyList } from './view'; -import { - MANAGEMENT_ROUTING_POLICIES_PATH, - MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, -} from '../../common/constants'; +import { PolicyDetails } from './view'; +import { MANAGEMENT_ROUTING_POLICY_DETAILS_PATH } from '../../common/constants'; import { NotFoundPage } from '../../../app/404'; export const PolicyContainer = memo(() => { return ( - diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_package_config.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_package_config.tsx index 67f24977406c6..2b08bfd2b282b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_package_config.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_package_config.tsx @@ -6,8 +6,7 @@ import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCallOut, EuiText, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiCallOut, EuiText, EuiSpacer } from '@elastic/eui'; import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; import { CustomConfigurePackageConfigContent, @@ -50,52 +49,37 @@ export const ConfigureEndpointPackageConfig = memo - -

- -

-

{from === 'edit' ? ( - <> - - - - - - + + + + ), + }} + /> ) : ( )}

diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 4f7c14735fe21..6ed4e06ee51c5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -10,7 +10,7 @@ import { mount } from 'enzyme'; import { PolicyDetails } from './policy_details'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; -import { getPolicyDetailPath, getPoliciesPath } from '../../../common/routing'; +import { getPolicyDetailPath, getHostListPath } from '../../../common/routing'; import { apiPathMockResponseProviders } from '../store/policy_list/test_mock_utils'; jest.mock('../../../../common/components/link_to'); @@ -19,7 +19,7 @@ describe('Policy Details', () => { type FindReactWrapperResponse = ReturnType['find']>; const policyDetailsPathUrl = getPolicyDetailPath('1'); - const policyListPathUrl = getPoliciesPath(); + const hostListPath = getHostListPath({ name: 'hostList' }); const sleep = (ms = 100) => new Promise((wakeup) => setTimeout(wakeup, ms)); const generator = new EndpointDocGenerator(); let history: AppContextTestRender['history']; @@ -127,8 +127,8 @@ describe('Policy Details', () => { const backToListButton = pageHeaderLeft.find('EuiButtonEmpty'); expect(backToListButton.prop('iconType')).toBe('arrowLeft'); - expect(backToListButton.prop('href')).toBe(policyListPathUrl); - expect(backToListButton.text()).toBe('Back to policy list'); + expect(backToListButton.prop('href')).toBe(hostListPath); + expect(backToListButton.text()).toBe('Back to endpoint hosts'); const pageTitle = pageHeaderLeft.find('[data-test-subj="pageViewHeaderLeftTitle"]'); expect(pageTitle).toHaveLength(1); @@ -141,7 +141,7 @@ describe('Policy Details', () => { ); expect(history.location.pathname).toEqual(policyDetailsPathUrl); backToListButton.simulate('click', { button: 0 }); - expect(history.location.pathname).toEqual(policyListPathUrl); + expect(history.location.pathname).toEqual(hostListPath); }); it('should display agent stats', async () => { await asyncActions; @@ -173,7 +173,7 @@ describe('Policy Details', () => { const navigateToAppMockedCalls = coreStart.application.navigateToApp.mock.calls; expect(navigateToAppMockedCalls[navigateToAppMockedCalls.length - 1]).toEqual([ 'securitySolution:administration', - { path: policyListPathUrl }, + { path: hostListPath }, ]); }); it('should display save button', async () => { @@ -232,7 +232,7 @@ describe('Policy Details', () => { ); expect(warningCallout).toHaveLength(1); expect(warningCallout.text()).toEqual( - 'This action will update 5 hostsSaving these changes will apply updates to all endpoints assigned to this policy' + 'This action will update 5 hostsSaving these changes will apply updates to all endpoints assigned to this agent configuration.' ); }); it('should close dialog if cancel button is clicked', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index 288bc484c23b5..cd63991dbac93 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -42,7 +42,7 @@ import { PageViewHeaderTitle } from '../../../../common/components/endpoint/page import { ManagementPageView } from '../../../components/management_page_view'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { SecurityPageName } from '../../../../app/types'; -import { getPoliciesPath } from '../../../common/routing'; +import { getHostListPath } from '../../../common/routing'; import { useFormatUrl } from '../../../../common/components/link_to'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { MANAGEMENT_APP_ID } from '../../../common/constants'; @@ -56,7 +56,7 @@ export const PolicyDetails = React.memo(() => { application: { navigateToApp }, }, } = useKibana(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); const { state: locationRouteState } = useLocation(); // Store values @@ -70,6 +70,7 @@ export const PolicyDetails = React.memo(() => { const [showConfirm, setShowConfirm] = useState(false); const [routeState, setRouteState] = useState(); const policyName = policyItem?.name ?? ''; + const hostListRouterPath = getHostListPath({ name: 'hostList' }); // Handle showing update statuses useEffect(() => { @@ -87,7 +88,7 @@ export const PolicyDetails = React.memo(() => { @@ -109,11 +110,11 @@ export const PolicyDetails = React.memo(() => { } }, [navigateToApp, notifications.toasts, policyName, policyUpdateStatus, routeState]); - const handleBackToListOnClick = useNavigateByRouterEventHandler(getPoliciesPath()); + const handleBackToListOnClick = useNavigateByRouterEventHandler(hostListRouterPath); const navigateToAppArguments = useMemo((): Parameters => { - return routeState?.onCancelNavigateTo ?? [MANAGEMENT_APP_ID, { path: getPoliciesPath() }]; - }, [routeState?.onCancelNavigateTo]); + return routeState?.onCancelNavigateTo ?? [MANAGEMENT_APP_ID, { path: hostListRouterPath }]; + }, [hostListRouterPath, routeState?.onCancelNavigateTo]); const handleCancelOnClick = useNavigateToAppEventHandler(...navigateToAppArguments); const handleSaveOnClick = useCallback(() => { @@ -162,11 +163,11 @@ export const PolicyDetails = React.memo(() => { iconType="arrowLeft" contentProps={{ style: { paddingLeft: '0' } }} onClick={handleBackToListOnClick} - href={formatUrl(getPoliciesPath(search))} + href={formatUrl(hostListRouterPath)} > {policyItem.name} @@ -306,7 +307,7 @@ const ConfirmUpdate = React.memo<{ >
diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx index 047aa6918736e..e35c97698f5cb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx @@ -14,7 +14,8 @@ import { AppAction } from '../../../../common/store/actions'; jest.mock('../../../../common/components/link_to'); -describe('when on the policies page', () => { +// Skipping these test now that the Policy List has been hidden +describe.skip('when on the policies page', () => { let render: () => ReturnType; let history: AppContextTestRender['history']; let store: AppContextTestRender['store']; diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts index 34e3347b5ff9a..a7f1be3debb83 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts @@ -9,14 +9,14 @@ import { i18n } from '@kbn/i18n'; export const ENDPOINT_POLICY = i18n.translate( 'xpack.securitySolution.host.details.endpoint.endpointPolicy', { - defaultMessage: 'Endpoint policy', + defaultMessage: 'Integration', } ); export const POLICY_STATUS = i18n.translate( 'xpack.securitySolution.host.details.endpoint.policyStatus', { - defaultMessage: 'Policy status', + defaultMessage: 'Configuration Status', } ); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index 07667a140d090..85d0e56231643 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -36,8 +36,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { [ 'Hostname', 'Host Status', - 'Policy', - 'Policy Status', + 'Integration', + 'Configuration Status', 'Operating System', 'IP Address', 'Version', @@ -119,7 +119,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); // The integration does not work properly yet. Skipping this test for now. - it.skip('navigates to ingest fleet when the Reassign Policy link is clicked', async () => { + it.skip('navigates to ingest fleet when the Reassign Configuration link is clicked', async () => { await (await testSubjects.find('hostnameCellLink')).click(); await (await testSubjects.find('hostDetailsLinkToIngest')).click(); await testSubjects.existOrFail('fleetAgentListTable'); @@ -145,8 +145,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'OS', 'Last Seen', 'Alerts', - 'Policy', - 'Policy Status', + 'Integration', + 'Configuration Status', 'IP Address', 'Hostname', 'Sensor Version', diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts index eec3da4ce1c5e..7962ec60ff57e 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts @@ -29,7 +29,6 @@ export default function (providerContext: FtrProviderContext) { await ingestManager.setup(); }); loadTestFile(require.resolve('./endpoint_list')); - loadTestFile(require.resolve('./policy_list')); loadTestFile(require.resolve('./policy_details')); }); } diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 8b197a6c69a30..ca84384390a29 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -73,7 +73,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await testSubjects.existOrFail('policyDetailsSuccessMessage'); expect(await testSubjects.getVisibleText('policyDetailsSuccessMessage')).to.equal( - `Policy ${policyInfo.packageConfig.name} has been updated.` + `Integration ${policyInfo.packageConfig.name} has been updated.` ); }); it('should persist update on the screen', async () => { @@ -81,7 +81,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.policy.confirmAndSave(); await testSubjects.existOrFail('policyDetailsSuccessMessage'); - await pageObjects.policy.navigateToPolicyList(); + await pageObjects.endpoint.navigateToHostList(); await pageObjects.policy.navigateToPolicyDetails(policyInfo.packageConfig.id); expect(await (await testSubjects.find('policyWindowsEvent_process')).isSelected()).to.equal( From 78aa24dbd6e61c05559d7432d4357af1c2ad81b3 Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Tue, 28 Jul 2020 19:30:42 -0500 Subject: [PATCH 08/10] Make button appear clickable, change state of empty text (#73496) Co-authored-by: Elastic Machine --- .../public/components/saved_views/toolbar_control.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx index db9f92532645f..2e06ee55189d9 100644 --- a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx @@ -161,7 +161,11 @@ export function SavedViewsToolbarControls(props: Props) { /> - + (props: Props) { {currentView ? currentView.name : i18n.translate('xpack.infra.savedView.unknownView', { - defaultMessage: 'Unknown', + defaultMessage: 'No view seleted', })} From fea3bfcebc2af2b8ff22660e015a044e32280cd0 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 28 Jul 2020 20:59:45 -0400 Subject: [PATCH 09/10] [Resolver] simulator and click through tests (#73310) Write a few jest tests for resolver's react code. --- .../resolver/data_access_layer/factory.ts | 70 ++++ .../mocks/one_ancestor_two_children.ts | 96 +++++ .../public/resolver/models/process_event.ts | 16 +- .../public/resolver/store/index.ts | 8 +- .../public/resolver/store/middleware/index.ts | 27 +- .../store/middleware/resolver_tree_fetcher.ts | 31 +- .../resolver/store/mocks/endpoint_event.ts | 2 +- .../connect_enzyme_wrapper_and_store.ts | 20 + .../resolver/test_utilities/extend_jest.ts | 88 +++++ .../resolver/test_utilities/react_wrapper.ts | 17 + .../test_utilities/simulator/index.tsx | 290 ++++++++++++++ .../simulator/mock_resolver.tsx | 109 ++++++ .../test_utilities/spy_middleware_factory.ts | 58 +++ .../public/resolver/types.ts | 136 ++++++- .../resolver/view/clickthrough.test.tsx | 99 +++++ .../public/resolver/view/index.tsx | 45 +-- .../panels/panel_content_process_detail.tsx | 368 +++++++++--------- .../resolver/view/process_event_dot.tsx | 13 +- .../view/resolver_without_providers.tsx | 139 +++++++ ...or.ts => side_effect_simulator_factory.ts} | 5 +- .../public/resolver/view/use_camera.test.tsx | 9 +- .../view/use_resolver_query_params.ts | 8 +- 22 files changed, 1347 insertions(+), 307 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/react_wrapper.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx rename x-pack/plugins/security_solution/public/resolver/view/{side_effect_simulator.ts => side_effect_simulator_factory.ts} (95%) diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts new file mode 100644 index 0000000000000..016ebfa0faee4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public'; +import { StartServices } from '../../types'; +import { DataAccessLayer } from '../types'; +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../common/endpoint/types'; +import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../common/constants'; + +/** + * The data access layer for resolver. All communication with the Kibana server is done through this object. This object is provided to Resolver. In tests, a mock data access layer can be used instead. + */ +export function dataAccessLayerFactory( + context: KibanaReactContextValue +): DataAccessLayer { + const dataAccessLayer: DataAccessLayer = { + /** + * Used to get non-process related events for a node. + */ + async relatedEvents(entityID: string): Promise { + return context.services.http.get(`/api/endpoint/resolver/${entityID}/events`, { + query: { events: 100 }, + }); + }, + /** + * Used to get descendant and ancestor process events for a node. + */ + async resolverTree(entityID: string, signal: AbortSignal): Promise { + return context.services.http.get(`/api/endpoint/resolver/${entityID}`, { + signal, + }); + }, + + /** + * Used to get the default index pattern from the SIEM application. + */ + indexPatterns(): string[] { + return context.services.uiSettings.get(defaultIndexKey); + }, + + /** + * Used to get the entity_id for an _id. + */ + async entities({ + _id, + indices, + signal, + }: { + _id: string; + indices: string[]; + signal: AbortSignal; + }): Promise { + return context.services.http.get('/api/endpoint/resolver/entity', { + signal, + query: { + _id, + indices, + }, + }); + }, + }; + return dataAccessLayer; +} diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts new file mode 100644 index 0000000000000..be0bc1b812a0b --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../../common/endpoint/types'; +import { mockEndpointEvent } from '../../store/mocks/endpoint_event'; +import { mockTreeWithNoAncestorsAnd2Children } from '../../store/mocks/resolver_tree'; +import { DataAccessLayer } from '../../types'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: { + /** + * The entityID of the node related to the document being analyzed. + */ + origin: 'origin'; + /** + * The entityID of the first child of the origin. + */ + firstChild: 'firstChild'; + /** + * The entityID of the second child of the origin. + */ + secondChild: 'secondChild'; + }; +} + +/** + * A simple mock dataAccessLayer possible that returns a tree with 0 ancestors and 2 direct children. 1 related event is returned. The parameter to `entities` is ignored. + */ +export function oneAncestorTwoChildren(): { dataAccessLayer: DataAccessLayer; metadata: Metadata } { + const metadata: Metadata = { + databaseDocumentID: '_id', + entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, + }; + return { + metadata, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + relatedEvents(entityID: string): Promise { + return Promise.resolve({ + entityID, + events: [ + mockEndpointEvent({ + entityID, + name: 'event', + timestamp: 0, + }), + ], + nextEvent: null, + }); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + resolverTree(): Promise { + return Promise.resolve( + mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }) + ); + }, + + /** + * Get an array of index patterns that contain events. + */ + indexPatterns(): string[] { + return ['index pattern']; + }, + + /** + * Get entities matching a document. + */ + entities(): Promise { + return Promise.resolve([{ entity_id: metadata.entityIDs.origin }]); + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts index 4f8df87b3ac0b..1a5c67f6a6f2f 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts @@ -29,7 +29,7 @@ export function isTerminatedProcess(passedEvent: ResolverEvent) { } /** - * ms since unix epoc, based on timestamp. + * ms since Unix epoc, based on timestamp. * may return NaN if the timestamp wasn't present or was invalid. */ export function datetime(passedEvent: ResolverEvent): number | null { @@ -85,7 +85,7 @@ export function eventType(passedEvent: ResolverEvent): ResolverProcessType { } /** - * Returns the process event's pid + * Returns the process event's PID */ export function uniquePidForProcess(passedEvent: ResolverEvent): string { if (event.isLegacyEvent(passedEvent)) { @@ -96,7 +96,7 @@ export function uniquePidForProcess(passedEvent: ResolverEvent): string { } /** - * Returns the pid for the process on the host + * Returns the PID for the process on the host */ export function processPid(passedEvent: ResolverEvent): number | undefined { if (event.isLegacyEvent(passedEvent)) { @@ -107,7 +107,7 @@ export function processPid(passedEvent: ResolverEvent): number | undefined { } /** - * Returns the process event's parent pid + * Returns the process event's parent PID */ export function uniqueParentPidForProcess(passedEvent: ResolverEvent): string | undefined { if (event.isLegacyEvent(passedEvent)) { @@ -118,7 +118,7 @@ export function uniqueParentPidForProcess(passedEvent: ResolverEvent): string | } /** - * Returns the process event's parent pid + * Returns the process event's parent PID */ export function processParentPid(passedEvent: ResolverEvent): number | undefined { if (event.isLegacyEvent(passedEvent)) { @@ -144,12 +144,12 @@ export function processPath(passedEvent: ResolverEvent): string | undefined { */ export function userInfoForProcess( passedEvent: ResolverEvent -): { user?: string; domain?: string } | undefined { +): { name?: string; domain?: string } | undefined { return passedEvent.user; } /** - * Returns the MD5 hash for the `passedEvent` param, or undefined if it can't be located + * Returns the MD5 hash for the `passedEvent` parameter, or undefined if it can't be located * @param {ResolverEvent} passedEvent The `ResolverEvent` to get the MD5 value for * @returns {string | undefined} The MD5 string for the event */ @@ -164,7 +164,7 @@ export function md5HashForProcess(passedEvent: ResolverEvent): string | undefine /** * Returns the command line path and arguments used to run the `passedEvent` if any * - * @param {ResolverEvent} passedEvent The `ResolverEvent` to get the arguemnts value for + * @param {ResolverEvent} passedEvent The `ResolverEvent` to get the arguments value for * @returns {string | undefined} The arguments (including the path) used to run the process */ export function argsForProcess(passedEvent: ResolverEvent): string | undefined { diff --git a/x-pack/plugins/security_solution/public/resolver/store/index.ts b/x-pack/plugins/security_solution/public/resolver/store/index.ts index d9e750241ced1..950a61db33f17 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/index.ts @@ -6,22 +6,20 @@ import { createStore, applyMiddleware, Store } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; -import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public'; -import { ResolverState } from '../types'; -import { StartServices } from '../../types'; +import { ResolverState, DataAccessLayer } from '../types'; import { resolverReducer } from './reducer'; import { resolverMiddlewareFactory } from './middleware'; import { ResolverAction } from './actions'; export const storeFactory = ( - context?: KibanaReactContextValue + dataAccessLayer: DataAccessLayer ): Store => { const actionsDenylist: Array = ['userMovedPointer']; const composeEnhancers = composeWithDevTools({ name: 'Resolver', actionsBlacklist: actionsDenylist, }); - const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(context)); + const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(dataAccessLayer)); return createStore(resolverReducer, composeEnhancers(middlewareEnhancer)); }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts index 398e855a1f5d4..ef6b1f5eb3c6f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts @@ -5,34 +5,26 @@ */ import { Dispatch, MiddlewareAPI } from 'redux'; -import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; -import { StartServices } from '../../../types'; -import { ResolverState } from '../../types'; +import { ResolverState, DataAccessLayer } from '../../types'; import { ResolverRelatedEvents } from '../../../../common/endpoint/types'; import { ResolverTreeFetcher } from './resolver_tree_fetcher'; import { ResolverAction } from '../actions'; type MiddlewareFactory = ( - context?: KibanaReactContextValue + dataAccessLayer: DataAccessLayer ) => ( api: MiddlewareAPI, S> ) => (next: Dispatch) => (action: ResolverAction) => unknown; /** - * The redux middleware that the app uses to trigger side effects. + * The `redux` middleware that the application uses to trigger side effects. * All data fetching should be done here. - * For actions that the app triggers directly, use `app` as a prefix for the type. + * For actions that the application triggers directly, use `app` as a prefix for the type. * For actions that are triggered as a result of server interaction, use `server` as a prefix for the type. */ -export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { +export const resolverMiddlewareFactory: MiddlewareFactory = (dataAccessLayer: DataAccessLayer) => { return (api) => (next) => { - // This cannot work w/o `context`. - if (!context) { - return async (action: ResolverAction) => { - next(action); - }; - } - const resolverTreeFetcher = ResolverTreeFetcher(context, api); + const resolverTreeFetcher = ResolverTreeFetcher(dataAccessLayer, api); return async (action: ResolverAction) => { next(action); @@ -45,12 +37,7 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { const entityIdToFetchFor = action.payload; let result: ResolverRelatedEvents | undefined; try { - result = await context.services.http.get( - `/api/endpoint/resolver/${entityIdToFetchFor}/events`, - { - query: { events: 100 }, - } - ); + result = await dataAccessLayer.relatedEvents(entityIdToFetchFor); } catch { api.dispatch({ type: 'serverFailedToReturnRelatedEventData', diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts index 7d16dc251e6fc..2c98059d420e8 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts @@ -9,11 +9,8 @@ import { Dispatch, MiddlewareAPI } from 'redux'; import { ResolverTree, ResolverEntityIndex } from '../../../../common/endpoint/types'; -import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; -import { ResolverState } from '../../types'; +import { ResolverState, DataAccessLayer } from '../../types'; import * as selectors from '../selectors'; -import { StartServices } from '../../../types'; -import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../../common/constants'; import { ResolverAction } from '../actions'; /** * A function that handles syncing ResolverTree data w/ the current entity ID. @@ -23,7 +20,7 @@ import { ResolverAction } from '../actions'; * This is a factory because it is stateful and keeps that state in closure. */ export function ResolverTreeFetcher( - context: KibanaReactContextValue, + dataAccessLayer: DataAccessLayer, api: MiddlewareAPI, ResolverState> ): () => void { let lastRequestAbortController: AbortController | undefined; @@ -48,17 +45,12 @@ export function ResolverTreeFetcher( payload: databaseDocumentIDToFetch, }); try { - const indices: string[] = context.services.uiSettings.get(defaultIndexKey); - const matchingEntities: ResolverEntityIndex = await context.services.http.get( - '/api/endpoint/resolver/entity', - { - signal: lastRequestAbortController.signal, - query: { - _id: databaseDocumentIDToFetch, - indices, - }, - } - ); + const indices: string[] = dataAccessLayer.indexPatterns(); + const matchingEntities: ResolverEntityIndex = await dataAccessLayer.entities({ + _id: databaseDocumentIDToFetch, + indices, + signal: lastRequestAbortController.signal, + }); if (matchingEntities.length < 1) { // If no entity_id could be found for the _id, bail out with a failure. api.dispatch({ @@ -68,9 +60,10 @@ export function ResolverTreeFetcher( return; } const entityIDToFetch = matchingEntities[0].entity_id; - result = await context.services.http.get(`/api/endpoint/resolver/${entityIDToFetch}`, { - signal: lastRequestAbortController.signal, - }); + result = await dataAccessLayer.resolverTree( + entityIDToFetch, + lastRequestAbortController.signal + ); } catch (error) { // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError if (error instanceof DOMException && error.name === 'AbortError') { diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts index 8f2e0ad3a6d85..709f2faf13b00 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts @@ -18,7 +18,7 @@ export function mockEndpointEvent({ }: { entityID: string; name: string; - parentEntityId: string | undefined; + parentEntityId?: string; timestamp: number; lifecycleType?: string; }): EndpointEvent { diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts new file mode 100644 index 0000000000000..3a4a1f7d634d1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Store } from 'redux'; +import { ReactWrapper } from 'enzyme'; + +/** + * We use the full-DOM emulation mode of `enzyme` via `mount`. Even though we use `react-redux`, `enzyme` + * does not update the DOM after state transitions. This subscribes to the `redux` store and after any state + * transition it asks `enzyme` to update the DOM to match the React state. + */ +export function connectEnzymeWrapperAndStore(store: Store, wrapper: ReactWrapper): void { + store.subscribe(() => { + // See https://enzymejs.github.io/enzyme/docs/api/ReactWrapper/update.html + return wrapper.update(); + }); +} diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts new file mode 100644 index 0000000000000..9fc7af38beb42 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Typescript won't allow global namespace stuff unless you're in a module. + * This wouldn't otherwise be a module. The code runs as soon as it's imported. + * This is done this way because the `declare` will be active on import, so in + * order to be correct, the code that the `declare` declares needs to be available on import as well. + */ +export {}; + +declare global { + /* eslint-disable @typescript-eslint/no-namespace */ + namespace jest { + interface Matchers { + toYieldEqualTo(expectedYield: T extends AsyncIterable ? E : never): Promise; + } + } +} + +expect.extend({ + /** + * A custom matcher that takes an async generator and compares each value it yields to an expected value. + * If any yielded value deep-equals the expected value, the matcher will pass. + * If the generator ends with none of the yielded values matching, it will fail. + */ + async toYieldEqualTo( + this: jest.MatcherContext, + receivedIterable: AsyncIterable, + expected: T + ): Promise<{ pass: boolean; message: () => string }> { + // Used in printing out the pass or fail message + const matcherName = 'toSometimesYieldEqualTo'; + const options: jest.MatcherHintOptions = { + comment: 'deep equality with any yielded value', + isNot: this.isNot, + promise: this.promise, + }; + // The last value received: Used in printing the message + const received: T[] = []; + + // Set to true if the test passes. + let pass: boolean = false; + + // Async iterate over the iterable + for await (const next of receivedIterable) { + // keep track of all received values. Used in pass and fail messages + received.push(next); + // Use deep equals to compare the value to the expected value + if (this.equals(next, expected)) { + // If the value is equal, break + pass = true; + break; + } + } + + // Use `pass` as set in the above loop (or initialized to `false`) + // See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils + const message = pass + ? () => + `${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\n` + + `Expected: not ${this.utils.printExpected(expected)}\n${ + this.utils.stringify(expected) !== this.utils.stringify(received[received.length - 1]!) + ? `Received: ${this.utils.printReceived(received[received.length - 1])}` + : '' + }` + : () => + `${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\nCompared ${ + received.length + } yields.\n\n${received + .map( + (next, index) => + `yield ${index + 1}:\n\n${this.utils.printDiffOrStringify( + expected, + next, + 'Expected', + 'Received', + this.expand + )}` + ) + .join(`\n\n`)}`; + + return { message, pass }; + }, +}); diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/react_wrapper.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/react_wrapper.ts new file mode 100644 index 0000000000000..40267d83c30f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/react_wrapper.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper } from 'enzyme'; + +/** + * Return a collection of attribute 'entries'. + * The 'entries' are attributeName-attributeValue tuples. + */ +export function attributeEntries(wrapper: ReactWrapper): Array<[string, string]> { + return Array.prototype.slice + .call(wrapper.getDOMNode().attributes) + .map(({ name, value }) => [name, value]); +} diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx new file mode 100644 index 0000000000000..7a61427c56a3b --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -0,0 +1,290 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Store, createStore, applyMiddleware } from 'redux'; +import { mount, ReactWrapper } from 'enzyme'; +import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; +import { CoreStart } from '../../../../../../../src/core/public'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { connectEnzymeWrapperAndStore } from '../connect_enzyme_wrapper_and_store'; +import { spyMiddlewareFactory } from '../spy_middleware_factory'; +import { resolverMiddlewareFactory } from '../../store/middleware'; +import { resolverReducer } from '../../store/reducer'; +import { MockResolver } from './mock_resolver'; +import { ResolverState, DataAccessLayer, SpyMiddleware } from '../../types'; +import { ResolverAction } from '../../store/actions'; + +/** + * Test a Resolver instance using jest, enzyme, and a mock data layer. + */ +export class Simulator { + /** + * A string that uniquely identifies this Resolver instance among others mounted in the DOM. + */ + private readonly resolverComponentInstanceID: string; + /** + * The redux store, creating in the constructor using the `dataAccessLayer`. + * This code subscribes to state transitions. + */ + private readonly store: Store; + /** + * A fake 'History' API used with `react-router` to simulate a browser history. + */ + private readonly history: HistoryPackageHistoryInterface; + /** + * The 'wrapper' returned by `enzyme` that contains the rendered Resolver react code. + */ + private readonly wrapper: ReactWrapper; + /** + * A `redux` middleware that exposes all actions dispatched (along with the state at that point.) + * This is used by `debugActions`. + */ + private readonly spyMiddleware: SpyMiddleware; + constructor({ + dataAccessLayer, + resolverComponentInstanceID, + databaseDocumentID, + }: { + /** + * A (mock) data access layer that will be used to create the Resolver store. + */ + dataAccessLayer: DataAccessLayer; + /** + * A string that uniquely identifies this Resolver instance among others mounted in the DOM. + */ + resolverComponentInstanceID: string; + /** + * a databaseDocumentID to pass to Resolver. Resolver will use this in requests to the mock data layer. + */ + databaseDocumentID?: string; + }) { + this.resolverComponentInstanceID = resolverComponentInstanceID; + // create the spy middleware (for debugging tests) + this.spyMiddleware = spyMiddlewareFactory(); + + /** + * Create the real resolver middleware with a fake data access layer. + * By providing different data access layers, you can simulate different data and server environments. + */ + const middlewareEnhancer = applyMiddleware( + resolverMiddlewareFactory(dataAccessLayer), + // install the spyMiddleware + this.spyMiddleware.middleware + ); + + // Create a redux store w/ the top level Resolver reducer and the enhancer that includes the Resolver middleware and the `spyMiddleware` + this.store = createStore(resolverReducer, middlewareEnhancer); + + // Create a fake 'history' instance that Resolver will use to read and write query string values + this.history = createMemoryHistory(); + + // Used for `KibanaContextProvider` + const coreStart: CoreStart = coreMock.createStart(); + + // Render Resolver via the `MockResolver` component, using `enzyme`. + this.wrapper = mount( + + ); + + // Update the enzyme wrapper after each state transition + connectEnzymeWrapperAndStore(this.store, this.wrapper); + } + + /** + * Call this to console.log actions (and state). Use this to debug your tests. + * State and actions aren't exposed otherwise because the tests using this simulator should + * assert stuff about the DOM instead of internal state. Use selector/middleware/reducer + * unit tests to test that stuff. + */ + public debugActions(): /** + * Optionally call this to stop debugging actions. + */ () => void { + return this.spyMiddleware.debugActions(); + } + + /** + * Return a promise that resolves after the `store`'s next state transition. + * Used by `mapStateTransitions` + */ + private stateTransitioned(): Promise { + // keep track of the resolve function of the promise that has been returned. + let resolveState: (() => void) | null = null; + + const promise: Promise = new Promise((resolve) => { + // Immediately expose the resolve function in the outer scope. It will be resolved when the next state transition occurs. + resolveState = resolve; + }); + + // Subscribe to the store + const unsubscribe = this.store.subscribe(() => { + // Once a state transition occurs, unsubscribe. + unsubscribe(); + // Resolve the promise. The null assertion is safe here as Promise initializers run immediately (according to spec and node/browser implementations.) + // NB: the state is not resolved here. Code using the simulator should not rely on state or selectors of state. + resolveState!(); + }); + + // Return the promise that will be resolved on the next state transition, allowing code to `await` for the next state transition. + return promise; + } + + /** + * This will yield the return value of `mapper` after each state transition. If no state transition occurs for 10 event loops in a row, this will give up. + */ + public async *mapStateTransitions(mapper: () => R): AsyncIterable { + // Yield the value before any state transitions have occurred. + yield mapper(); + + /** Increment this each time an event loop completes without a state transition. + * If this value hits `10`, end the loop. + * + * Code will test assertions after each state transition. If the assertion hasn't passed and no further state transitions occur, + * then the jest timeout will happen. The timeout doesn't give a useful message about the assertion. + * By short-circuiting this function, code that uses it can short circuit the test timeout and print a useful error message. + * + * NB: the logic to short-circuit the loop is here because knowledge of state is a concern of the simulator, not tests. + */ + let timeoutCount = 0; + while (true) { + /** + * `await` a race between the next state transition and a timeout that happens after `0`ms. + * If the timeout wins, no `dispatch` call caused a state transition in the last loop. + * If this keeps happening, assume that Resolver isn't going to do anything else. + * + * If Resolver adds intentional delay logic (e.g. waiting before making a request), this code might have to change. + * In that case, Resolver should use the side effect context to schedule future work. This code could then subscribe to some event published by the side effect context. That way, this code will be aware of Resolver's intention to do work. + */ + const timedOut: boolean = await Promise.race([ + (async (): Promise => { + await this.stateTransitioned(); + // If a state transition occurs, return false for `timedOut` + return false; + })(), + new Promise((resolve) => { + setTimeout(() => { + // If a timeout occurs, resolve `timedOut` as true + return resolve(true); + }, 0); + }), + ]); + + if (timedOut) { + // If a timout occurred, note it. + timeoutCount++; + if (timeoutCount === 10) { + // if 10 timeouts happen in a row, end the loop early + return; + } + } else { + // If a state transition occurs, reset the timeout count and yield the value + timeoutCount = 0; + yield mapper(); + } + } + } + + /** + * Find a process node element. Takes options supported by `resolverNodeSelector`. + * returns a `ReactWrapper` even if nothing is found, as that is how `enzyme` does things. + */ + public processNodeElements(options: ProcessNodeElementSelectorOptions = {}): ReactWrapper { + return this.findInDOM(processNodeElementSelector(options)); + } + + /** + * true if a process node element is found for the entityID and if it has an [aria-selected] attribute. + */ + public processNodeElementLooksSelected(entityID: string): boolean { + return this.processNodeElements({ entityID, selected: true }).length === 1; + } + + /** + * true if a process node element is found for the entityID and if it *does not have* an [aria-selected] attribute. + */ + public processNodeElementLooksUnselected(entityID: string): boolean { + // find the process node, then exclude it if its selected. + return ( + this.processNodeElements({ entityID }).not( + processNodeElementSelector({ entityID, selected: true }) + ).length === 1 + ); + } + + /** + * Return the selected node query string values. + */ + public queryStringValues(): { selectedNode: string[] } { + const urlSearchParams = new URLSearchParams(this.history.location.search); + return { + selectedNode: urlSearchParams.getAll(`resolver-${this.resolverComponentInstanceID}-id`), + }; + } + + /** + * The element that shows when Resolver is waiting for the graph data. + */ + public graphLoadingElement(): ReactWrapper { + return this.findInDOM('[data-test-subj="resolver:graph:loading"]'); + } + + /** + * The element that shows if Resolver couldn't draw the graph. + */ + public graphErrorElement(): ReactWrapper { + return this.findInDOM('[data-test-subj="resolver:graph:error"]'); + } + + /** + * The element where nodes get drawn. + */ + public graphElement(): ReactWrapper { + return this.findInDOM('[data-test-subj="resolver:graph"]'); + } + + /** + * Like `this.wrapper.find` but only returns DOM nodes. + */ + private findInDOM(selector: string): ReactWrapper { + return this.wrapper.find(selector).filterWhere((wrapper) => typeof wrapper.type() === 'string'); + } +} + +const baseResolverSelector = '[data-test-subj="resolver:node"]'; + +interface ProcessNodeElementSelectorOptions { + /** + * Entity ID of the node. If passed, will be used to create an data-attribute CSS selector that should only get the related node element. + */ + entityID?: string; + /** + * If true, only get nodes with an `[aria-selected="true"]` attribute. + */ + selected?: boolean; +} + +/** + * An `enzyme` supported CSS selector for process node elements. + */ +function processNodeElementSelector({ + entityID, + selected = false, +}: ProcessNodeElementSelectorOptions = {}): string { + let selector: string = baseResolverSelector; + if (entityID !== undefined) { + selector += `[data-test-resolver-node-id="${entityID}"]`; + } + if (selected) { + selector += '[aria-selected="true"]'; + } + return selector; +} diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx new file mode 100644 index 0000000000000..36bb2a5ffc9fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-duplicate-imports */ +/* eslint-disable react/display-name */ + +import React, { useMemo, useEffect, useState, useCallback } from 'react'; +import { Router } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { CoreStart } from '../../../../../../../src/core/public'; +import { ResolverState, SideEffectSimulator, ResolverProps } from '../../types'; +import { ResolverAction } from '../../store/actions'; +import { ResolverWithoutProviders } from '../../view/resolver_without_providers'; +import { SideEffectContext } from '../../view/side_effect_context'; +import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory'; + +type MockResolverProps = { + /** + * Used to simulate a raster width. Defaults to 1600. + */ + rasterWidth?: number; + /** + * Used to simulate a raster height. Defaults to 1200. + */ + rasterHeight?: number; + /** + * Used for the `KibanaContextProvider` + */ + coreStart: CoreStart; + /** + * Used for `react-router`. + */ + history: React.ComponentProps['history']; + /** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */ + store: Store; + /** + * All the props from `ResolverWithoutStore` can be passed. These aren't defaulted to anything (you might want to test what happens when they aren't present.) + */ +} & ResolverProps; + +/** + * This is a mock Resolver component. It is intended to be used with `enzyme` tests via the `Simulator` class. It wraps Resolver in the required providers: + * * `i18n` + * * `Router` using a provided `History` + * * `SideEffectContext.Provider` using side effect simulator it creates + * * `KibanaContextProvider` using a provided `CoreStart` instance + * * `react-redux`'s `Provider` using a provided `Store`. + * + * Resolver needs to measure its size in the DOM. The `SideEffectSimulator` instance can fake the size of an element. + * However in tests, React doesn't have good DOM reconciliation and the root element is often swapped out. When the + * element is replaced, the fake dimensions stop being applied. In order to get around this issue, this component will + * trigger a simulated resize on the root node reference any time it changes. This simulates the layout process a real + * browser would do when an element is attached to the DOM. + */ +export const MockResolver = React.memo((props: MockResolverProps) => { + const [resolverElement, setResolverElement] = useState(null); + + // Get a ref to the underlying Resolver element so we can resize. + // Use a callback function because the underlying DOM node can change. In fact, `enzyme` seems to change it a lot. + const resolverRef = useCallback((element: HTMLDivElement | null) => { + setResolverElement(element); + }, []); + + const simulator: SideEffectSimulator = useMemo(() => sideEffectSimulatorFactory(), []); + + // Resize the Resolver element to match the passed in props. Resolver is size dependent. + useEffect(() => { + if (resolverElement) { + const size: DOMRect = { + width: props.rasterWidth ?? 1600, + height: props.rasterHeight ?? 1200, + x: 0, + y: 0, + bottom: 0, + left: 0, + top: 0, + right: 0, + toJSON() { + return this; + }, + }; + simulator.controls.simulateElementResize(resolverElement, size); + } + }, [props.rasterWidth, props.rasterHeight, simulator.controls, resolverElement]); + + return ( + + + + + + + + + + + + ); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts new file mode 100644 index 0000000000000..45730531cf467 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ResolverAction } from '../store/actions'; +import { SpyMiddleware, SpyMiddlewareStateActionPair } from '../types'; + +/** + * Return a `SpyMiddleware` to be used in testing. Use `debugActions` to console.log actions and the state they produced. + * For reducer/middleware tests, you can use `actions` to get access to each dispatched action along with the state it produced. + */ +export const spyMiddlewareFactory: () => SpyMiddleware = () => { + const resolvers: Set<(stateActionPair: SpyMiddlewareStateActionPair) => void> = new Set(); + + const actions = async function* actions() { + while (true) { + const promise: Promise = new Promise((resolve) => { + resolvers.add(resolve); + }); + yield await promise; + } + }; + + return { + middleware: (api) => (next) => (action: ResolverAction) => { + // handle the action first so we get the state after the reducer + next(action); + + const state = api.getState(); + + // Resolving these promises may cause code to await the next result. That will add more resolve functions to `resolvers`. + // For this reason, copy all the existing resolvers to an array and clear the set. + const oldResolvers = [...resolvers]; + resolvers.clear(); + for (const resolve of oldResolvers) { + resolve({ action, state }); + } + }, + actions, + debugActions() { + let stop: boolean = false; + (async () => { + for await (const actionStatePair of actions()) { + if (stop) { + break; + } + // eslint-disable-next-line no-console + console.log('action', actionStatePair.action, 'state', actionStatePair.state); + } + })(); + return () => { + stop = true; + }; + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 02a890ca13ee8..38e0cd0483559 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -4,10 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable no-duplicate-imports */ + import { Store } from 'redux'; +import { Middleware, Dispatch } from 'redux'; import { BBox } from 'rbush'; import { ResolverAction } from './store/actions'; -import { ResolverEvent, ResolverRelatedEvents, ResolverTree } from '../../common/endpoint/types'; +import { + ResolverEvent, + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../common/endpoint/types'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -30,21 +38,21 @@ export interface ResolverState { } /** - * Piece of redux state that models an animation for the camera. + * Piece of `redux` state that models an animation for the camera. */ export interface ResolverUIState { /** - * The nodeID for the process that is selected (in the aria-activedescendent sense of being selected.) + * The `nodeID` for the process that is selected (in the `aria-activedescendent` sense of being selected.) */ readonly ariaActiveDescendant: string | null; /** - * nodeID of the selected node + * `nodeID` of the selected node */ readonly selectedNode: string | null; } /** - * Piece of redux state that models an animation for the camera. + * Piece of `redux` state that models an animation for the camera. */ export interface CameraAnimationState { /** @@ -68,7 +76,7 @@ export interface CameraAnimationState { } /** - * The redux state for the `useCamera` hook. + * The `redux` state for the `useCamera` hook. */ export type CameraState = { /** @@ -88,7 +96,7 @@ export type CameraState = { readonly translationNotCountingCurrentPanning: Vector2; /** - * The world coordinates that the pointing device was last over. This is used during mousewheel zoom. + * The world coordinates that the pointing device was last over. This is used during mouse-wheel zoom. */ readonly latestFocusedWorldCoordinates: Vector2 | null; } & ( @@ -135,7 +143,7 @@ export type CameraState = { export type IndexedEntity = IndexedEdgeLineSegment | IndexedProcessNode; /** - * The entity stored in rbush for resolver edge lines. + * The entity stored in `rbush` for resolver edge lines. */ export interface IndexedEdgeLineSegment extends BBox { type: 'edgeLine'; @@ -143,7 +151,7 @@ export interface IndexedEdgeLineSegment extends BBox { } /** - * The entity store in rbush for resolver process nodes. + * The entity store in `rbush` for resolver process nodes. */ export interface IndexedProcessNode extends BBox { type: 'processNode'; @@ -160,7 +168,7 @@ export interface VisibleEntites { } /** - * State for `data` reducer which handles receiving Resolver data from the backend. + * State for `data` reducer which handles receiving Resolver data from the back-end. */ export interface DataState { readonly relatedEvents: Map; @@ -213,11 +221,11 @@ export type Vector2 = readonly [number, number]; */ export interface AABB { /** - * Vector who's `x` component is the _left_ side of the AABB and who's `y` component is the _bottom_ side of the AABB. + * Vector who's `x` component is the _left_ side of the `AABB` and who's `y` component is the _bottom_ side of the `AABB`. **/ readonly minimum: Vector2; /** - * Vector who's `x` component is the _right_ side of the AABB and who's `y` component is the _bottom_ side of the AABB. + * Vector who's `x` component is the _right_ side of the `AABB` and who's `y` component is the _bottom_ side of the `AABB`. **/ readonly maximum: Vector2; } @@ -266,7 +274,7 @@ export interface ProcessEvent { } /** - * A represention of a process tree with indices for O(1) access to children and values by id. + * A representation of a process tree with indices for O(1) access to children and values by id. */ export interface IndexedProcessTree { /** @@ -280,7 +288,7 @@ export interface IndexedProcessTree { } /** - * A map of ProcessEvents (representing process nodes) to the 'width' of their subtrees as calculated by `widthsOfProcessSubtrees` + * A map of `ProcessEvents` (representing process nodes) to the 'width' of their subtrees as calculated by `widthsOfProcessSubtrees` */ export type ProcessWidths = Map; /** @@ -318,16 +326,16 @@ export interface DurationDetails { */ export interface EdgeLineMetadata { elapsedTime?: DurationDetails; - // A string of the two joined process nodes concatted together. + // A string of the two joined process nodes concatenated together. uniqueId: string; } /** - * A tuple of 2 vector2 points forming a polyline. Used to connect process nodes in the graph. + * A tuple of 2 vector2 points forming a poly-line. Used to connect process nodes in the graph. */ export type EdgeLinePoints = Vector2[]; /** - * Edge line components including the points joining the edgeline and any optional associated metadata + * Edge line components including the points joining the edge-line and any optional associated metadata */ export interface EdgeLineSegment { points: EdgeLinePoints; @@ -335,7 +343,7 @@ export interface EdgeLineSegment { } /** - * Used to provide precalculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. + * Used to provide pre-calculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. */ export type ProcessWithWidthMetadata = { process: ResolverEvent; @@ -423,11 +431,11 @@ export type ResolverStore = Store; */ export interface IsometricTaxiLayout { /** - * A map of events to position. each event represents its own node. + * A map of events to position. Each event represents its own node. */ processNodePositions: Map; /** - * A map of edgline segments, which graphically connect nodes. + * A map of edge-line segments, which graphically connect nodes. */ edgeLineSegments: EdgeLineSegment[]; @@ -436,3 +444,91 @@ export interface IsometricTaxiLayout { */ ariaLevels: Map; } + +/** + * An object with methods that can be used to access data from the Kibana server. + * This is injected into Resolver. + * This allows tests to provide a mock data access layer. + * In the future, other implementations of Resolver could provide different data access layers. + */ +export interface DataAccessLayer { + /** + * Fetch related events for an entity ID + */ + relatedEvents: (entityID: string) => Promise; + + /** + * Fetch a ResolverTree for a entityID + */ + resolverTree: (entityID: string, signal: AbortSignal) => Promise; + + /** + * Get an array of index patterns that contain events. + */ + indexPatterns: () => string[]; + + /** + * Get entities matching a document. + */ + entities: (parameters: { + /** _id of the document to find an entity in. */ + _id: string; + /** indices to search in */ + indices: string[]; + /** signal to abort the request */ + signal: AbortSignal; + }) => Promise; +} + +/** + * The externally provided React props. + */ +export interface ResolverProps { + /** + * Used by `styled-components`. + */ + className?: string; + /** + * The `_id` value of an event in ES. + * Used as the origin of the Resolver graph. + */ + databaseDocumentID?: string; + /** + * A string literal describing where in the application resolver is located. + * Used to prevent collisions in things like query parameters. + */ + resolverComponentInstanceID: string; +} + +/** + * Used by `SpyMiddleware`. + */ +export interface SpyMiddlewareStateActionPair { + /** An action dispatched, `state` is the state that the reducer returned when handling this action. + */ + action: ResolverAction; + /** + * A resolver state that was returned by the reducer when handling `action`. + */ + state: ResolverState; +} + +/** + * A wrapper object that has a middleware along with an async generator that returns the actions dispatched to the store (along with state.) + */ +export interface SpyMiddleware { + /** + * A middleware to use with `applyMiddleware`. + */ + middleware: Middleware<{}, ResolverState, Dispatch>; + /** + * A generator that returns all state and action pairs that pass through the middleware. + */ + actions: () => AsyncGenerator; + + /** + * Prints actions to the console. + * Call the returned function to stop debugging. + */ + debugActions: () => () => void; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx new file mode 100644 index 0000000000000..9cb900736677e --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { oneAncestorTwoChildren } from '../data_access_layer/mocks/one_ancestor_two_children'; +import { Simulator } from '../test_utilities/simulator'; +// Extend jest with a custom matcher +import '../test_utilities/extend_jest'; + +describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', () => { + let simulator: Simulator; + let databaseDocumentID: string; + let entityIDs: { origin: string; firstChild: string; secondChild: string }; + + // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances + const resolverComponentInstanceID = 'resolverComponentInstanceID'; + + beforeEach(async () => { + // create a mock data access layer + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren(); + + // save a reference to the entity IDs exposed by the mock data layer + entityIDs = dataAccessLayerMetadata.entityIDs; + + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + }); + + describe('when it has loaded', () => { + beforeEach(async () => { + await expect( + /** + * It's important that all of these are done in a single `expect`. + * If you do them concurrently with each other, you'll have incorrect results. + * + * For example, there might be no loading element at one point, and 1 graph element at one point, but never a single time when there is both 1 graph element and 0 loading elements. + */ + simulator.mapStateTransitions(() => ({ + graphElements: simulator.graphElement().length, + graphLoadingElements: simulator.graphLoadingElement().length, + graphErrorElements: simulator.graphErrorElement().length, + })) + ).toYieldEqualTo({ + // it should have 1 graph element, an no error or loading elements. + graphElements: 1, + graphLoadingElements: 0, + graphErrorElements: 0, + }); + }); + + // Combining assertions here for performance. Unfortunately, Enzyme + jsdom + React is slow. + it(`should have 3 nodes, with the entityID's 'origin', 'firstChild', and 'secondChild'. 'origin' should be selected.`, async () => { + expect(simulator.processNodeElementLooksSelected(entityIDs.origin)).toBe(true); + + expect(simulator.processNodeElementLooksUnselected(entityIDs.firstChild)).toBe(true); + expect(simulator.processNodeElementLooksUnselected(entityIDs.secondChild)).toBe(true); + + expect(simulator.processNodeElements().length).toBe(3); + }); + + describe("when the second child node's first button has been clicked", () => { + beforeEach(() => { + // Click the first button under the second child element. + simulator + .processNodeElements({ entityID: entityIDs.secondChild }) + .find('button') + .simulate('click'); + }); + it('should render the second child node as selected, and the first child not as not selected, and the query string should indicate that the second child is selected', async () => { + await expect( + simulator.mapStateTransitions(function value() { + return { + // the query string has a key showing that the second child is selected + queryStringSelectedNode: simulator.queryStringValues().selectedNode, + // the second child is rendered in the DOM, and shows up as selected + secondChildLooksSelected: simulator.processNodeElementLooksSelected( + entityIDs.secondChild + ), + // the origin is in the DOM, but shows up as unselected + originLooksUnselected: simulator.processNodeElementLooksUnselected(entityIDs.origin), + }; + }) + ).toYieldEqualTo({ + // Just the second child should be marked as selected in the query string + queryStringSelectedNode: [entityIDs.secondChild], + // The second child is rendered and has `[aria-selected]` + secondChildLooksSelected: true, + // The origin child is rendered and doesn't have `[aria-selected]` + originLooksUnselected: true, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index c1ffa42d02abb..d9a0bf291d0e4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -7,50 +7,29 @@ import React, { useMemo } from 'react'; import { Provider } from 'react-redux'; -import { ResolverMap } from './map'; import { storeFactory } from '../store'; import { StartServices } from '../../types'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { DataAccessLayer, ResolverProps } from '../types'; +import { dataAccessLayerFactory } from '../data_access_layer/factory'; +import { ResolverWithoutProviders } from './resolver_without_providers'; /** - * The top level, unconnected, Resolver component. + * The `Resolver` component to use. This sets up the DataAccessLayer provider. Use `ResolverWithoutProviders` in tests or in other scenarios where you want to provide a different (or fake) data access layer. */ -export const Resolver = React.memo(function ({ - className, - databaseDocumentID, - resolverComponentInstanceID, -}: { - /** - * Used by `styled-components`. - */ - className?: string; - /** - * The `_id` value of an event in ES. - * Used as the origin of the Resolver graph. - */ - databaseDocumentID?: string; - /** - * A string literal describing where in the app resolver is located, - * used to prevent collisions in things like query params - */ - resolverComponentInstanceID: string; -}) { +export const Resolver = React.memo((props: ResolverProps) => { const context = useKibana(); + const dataAccessLayer: DataAccessLayer = useMemo(() => dataAccessLayerFactory(context), [ + context, + ]); + const store = useMemo(() => { - return storeFactory(context); - }, [context]); + return storeFactory(dataAccessLayer); + }, [dataAccessLayer]); - /** - * Setup the store and use `Provider` here. This allows the ResolverMap component to - * dispatch actions and read from state. - */ return ( - + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx index 29c7676d2167d..7b5eb13359dbb 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx @@ -1,184 +1,184 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { memo, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { i18n } from '@kbn/i18n'; -import { - htmlIdGenerator, - EuiSpacer, - EuiTitle, - EuiText, - EuiTextColor, - EuiDescriptionList, -} from '@elastic/eui'; -import styled from 'styled-components'; -import { FormattedMessage } from 'react-intl'; -import * as selectors from '../../store/selectors'; -import * as event from '../../../../common/endpoint/models/event'; -import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities'; -import { - processPath, - processPid, - userInfoForProcess, - processParentPid, - md5HashForProcess, - argsForProcess, -} from '../../models/process_event'; -import { CubeForProcess } from './process_cube_icon'; -import { ResolverEvent } from '../../../../common/endpoint/types'; -import { useResolverTheme } from '../assets'; - -const StyledDescriptionList = styled(EuiDescriptionList)` - &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { - max-width: 10em; - } -`; - -/** - * A description list view of all the Metadata that goes with a particular process event, like: - * Created, Pid, User/Domain, etc. - */ -export const ProcessDetails = memo(function ProcessDetails({ - processEvent, - pushToQueryParams, -}: { - processEvent: ResolverEvent; - pushToQueryParams: (queryStringKeyValuePair: CrumbInfo) => unknown; -}) { - const processName = event.eventName(processEvent); - const entityId = event.entityId(processEvent); - const isProcessTerminated = useSelector(selectors.isProcessTerminated)(entityId); - const processInfoEntry = useMemo(() => { - const eventTime = event.eventTimestamp(processEvent); - const dateTime = eventTime ? formatDate(eventTime) : ''; - - const createdEntry = { - title: '@timestamp', - description: dateTime, - }; - - const pathEntry = { - title: 'process.executable', - description: processPath(processEvent), - }; - - const pidEntry = { - title: 'process.pid', - description: processPid(processEvent), - }; - - const userEntry = { - title: 'user.name', - description: (userInfoForProcess(processEvent) as { name: string }).name, - }; - - const domainEntry = { - title: 'user.domain', - description: (userInfoForProcess(processEvent) as { domain: string }).domain, - }; - - const parentPidEntry = { - title: 'process.parent.pid', - description: processParentPid(processEvent), - }; - - const md5Entry = { - title: 'process.hash.md5', - description: md5HashForProcess(processEvent), - }; - - const commandLineEntry = { - title: 'process.args', - description: argsForProcess(processEvent), - }; - - // This is the data in {title, description} form for the EUIDescriptionList to display - const processDescriptionListData = [ - createdEntry, - pathEntry, - pidEntry, - userEntry, - domainEntry, - parentPidEntry, - md5Entry, - commandLineEntry, - ] - .filter((entry) => { - return entry.description; - }) - .map((entry) => { - return { - ...entry, - description: String(entry.description), - }; - }); - - return processDescriptionListData; - }, [processEvent]); - - const crumbs = useMemo(() => { - return [ - { - text: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.processDescList.events', - { - defaultMessage: 'Events', - } - ), - onClick: () => { - pushToQueryParams({ crumbId: '', crumbEvent: '' }); - }, - }, - { - text: ( - <> - - - ), - onClick: () => {}, - }, - ]; - }, [processName, pushToQueryParams]); - const { cubeAssetsForNode } = useResolverTheme(); - const { descriptionText } = useMemo(() => { - if (!processEvent) { - return { descriptionText: '' }; - } - return cubeAssetsForNode(isProcessTerminated, false); - }, [processEvent, cubeAssetsForNode, isProcessTerminated]); - - const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []); - return ( - <> - - - -

- - {processName} -

-
- - - {descriptionText} - - - - - - ); -}); -ProcessDetails.displayName = 'ProcessDetails'; +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { + htmlIdGenerator, + EuiSpacer, + EuiTitle, + EuiText, + EuiTextColor, + EuiDescriptionList, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { FormattedMessage } from 'react-intl'; +import * as selectors from '../../store/selectors'; +import * as event from '../../../../common/endpoint/models/event'; +import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities'; +import { + processPath, + processPid, + userInfoForProcess, + processParentPid, + md5HashForProcess, + argsForProcess, +} from '../../models/process_event'; +import { CubeForProcess } from './process_cube_icon'; +import { ResolverEvent } from '../../../../common/endpoint/types'; +import { useResolverTheme } from '../assets'; + +const StyledDescriptionList = styled(EuiDescriptionList)` + &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { + max-width: 10em; + } +`; + +/** + * A description list view of all the Metadata that goes with a particular process event, like: + * Created, PID, User/Domain, etc. + */ +export const ProcessDetails = memo(function ProcessDetails({ + processEvent, + pushToQueryParams, +}: { + processEvent: ResolverEvent; + pushToQueryParams: (queryStringKeyValuePair: CrumbInfo) => unknown; +}) { + const processName = event.eventName(processEvent); + const entityId = event.entityId(processEvent); + const isProcessTerminated = useSelector(selectors.isProcessTerminated)(entityId); + const processInfoEntry = useMemo(() => { + const eventTime = event.eventTimestamp(processEvent); + const dateTime = eventTime ? formatDate(eventTime) : ''; + + const createdEntry = { + title: '@timestamp', + description: dateTime, + }; + + const pathEntry = { + title: 'process.executable', + description: processPath(processEvent), + }; + + const pidEntry = { + title: 'process.pid', + description: processPid(processEvent), + }; + + const userEntry = { + title: 'user.name', + description: userInfoForProcess(processEvent)?.name, + }; + + const domainEntry = { + title: 'user.domain', + description: userInfoForProcess(processEvent)?.domain, + }; + + const parentPidEntry = { + title: 'process.parent.pid', + description: processParentPid(processEvent), + }; + + const md5Entry = { + title: 'process.hash.md5', + description: md5HashForProcess(processEvent), + }; + + const commandLineEntry = { + title: 'process.args', + description: argsForProcess(processEvent), + }; + + // This is the data in {title, description} form for the EUIDescriptionList to display + const processDescriptionListData = [ + createdEntry, + pathEntry, + pidEntry, + userEntry, + domainEntry, + parentPidEntry, + md5Entry, + commandLineEntry, + ] + .filter((entry) => { + return entry.description; + }) + .map((entry) => { + return { + ...entry, + description: String(entry.description), + }; + }); + + return processDescriptionListData; + }, [processEvent]); + + const crumbs = useMemo(() => { + return [ + { + text: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processDescList.events', + { + defaultMessage: 'Events', + } + ), + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + { + text: ( + <> + + + ), + onClick: () => {}, + }, + ]; + }, [processName, pushToQueryParams]); + const { cubeAssetsForNode } = useResolverTheme(); + const { descriptionText } = useMemo(() => { + if (!processEvent) { + return { descriptionText: '' }; + } + return cubeAssetsForNode(isProcessTerminated, false); + }, [processEvent, cubeAssetsForNode, isProcessTerminated]); + + const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []); + return ( + <> + + + +

+ + {processName} +

+
+ + + {descriptionText} + + + + + + ); +}); +ProcessDetails.displayName = 'ProcessDetails'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index aed292e4a39d1..24de45ee894dc 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -195,7 +195,7 @@ const UnstyledProcessEventDot = React.memo( * `beginElement` is by [w3](https://www.w3.org/TR/SVG11/animate.html#__smil__ElementTimeControl__beginElement) * but missing in [TSJS-lib-generator](https://github.com/microsoft/TSJS-lib-generator/blob/15a4678e0ef6de308e79451503e444e9949ee849/inputfiles/addedTypes.json#L1819) */ - beginElement: () => void; + beginElement?: () => void; }) | null; } = React.createRef(); @@ -238,10 +238,8 @@ const UnstyledProcessEventDot = React.memo( const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { - if (animationTarget.current !== null) { - // This works but the types are missing in the typescript DOM lib - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (animationTarget.current as any).beginElement(); + if (animationTarget.current?.beginElement) { + animationTarget.current.beginElement(); } dispatch({ type: 'userSelectedResolverNode', @@ -297,7 +295,8 @@ const UnstyledProcessEventDot = React.memo( */ return (
{ handleFocus(); handleClick(); - } /* a11y note: this is strictly an alternate to the button, so no tabindex is necessary*/ + } /* a11y note: this is strictly an alternate to the button, so no tabindex is necessary*/ } role="img" aria-labelledby={labelHTMLID} diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx new file mode 100644 index 0000000000000..f444d5a25e1ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-duplicate-imports */ + +/* eslint-disable react/display-name */ + +import React, { useContext, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { useEffectOnce } from 'react-use'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as selectors from '../store/selectors'; +import { EdgeLine } from './edge_line'; +import { GraphControls } from './graph_controls'; +import { ProcessEventDot } from './process_event_dot'; +import { useCamera } from './use_camera'; +import { SymbolDefinitions, useResolverTheme } from './assets'; +import { useStateSyncingActions } from './use_state_syncing_actions'; +import { useResolverQueryParams } from './use_resolver_query_params'; +import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; +import { entityId } from '../../../common/endpoint/models/event'; +import { SideEffectContext } from './side_effect_context'; +import { ResolverProps } from '../types'; + +/** + * The highest level connected Resolver component. Needs a `Provider` in its ancestry to work. + */ +export const ResolverWithoutProviders = React.memo( + /** + * Use `forwardRef` so that the `Simulator` used in testing can access the top level DOM element. + */ + React.forwardRef(function ( + { className, databaseDocumentID, resolverComponentInstanceID }: ResolverProps, + refToForward + ) { + /** + * This is responsible for dispatching actions that include any external data. + * `databaseDocumentID` + */ + useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); + + const { timestamp } = useContext(SideEffectContext); + + // use this for the entire render in order to keep things in sync + const timeAtRender = timestamp(); + + const { processNodePositions, connectingEdgeLineSegments } = useSelector( + selectors.visibleNodesAndEdgeLines + )(timeAtRender); + const terminatedProcesses = useSelector(selectors.terminatedProcesses); + const { projectionMatrix, ref: cameraRef, onMouseDown } = useCamera(); + + const ref = useCallback( + (element: HTMLDivElement | null) => { + // Supply `useCamera` with the ref + cameraRef(element); + + // If a ref is being forwarded, populate that as well. + if (typeof refToForward === 'function') { + refToForward(element); + } else if (refToForward !== null) { + refToForward.current = element; + } + }, + [cameraRef, refToForward] + ); + const isLoading = useSelector(selectors.isLoading); + const hasError = useSelector(selectors.hasError); + const activeDescendantId = useSelector(selectors.ariaActiveDescendant); + const { colorMap } = useResolverTheme(); + const { cleanUpQueryParams } = useResolverQueryParams(); + + useEffectOnce(() => { + return () => cleanUpQueryParams(); + }); + + return ( + + {isLoading ? ( +
+ +
+ ) : hasError ? ( +
+
+ {' '} + +
+
+ ) : ( + + {connectingEdgeLineSegments.map( + ({ points: [startPosition, endPosition], metadata }) => ( + + ) + )} + {[...processNodePositions].map(([processEvent, position]) => { + const processEntityId = entityId(processEvent); + return ( + + ); + })} + + )} + + + +
+ ); + }) +); diff --git a/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator.ts b/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts similarity index 95% rename from x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator.ts rename to x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts index 5e9073ba2d3c9..25be222e2fe4a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts @@ -11,12 +11,13 @@ import { SideEffectSimulator } from '../types'; * Create mock `SideEffectors` for `SideEffectContext.Provider`. The `control` * object is used to control the mocks. */ -export const sideEffectSimulator: () => SideEffectSimulator = () => { +export const sideEffectSimulatorFactory: () => SideEffectSimulator = () => { // The set of mock `ResizeObserver` instances that currently exist const resizeObserverInstances: Set = new Set(); // A map of `Element`s to their fake `DOMRect`s - const contentRects: Map = new Map(); + // Use a `WeakMap` since elements can be removed from the DOM. + const contentRects: WeakMap = new Map(); /** * Simulate an element's size changing. This will trigger any `ResizeObserverCallback`s which diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index a27f157bc9364..b32d63283b547 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -10,15 +10,16 @@ import { renderHook, act as hooksAct } from '@testing-library/react-hooks'; import { useCamera, useAutoUpdatingClientRect } from './use_camera'; import { Provider } from 'react-redux'; import * as selectors from '../store/selectors'; -import { storeFactory } from '../store'; import { Matrix3, ResolverStore, SideEffectSimulator } from '../types'; import { ResolverEvent } from '../../../common/endpoint/types'; import { SideEffectContext } from './side_effect_context'; import { applyMatrix3 } from '../models/vector2'; -import { sideEffectSimulator } from './side_effect_simulator'; +import { sideEffectSimulatorFactory } from './side_effect_simulator_factory'; import { mockProcessEvent } from '../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../models/resolver_tree'; import { ResolverAction } from '../store/actions'; +import { createStore } from 'redux'; +import { resolverReducer } from '../store/reducer'; describe('useCamera on an unpainted element', () => { let element: HTMLElement; @@ -29,7 +30,7 @@ describe('useCamera on an unpainted element', () => { let simulator: SideEffectSimulator; beforeEach(async () => { - store = storeFactory(); + store = createStore(resolverReducer); const Test = function Test() { const camera = useCamera(); @@ -38,7 +39,7 @@ describe('useCamera on an unpainted element', () => { return
; }; - simulator = sideEffectSimulator(); + simulator = sideEffectSimulatorFactory(); reactRenderResult = render( diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index 84d954de6ef27..ed514a61d4e06 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -20,12 +20,12 @@ export function useResolverQueryParams() { const history = useHistory(); const urlSearch = useLocation().search; const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); - const uniqueCrumbIdKey: string = `resolver-id:${resolverComponentInstanceID}`; - const uniqueCrumbEventKey: string = `resolver-event:${resolverComponentInstanceID}`; + const uniqueCrumbIdKey: string = `resolver-${resolverComponentInstanceID}-id`; + const uniqueCrumbEventKey: string = `resolver-${resolverComponentInstanceID}-event`; const pushToQueryParams = useCallback( (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` + // Construct a new set of parameters from the current set (minus empty parameters) + // by assigning the new set of parameters provided in `newCrumbs` const crumbsToPass = { ...querystring.parse(urlSearch.slice(1)), [uniqueCrumbIdKey]: newCrumbs.crumbId, From a6a09370625240eb1891613b8b39a18485d7ded3 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Tue, 28 Jul 2020 21:24:04 -0400 Subject: [PATCH 10/10] [Security Solution] Validate exception list size when adding new items (#73399) * Validate exception list size when adding new items * Update comment * Extract list size validation and apply to endpoint route also Co-authored-by: Elastic Machine --- x-pack/plugins/lists/common/constants.ts | 2 + .../routes/create_endpoint_list_item_route.ts | 15 ++++- .../create_exception_list_item_route.ts | 13 +++++ .../plugins/lists/server/routes/validate.ts | 56 +++++++++++++++++++ .../delete_exception_list_item.ts | 16 ++++++ .../exception_lists/exception_list_client.ts | 15 ++++- .../exception_list_client_types.ts | 6 ++ .../lib/detection_engine/signals/utils.ts | 3 +- 8 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/lists/server/routes/validate.ts diff --git a/x-pack/plugins/lists/common/constants.ts b/x-pack/plugins/lists/common/constants.ts index df16085b53405..6c73dc1656302 100644 --- a/x-pack/plugins/lists/common/constants.ts +++ b/x-pack/plugins/lists/common/constants.ts @@ -48,3 +48,5 @@ export const ENDPOINT_LIST_NAME = 'Elastic Endpoint Security Exception List'; /** The description of the single global space agnostic endpoint list */ export const ENDPOINT_LIST_DESCRIPTION = 'Elastic Endpoint Security Exception List'; + +export const MAX_EXCEPTION_LIST_SIZE = 10000; diff --git a/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts index 5ff2a9d9df9f4..22aa1fb59858b 100644 --- a/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts @@ -6,7 +6,7 @@ import { IRouter } from 'kibana/server'; -import { ENDPOINT_LIST_ITEM_URL } from '../../common/constants'; +import { ENDPOINT_LIST_ID, ENDPOINT_LIST_ITEM_URL } from '../../common/constants'; import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps'; import { validate } from '../../common/siem_common_deps'; import { @@ -16,6 +16,7 @@ import { } from '../../common/schemas'; import { getExceptionListClient } from './utils/get_exception_list_client'; +import { validateExceptionListSize } from './validate'; export const createEndpointListItemRoute = (router: IRouter): void => { router.post( @@ -71,6 +72,18 @@ export const createEndpointListItemRoute = (router: IRouter): void => { if (errors != null) { return siemResponse.error({ body: errors, statusCode: 500 }); } else { + const listSizeError = await validateExceptionListSize( + exceptionLists, + ENDPOINT_LIST_ID, + 'agnostic' + ); + if (listSizeError != null) { + await exceptionLists.deleteExceptionListItemById({ + id: createdList.id, + namespaceType: 'agnostic', + }); + return siemResponse.error(listSizeError); + } return response.ok({ body: validated ?? {} }); } } diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts index e4885c7393bd4..ed58621dae973 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts @@ -17,6 +17,7 @@ import { import { getExceptionListClient } from './utils/get_exception_list_client'; import { endpointDisallowedFields } from './endpoint_disallowed_fields'; +import { validateExceptionListSize } from './validate'; export const createExceptionListItemRoute = (router: IRouter): void => { router.post( @@ -104,6 +105,18 @@ export const createExceptionListItemRoute = (router: IRouter): void => { if (errors != null) { return siemResponse.error({ body: errors, statusCode: 500 }); } else { + const listSizeError = await validateExceptionListSize( + exceptionLists, + listId, + namespaceType + ); + if (listSizeError != null) { + await exceptionLists.deleteExceptionListItemById({ + id: createdList.id, + namespaceType, + }); + return siemResponse.error(listSizeError); + } return response.ok({ body: validated ?? {} }); } } diff --git a/x-pack/plugins/lists/server/routes/validate.ts b/x-pack/plugins/lists/server/routes/validate.ts new file mode 100644 index 0000000000000..bbd4b0eaf0e33 --- /dev/null +++ b/x-pack/plugins/lists/server/routes/validate.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExceptionListClient } from '../services/exception_lists/exception_list_client'; +import { MAX_EXCEPTION_LIST_SIZE } from '../../common/constants'; +import { foundExceptionListItemSchema } from '../../common/schemas'; +import { NamespaceType } from '../../common/schemas/types'; +import { validate } from '../../common/siem_common_deps'; + +export const validateExceptionListSize = async ( + exceptionLists: ExceptionListClient, + listId: string, + namespaceType: NamespaceType +): Promise<{ body: string; statusCode: number } | null> => { + const exceptionListItems = await exceptionLists.findExceptionListItem({ + filter: undefined, + listId, + namespaceType, + page: undefined, + perPage: undefined, + sortField: undefined, + sortOrder: undefined, + }); + if (exceptionListItems == null) { + // If exceptionListItems is null then we couldn't find the list so it may have been deleted + return { + body: `Unable to find list id: ${listId} to verify max exception list size`, + statusCode: 500, + }; + } + const [validatedItems, err] = validate(exceptionListItems, foundExceptionListItemSchema); + if (err != null) { + return { + body: err, + statusCode: 500, + }; + } + // Unnecessary since validatedItems comes from exceptionListItems which is already + // checked for null, but typescript fails to detect that + if (validatedItems == null) { + return { + body: `Unable to find list id: ${listId} to verify max exception list size`, + statusCode: 500, + }; + } + if (validatedItems.total > MAX_EXCEPTION_LIST_SIZE) { + return { + body: `Failed to add exception item, exception list would exceed max size of ${MAX_EXCEPTION_LIST_SIZE}`, + statusCode: 400, + }; + } + return null; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts index 8dce1f1f79e35..ee85cf36a48b5 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts @@ -8,6 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { ExceptionListItemSchema, + Id, IdOrUndefined, ItemIdOrUndefined, NamespaceType, @@ -23,6 +24,12 @@ interface DeleteExceptionListItemOptions { savedObjectsClient: SavedObjectsClientContract; } +interface DeleteExceptionListItemByIdOptions { + id: Id; + namespaceType: NamespaceType; + savedObjectsClient: SavedObjectsClientContract; +} + export const deleteExceptionListItem = async ({ itemId, id, @@ -43,3 +50,12 @@ export const deleteExceptionListItem = async ({ return exceptionListItem; } }; + +export const deleteExceptionListItemById = async ({ + id, + namespaceType, + savedObjectsClient, +}: DeleteExceptionListItemByIdOptions): Promise => { + const savedObjectType = getSavedObjectType({ namespaceType }); + await savedObjectsClient.delete(savedObjectType, id); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 11302e64b3538..83b44ababf9de 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -20,6 +20,7 @@ import { CreateExceptionListItemOptions, CreateExceptionListOptions, DeleteEndpointListItemOptions, + DeleteExceptionListItemByIdOptions, DeleteExceptionListItemOptions, DeleteExceptionListOptions, FindEndpointListItemOptions, @@ -40,7 +41,7 @@ import { createExceptionListItem } from './create_exception_list_item'; import { updateExceptionList } from './update_exception_list'; import { updateExceptionListItem } from './update_exception_list_item'; import { deleteExceptionList } from './delete_exception_list'; -import { deleteExceptionListItem } from './delete_exception_list_item'; +import { deleteExceptionListItem, deleteExceptionListItemById } from './delete_exception_list_item'; import { findExceptionListItem } from './find_exception_list_item'; import { findExceptionList } from './find_exception_list'; import { findExceptionListsItem } from './find_exception_list_items'; @@ -326,6 +327,18 @@ export class ExceptionListClient { }); }; + public deleteExceptionListItemById = async ({ + id, + namespaceType, + }: DeleteExceptionListItemByIdOptions): Promise => { + const { savedObjectsClient } = this; + return deleteExceptionListItemById({ + id, + namespaceType, + savedObjectsClient, + }); + }; + /** * This is the same as "deleteExceptionListItem" except it applies specifically to the endpoint list. */ diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 555b9c5e95a77..963716b55ea77 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -19,6 +19,7 @@ import { ExceptionListType, ExceptionListTypeOrUndefined, FilterOrUndefined, + Id, IdOrUndefined, Immutable, ItemId, @@ -93,6 +94,11 @@ export interface DeleteExceptionListItemOptions { namespaceType: NamespaceType; } +export interface DeleteExceptionListItemByIdOptions { + id: Id; + namespaceType: NamespaceType; +} + export interface DeleteEndpointListItemOptions { id: IdOrUndefined; itemId: ItemIdOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 90373ee676121..ae4274f31e145 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -15,6 +15,7 @@ import { ListArrayOrUndefined } from '../../../../common/detection_engine/schema import { BulkResponse, BulkResponseErrorAggregation, isValidUnit } from './types'; import { BuildRuleMessage } from './rule_messages'; import { hasLargeValueList } from '../../../../common/detection_engine/utils'; +import { MAX_EXCEPTION_LIST_SIZE } from '../../../../../lists/common/constants'; interface SortExceptionsReturn { exceptionsWithValueLists: ExceptionListItemSchema[]; @@ -183,7 +184,7 @@ export const getExceptions = async ({ listId: foundList.list_id, namespaceType, page: 1, - perPage: 10000, + perPage: MAX_EXCEPTION_LIST_SIZE, filter: undefined, sortOrder: undefined, sortField: undefined,