diff --git a/dev_docs/tutorials/data/search.mdx b/dev_docs/tutorials/data/search.mdx index 69b4d5dab58b5..9cf46bb96c72a 100644 --- a/dev_docs/tutorials/data/search.mdx +++ b/dev_docs/tutorials/data/search.mdx @@ -355,7 +355,7 @@ export class SearchEmbeddable this.updateOutput({ loading: true, error: undefined }); // Make the request, wait for the final result - const resp = await searchSource.fetch$({ + const {rawResponse: resp} = await searchSource.fetch$({ sessionId: searchSessionId, }).toPromise(); diff --git a/docs/apm/apm-app-users.asciidoc b/docs/apm/apm-app-users.asciidoc index 3f0a42251304c..9b8a9c64ac43b 100644 --- a/docs/apm/apm-app-users.asciidoc +++ b/docs/apm/apm-app-users.asciidoc @@ -10,7 +10,7 @@ Users and privileges ++++ -You can use role-based access control to grant users access to secured +Use role-based access control to grant users access to secured resources. The roles that you set up depend on your organization's security requirements and the minimum privileges required to use specific features. @@ -24,6 +24,13 @@ In general, there are three types of privileges you'll work with: * **Elasticsearch index privileges**: Control access to the data in specific indices your cluster. * **Kibana space privileges**: Grant users write or read access to features and apps within Kibana. +Select your use-case to get started: + +* <> +* <> +* <> +* <> + //// *********************************** *********************************** //// @@ -36,13 +43,25 @@ In general, there are three types of privileges you'll work with: Create an APM reader user ++++ +APM reader users typically need to view the APM app and dashboards and visualizations that use APM data. +These users might also need to create and edit dashboards, visualizations, and machine learning jobs. + [[apm-app-reader-full]] -==== Full APM reader +==== APM reader -APM reader users typically need to view the APM app, dashboards, and visualizations that contain APM data. -These users might also need to create and edit dashboards, visualizations, and machine learning jobs. +To create an APM reader user: + +. Create a new role, named something like `read-apm`, and assign the following privileges: ++ +-- +include::./tab-widgets/apm-app-reader/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following built-in roles: +. Assign the `read-apm` role created in the previous step, and the following built-in roles to +any APM reader users: + [options="header"] |==== @@ -51,9 +70,6 @@ These users might also need to create and edit dashboards, visualizations, and m |`kibana_admin` |Grants access to all features in Kibana. -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices - |`machine_learning_admin` |Grants the privileges required to create, update, and view machine learning jobs |==== @@ -63,14 +79,14 @@ These users might also need to create and edit dashboards, visualizations, and m In some instances, you may wish to restrict certain Kibana apps that a user has access to. -. Assign the following built in roles: +. Create a new role, named something like `read-apm-partial`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/apm-app-reader/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. . Assign space privileges to any Kibana space that the user needs access to. Here are two examples: @@ -98,6 +114,8 @@ Here are two examples: |Grants the privileges required to create, update, and view machine learning jobs |==== +include::./tab-widgets/code.asciidoc[] + //// *********************************** *********************************** //// @@ -138,7 +156,7 @@ and assign the following privileges: ^1^ +\{ANNOTATION_INDEX\}+ should be the index name you've defined in <>. -. Assign the `annotation_user` created previously, and the built-in roles necessary to create +. Assign the `annotation_user` created previously, and the roles and privileges necessary to create a <> or <> APM reader to any users that need to view annotations in the APM app [[apm-app-annotation-api]] @@ -163,17 +181,17 @@ See <>. Central configuration users need to be able to view, create, update, and delete Agent configurations. -. Assign the following built-in roles: +. Create a new role, named something like `central-config-manager`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose - -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/central-config-users/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following Kibana space privileges: +. Assign the `central-config-manager` role created in the previous step, and the following Kibana space privileges to +anyone who needs to manage central configurations: + [options="header"] |==== @@ -190,16 +208,17 @@ Central configuration users need to be able to view, create, update, and delete In some instances, you may wish to create a user that can only read central configurations, but not create, update, or delete them. -. Assign the following built-in roles: +. Create a new role, named something like `central-config-reader`, and assign the following privileges: + -[options="header"] -|==== -|Role | Purpose -|`apm_user` -|Grants the privileges required for APM users on +{beat_default_index_prefix}*+ indices -|==== +-- +include::./tab-widgets/central-config-users/widget.asciidoc[] +-- ++ +TIP: Using the {apm-server-ref-v}/apm-integration.html[APM integration for Elastic Agent]? +Add the privileges under the **Data streams** tab. -. Assign the following Kibana space privileges: +. Assign the `central-config-reader` role created in the previous step, and the following Kibana space privileges to +anyone who needs to read central configurations: + [options="header"] |==== @@ -215,6 +234,8 @@ but not create, update, or delete them. See <>. +include::./tab-widgets/code.asciidoc[] + //// *********************************** *********************************** //// diff --git a/docs/apm/tab-widgets/apm-app-reader/content.asciidoc b/docs/apm/tab-widgets/apm-app-reader/content.asciidoc new file mode 100644 index 0000000000000..6b9c996035f6c --- /dev/null +++ b/docs/apm/tab-widgets/apm-app-reader/content.asciidoc @@ -0,0 +1,45 @@ +// tag::classic-indices[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-*` +|Read-only access to `apm-*` data + +|Index +|`view_index_metadata` on `apm-*` +|Read-only access to `apm-*` index metadata +|==== +// end::classic-indices[] + +// tag::data-streams[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `logs-apm*` +|Read-only access to `logs-apm*` data + +|Index +|`view_index_metadata` on `logs-apm*` +|Read-only access to `logs-apm*` index metadata + +|Index +|`read` on `metrics-apm*` +|Read-only access to `metrics-apm*` data + +|Index +|`view_index_metadata` on `metrics-apm*` +|Read-only access to `metrics-apm*` index metadata + +|Index +|`read` on `traces-apm*` +|Read-only access to `traces-apm*` data + +|Index +|`view_index_metadata` on `traces-apm*` +|Read-only access to `traces-apm*` index metadata +|==== +// end::data-streams[] diff --git a/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc b/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc new file mode 100644 index 0000000000000..51c01367786b6 --- /dev/null +++ b/docs/apm/tab-widgets/apm-app-reader/widget.asciidoc @@ -0,0 +1,40 @@ +++++ +
+
+ + +
+
+++++ + +include::content.asciidoc[tag=classic-indices] + +++++ +
+ +
+++++ \ No newline at end of file diff --git a/docs/apm/tab-widgets/central-config-users/content.asciidoc b/docs/apm/tab-widgets/central-config-users/content.asciidoc new file mode 100644 index 0000000000000..0945050d9a861 --- /dev/null +++ b/docs/apm/tab-widgets/central-config-users/content.asciidoc @@ -0,0 +1,53 @@ +// tag::classic-indices[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-*` +|Read-only access to `apm-*` data + +|Index +|`view_index_metadata` on `apm-*` +|Read-only access to `apm-*` index metadata +|==== +// end::classic-indices[] + +// tag::data-streams[] +[options="header"] +|==== +|Type |Privilege |Purpose + +|Index +|`read` on `apm-agent-configuration` +|Read-only access to `apm-agent-configuration` data + +|Index +|`view_index_metadata` on `apm-agent-configuration` +|Read-only access to `apm-agent-configuration` index metadata + +|Index +|`read` on `logs-apm*` +|Read-only access to `logs-apm*` data + +|Index +|`view_index_metadata` on `logs-apm*` +|Read-only access to `logs-apm*` index metadata + +|Index +|`read` on `metrics-apm*` +|Read-only access to `metrics-apm*` data + +|Index +|`view_index_metadata` on `metrics-apm*` +|Read-only access to `metrics-apm*` index metadata + +|Index +|`read` on `traces-apm*` +|Read-only access to `traces-apm*` data + +|Index +|`view_index_metadata` on `traces-apm*` +|Read-only access to `traces-apm*` index metadata +|==== +// end::data-streams[] diff --git a/docs/apm/tab-widgets/central-config-users/widget.asciidoc b/docs/apm/tab-widgets/central-config-users/widget.asciidoc new file mode 100644 index 0000000000000..68bef4e50c549 --- /dev/null +++ b/docs/apm/tab-widgets/central-config-users/widget.asciidoc @@ -0,0 +1,40 @@ +++++ +
+
+ + +
+
+++++ + +include::content.asciidoc[tag=classic-indices] + +++++ +
+ +
+++++ \ No newline at end of file diff --git a/docs/apm/tab-widgets/code.asciidoc b/docs/apm/tab-widgets/code.asciidoc new file mode 100644 index 0000000000000..6a30cf55c8dbb --- /dev/null +++ b/docs/apm/tab-widgets/code.asciidoc @@ -0,0 +1,166 @@ +// Defining styles and script here for simplicity. +++++ + + + +++++ \ No newline at end of file diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 282dd5a63f50a..31a153cdb3490 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -69,7 +69,9 @@ yarn kbn watch-bazel - @kbn/babel-code-parser - @kbn/babel-preset - @kbn/config-schema +- @kbn/dev-utils - @kbn/expect +- @kbn/logging - @kbn/std - @kbn/tinymath - @kbn/utility-types diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 7d7d2c1246872..6f54e924769b8 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -6,7 +6,7 @@ NOTE: node scripts/build_plugin_list_docs - You can update the template within packages/kbn-dev-utils/target/plugin_list/generate_plugin_list.js + You can update the template within node_modules/@kbn/dev-utils/target/plugin_list/generate_plugin_list.js //// diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index 4369cf7c087da..8bc4b7606ab51 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): Observable>; +fetch$(options?: ISearchOptions): Observable>>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): Observable>; Returns: -`Observable>` +`Observable>>` diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c748d63484e28..50ed0d2652c6f 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -47,6 +47,88 @@ You can configure the following settings in the `kibana.yml` file. | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well. + +| `xpack.actions.customHostSettings` {ess-icon} + | A list of custom host settings to override existing global settings. + Defaults to an empty list. + + + + Each entry in the list must have a `url` property, to associate a connection + type (mail or https), hostname and port with the remaining options in the + entry. + + + In the following example, two custom host settings + are defined. The first provides a custom host setting for mail server + `mail.example.com` using port 465 that supplies server certificate authorization + data from both a file and inline, and requires TLS for the + connection. The second provides a custom host setting for https server + `webhook.example.com` which turns off server certificate authorization. + +|=== + +[source,yaml] +-- +xpack.actions.customHostSettings: + - url: smtp://mail.example.com:465 + tls: + certificateAuthoritiesFiles: [ 'one.crt' ] + certificateAuthoritiesData: | + -----BEGIN CERTIFICATE----- + ... multiple lines of certificate data here ... + -----END CERTIFICATE----- + smtp: + requireTLS: true + - url: https://webhook.example.com + tls: + rejectUnauthorized: false +-- + +[cols="2*<"] +|=== + +| `xpack.actions.customHostSettings[n]` +`.url` {ess-icon} + | A URL associated with this custom host setting. Should be in the form of + `protocol://hostname:port`, where `protocol` is `https` or `smtp`. If the + port is not provided, 443 is used for `https` and 25 is used for + `smtp`. The `smtp` URLs are used for the Email actions that use this + server, and the `https` URLs are used for actions which use `https` to + connect to services. + + + + Entries with `https` URLs can use the `tls` options, and entries with `smtp` + URLs can use both the `tls` and `smtp` options. + + + + No other URL values should be part of this URL, including paths, + query strings, and authentication information. When an http or smtp request + is made as part of executing an action, only the protocol, hostname, and + port of the URL for that request are used to look up these configuration + values. + +| `xpack.actions.customHostSettings[n]` +`.smtp.ignoreTLS` {ess-icon} + | A boolean value indicating that TLS must not be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.smtp.requireTLS` {ess-icon} + | A boolean value indicating that TLS must be used for this connection. + The options `smtp.ignoreTLS` and `smtp.requireTLS` can not both be set to true. + +| `xpack.actions.customHostSettings[n]` +`.tls.rejectUnauthorized` {ess-icon} + | A boolean value indicating whether to bypass server certificate validation. + Overrides the general `xpack.actions.rejectUnauthorized` configuration + for requests made for this hostname/port. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesFiles` + | A file name or list of file names of PEM-encoded certificate files to use + to validate the server. + +| `xpack.actions.customHostSettings[n]` +`.tls.certificateAuthoritiesData` {ess-icon} + | The contents of a PEM-encoded certificate file, or multiple files appended + into a single string. This configuration can be used for environments where + the files cannot be made available. | `xpack.actions.enabledActionTypes` {ess-icon} | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + @@ -79,13 +161,18 @@ a|`xpack.actions.` | `xpack.actions.rejectUnauthorized` {ess-icon} | Set to `false` to bypass certificate validation for actions. Defaults to `true`. + + - As an alternative to setting both `xpack.actions.proxyRejectUnauthorizedCertificates` and `xpack.actions.rejectUnauthorized`, you can point the OS level environment variable `NODE_EXTRA_CA_CERTS` to a file that contains the root CAs needed to trust certificates. + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting + `xpack.actions.customHostSettings` to set TLS options for specific servers. | `xpack.actions.maxResponseContentLength` {ess-icon} | Specifies the max number of bytes of the http response for requests to external resources. Defaults to 1000000 (1MB). | `xpack.actions.responseTimeout` {ess-icon} - | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as [ms|s|m|h|d|w|M|Y], for example, '20m', '24h', '7d', '1w'. Defaults to 60s. + | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as: + + + + `[ms,s,m,h,d,w,M,Y]` + + + + For example, `20m`, `24h`, `7d`, `1w`. Defaults to `60s`. |=== diff --git a/docs/user/alerting/alerting-troubleshooting.asciidoc b/docs/user/alerting/alerting-troubleshooting.asciidoc index f4673d10bc248..6d4a0e9375678 100644 --- a/docs/user/alerting/alerting-troubleshooting.asciidoc +++ b/docs/user/alerting/alerting-troubleshooting.asciidoc @@ -53,3 +53,19 @@ Alerting and action tasks are identified by their type. When diagnosing issues related to Alerting, focus on the tasks that begin with `alerting:` and `actions:`. For more details on monitoring and diagnosing task execution in Task Manager, see <>. + +[float] +[[connector-tls-settings]] +=== Connectors have TLS errors when executing actions + +*Problem*: + +When executing actions, a connector gets a TLS socket error when connecting to +the server. + +*Resolution*: + +Configuration options are available to specialize connections to TLS servers, +including ignoring server certificate validation, and providing certificate +authority data to verify servers using custom certificates. For more details, +see <>. diff --git a/examples/search_examples/public/search/app.tsx b/examples/search_examples/public/search/app.tsx index 65d939088515a..c9ede2ff2b45f 100644 --- a/examples/search_examples/public/search/app.tsx +++ b/examples/search_examples/public/search/app.tsx @@ -233,7 +233,7 @@ export const SearchExamplesApp = ({ } setRequest(searchSource.getSearchRequestBody()); - const res = await searchSource.fetch$().toPromise(); + const { rawResponse: res } = await searchSource.fetch$().toPromise(); setResponse(res); const message = Searched {res.hits.total} documents.; diff --git a/package.json b/package.json index 76bca44b66874..7cd0c273ee543 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "@kbn/interpreter": "link:packages/kbn-interpreter", "@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:packages/kbn-legacy-logging", - "@kbn/logging": "link:packages/kbn-logging", + "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", "@kbn/monaco": "link:packages/kbn-monaco", "@kbn/server-http-tools": "link:packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", @@ -208,7 +208,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.10.0", + "elastic-apm-node": "^3.14.0", "elasticsearch": "^16.7.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", @@ -369,7 +369,7 @@ "semver": "^7.3.2", "set-value": "^3.0.2", "source-map-support": "^0.5.19", - "squel": "^5.13.0", + "safe-squel": "^5.12.5", "stats-lite": "^2.2.0", "strip-ansi": "^6.0.0", "style-it": "^2.1.3", @@ -440,7 +440,7 @@ "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", - "@kbn/dev-utils": "link:packages/kbn-dev-utils", + "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module", "@kbn/docs-utils": "link:packages/kbn-docs-utils", "@kbn/es": "link:packages/kbn-es", "@kbn/es-archiver": "link:packages/kbn-es-archiver", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 1f7aaa39e11bb..2aec108f97047 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -11,7 +11,9 @@ filegroup( "//packages/kbn-babel-code-parser:build", "//packages/kbn-babel-preset:build", "//packages/kbn-config-schema:build", + "//packages/kbn-dev-utils:build", "//packages/kbn-expect:build", + "//packages/kbn-logging:build", "//packages/kbn-std:build", "//packages/kbn-tinymath:build", "//packages/kbn-utility-types:build", diff --git a/packages/kbn-ace/package.json b/packages/kbn-ace/package.json index 5b4b0312aa1ae..30a87dbd1e21b 100644 --- a/packages/kbn-ace/package.json +++ b/packages/kbn-ace/package.json @@ -8,8 +8,5 @@ "scripts": { "build": "node ./scripts/build.js", "kbn:bootstrap": "yarn build --dev" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-analytics/package.json b/packages/kbn-analytics/package.json index 5b9db79febd77..2195de578081e 100644 --- a/packages/kbn-analytics/package.json +++ b/packages/kbn-analytics/package.json @@ -12,8 +12,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build --source-maps", "kbn:watch": "node scripts/build --source-maps --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-cli-dev-mode/package.json b/packages/kbn-cli-dev-mode/package.json index cc91be0df4550..9def59623c938 100644 --- a/packages/kbn-cli-dev-mode/package.json +++ b/packages/kbn-cli-dev-mode/package.json @@ -15,9 +15,7 @@ }, "dependencies": { "@kbn/config": "link:../kbn-config", - "@kbn/logging": "link:../kbn-logging", "@kbn/server-http-tools": "link:../kbn-server-http-tools", - "@kbn/optimizer": "link:../kbn-optimizer", - "@kbn/dev-utils": "link:../kbn-dev-utils" + "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-config/package.json b/packages/kbn-config/package.json index 1611da9aa60d4..b114cb13933d1 100644 --- a/packages/kbn-config/package.json +++ b/packages/kbn-config/package.json @@ -8,12 +8,5 @@ "scripts": { "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build" - }, - "dependencies": { - "@kbn/logging": "link:../kbn-logging" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/utility-types": "link:../kbn-utility-types" } } \ No newline at end of file diff --git a/packages/kbn-crypto/package.json b/packages/kbn-crypto/package.json index 7e26b96218319..0787427c60b10 100644 --- a/packages/kbn-crypto/package.json +++ b/packages/kbn-crypto/package.json @@ -9,9 +9,5 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "dependencies": {}, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-dev-utils/BUILD.bazel b/packages/kbn-dev-utils/BUILD.bazel new file mode 100644 index 0000000000000..e3935040240dc --- /dev/null +++ b/packages/kbn-dev-utils/BUILD.bazel @@ -0,0 +1,128 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-dev-utils" +PKG_REQUIRE_NAME = "@kbn/dev-utils" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +filegroup( + name = "certs", + srcs = glob( + [ + "certs/**/*", + ], + exclude = [ + "**/README.md" + ], + ), +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md", + ":certs", + "ci_stats_reporter/package.json", + "stdio/package.json", + "tooling_log/package.json" +] + +SRC_DEPS = [ + "//packages/kbn-expect", + "//packages/kbn-utils", + "@npm//@babel/core", + "@npm//axios", + "@npm//chalk", + "@npm//chance", + "@npm//cheerio", + "@npm//dedent", + "@npm//execa", + "@npm//exit-hook", + "@npm//getopts", + "@npm//globby", + "@npm//jest-styled-components", + "@npm//load-json-file", + "@npm//markdown-it", + "@npm//moment", + "@npm//normalize-path", + "@npm//rxjs", + "@npm//tree-kill", + "@npm//tslib", + "@npm//typescript", + "@npm//vinyl" +] + +TYPES_DEPS = [ + "@npm//@types/babel__core", + "@npm//@types/cheerio", + "@npm//@types/dedent", + "@npm//@types/flot", + "@npm//@types/jest", + "@npm//@types/markdown-it", + "@npm//@types/node", + "@npm//@types/normalize-path", + "@npm//@types/react", + "@npm//@types/testing-library__jest-dom", + "@npm//@types/vinyl" +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-dev-utils/package.json b/packages/kbn-dev-utils/package.json index 4ce2880afbbda..90c5ef17d1859 100644 --- a/packages/kbn-dev-utils/package.json +++ b/packages/kbn-dev-utils/package.json @@ -5,15 +5,7 @@ "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - }, "kibana": { "devOnly": true - }, - "devDependencies": { - "@kbn/expect": "link:../kbn-expect" } } \ No newline at end of file diff --git a/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts b/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts index b88382c3b0da4..127e2a9904a4f 100644 --- a/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts +++ b/packages/kbn-dev-utils/src/plugin_list/generate_plugin_list.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import Fs from 'fs'; import Path from 'path'; import normalizePath from 'normalize-path'; @@ -49,7 +48,7 @@ NOTE: node scripts/build_plugin_list_docs You can update the template within ${normalizePath( - Path.relative(REPO_ROOT, Fs.realpathSync(Path.resolve(__dirname, __filename))) + Path.relative(REPO_ROOT, Path.resolve(__dirname, __filename)) )} //// diff --git a/packages/kbn-dev-utils/tsconfig.json b/packages/kbn-dev-utils/tsconfig.json index 65536c576b679..5bb7bd0424daf 100644 --- a/packages/kbn-dev-utils/tsconfig.json +++ b/packages/kbn-dev-utils/tsconfig.json @@ -1,12 +1,13 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "target", "stripInternal": false, "target": "ES2019", "declaration": true, "declarationMap": true, + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-dev-utils/src", "types": [ diff --git a/packages/kbn-docs-utils/package.json b/packages/kbn-docs-utils/package.json index e2db07001b543..6aca554f0f945 100644 --- a/packages/kbn-docs-utils/package.json +++ b/packages/kbn-docs-utils/package.json @@ -13,7 +13,6 @@ "kbn:watch": "../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/config": "link:../kbn-config", - "@kbn/dev-utils": "link:../kbn-dev-utils" + "@kbn/config": "link:../kbn-config" } } \ No newline at end of file diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json index 0e4c9884d2c39..c86d94c70d739 100644 --- a/packages/kbn-es-archiver/package.json +++ b/packages/kbn-es-archiver/package.json @@ -13,7 +13,6 @@ "kbn:watch": "rm -rf target && ../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/test": "link:../kbn-test" } } \ No newline at end of file diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index f47f042505cad..e7356794b6113 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -11,8 +11,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build", "kbn:watch": "node scripts/build --watch" - }, - "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-i18n/package.json b/packages/kbn-i18n/package.json index 570110589490b..1f9d21f724ea8 100644 --- a/packages/kbn-i18n/package.json +++ b/packages/kbn-i18n/package.json @@ -10,8 +10,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build --source-maps", "kbn:watch": "node scripts/build --watch --source-maps" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-interpreter/package.json b/packages/kbn-interpreter/package.json index 491a7205be210..997fbb0eb8a4f 100644 --- a/packages/kbn-interpreter/package.json +++ b/packages/kbn-interpreter/package.json @@ -11,8 +11,5 @@ }, "dependencies": { "@kbn/i18n": "link:../kbn-i18n" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-logging/BUILD.bazel b/packages/kbn-logging/BUILD.bazel new file mode 100644 index 0000000000000..f42ca22ae5256 --- /dev/null +++ b/packages/kbn-logging/BUILD.bazel @@ -0,0 +1,82 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-logging" +PKG_REQUIRE_NAME = "@kbn/logging" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*" + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md" +] + +SRC_DEPS = [ + "//packages/kbn-std" +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = [":tsc"] + DEPS, + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-logging/package.json b/packages/kbn-logging/package.json index 596eda1fe625a..d80cc1c40d7e1 100644 --- a/packages/kbn-logging/package.json +++ b/packages/kbn-logging/package.json @@ -4,10 +4,5 @@ "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", "main": "./target/index.js", - "types": "./target/index.d.ts", - "scripts": { - "build": "../../node_modules/.bin/tsc", - "kbn:bootstrap": "yarn build", - "kbn:watch": "yarn build --watch" - } + "types": "./target/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-logging/tsconfig.json b/packages/kbn-logging/tsconfig.json index adec4c1966036..78985b823dd95 100644 --- a/packages/kbn-logging/tsconfig.json +++ b/packages/kbn-logging/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "target", "stripInternal": false, "declaration": true, "declarationMap": true, + "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-logging/src", "types": [ diff --git a/packages/kbn-monaco/package.json b/packages/kbn-monaco/package.json index f4309e08f5bdb..75f1d74f1c9c9 100644 --- a/packages/kbn-monaco/package.json +++ b/packages/kbn-monaco/package.json @@ -10,9 +10,6 @@ "kbn:bootstrap": "yarn build --dev", "build:antlr4ts": "../../node_modules/antlr4ts-cli/antlr4ts ./src/painless/antlr/painless_lexer.g4 ./src/painless/antlr/painless_parser.g4 && node ./scripts/fix_generated_antlr.js" }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" - }, "dependencies": { "@kbn/i18n": "link:../kbn-i18n" } diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index 423bba0fd8c7a..f193fcf898a3d 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@kbn/config": "link:../kbn-config", - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/ui-shared-deps": "link:../kbn-ui-shared-deps" } } \ No newline at end of file diff --git a/packages/kbn-plugin-generator/package.json b/packages/kbn-plugin-generator/package.json index ae4dfbc670f19..583085430d915 100644 --- a/packages/kbn-plugin-generator/package.json +++ b/packages/kbn-plugin-generator/package.json @@ -8,8 +8,5 @@ "scripts": { "kbn:bootstrap": "node scripts/build", "kbn:watch": "node scripts/build --watch" - }, - "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-plugin-helpers/package.json b/packages/kbn-plugin-helpers/package.json index 6b9dd4d51baf9..2d642d9ede13b 100644 --- a/packages/kbn-plugin-helpers/package.json +++ b/packages/kbn-plugin-helpers/package.json @@ -17,7 +17,6 @@ "kbn:watch": "../../node_modules/.bin/tsc --watch" }, "dependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", "@kbn/optimizer": "link:../kbn-optimizer" } } \ No newline at end of file diff --git a/packages/kbn-pm/package.json b/packages/kbn-pm/package.json index c46906112b2e2..72061c9625b09 100644 --- a/packages/kbn-pm/package.json +++ b/packages/kbn-pm/package.json @@ -11,8 +11,5 @@ "build": "../../node_modules/.bin/webpack", "kbn:watch": "../../node_modules/.bin/webpack --watch", "prettier": "../../node_modules/.bin/prettier --write './src/**/*.ts'" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-storybook/package.json b/packages/kbn-storybook/package.json index fdc7359aab58d..0e70f7c340a90 100644 --- a/packages/kbn-storybook/package.json +++ b/packages/kbn-storybook/package.json @@ -12,8 +12,5 @@ "build": "../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "watch": "yarn build --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/packages/kbn-telemetry-tools/package.json b/packages/kbn-telemetry-tools/package.json index 2ae1f596a1c68..31fac5c043832 100644 --- a/packages/kbn-telemetry-tools/package.json +++ b/packages/kbn-telemetry-tools/package.json @@ -12,9 +12,5 @@ "build": "../../node_modules/.bin/babel src --out-dir target --delete-dir-on-start --extensions .ts --source-maps=inline", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/utility-types": "link:../kbn-utility-types" } } \ No newline at end of file diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json index 9bf8a01e031cc..15d6ac90b2ebe 100644 --- a/packages/kbn-test/package.json +++ b/packages/kbn-test/package.json @@ -17,9 +17,5 @@ "@kbn/es": "link:../kbn-es", "@kbn/i18n": "link:../kbn-i18n", "@kbn/optimizer": "link:../kbn-optimizer" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/expect": "link:../kbn-expect" } } \ No newline at end of file diff --git a/packages/kbn-tinymath/grammar/grammar.peggy b/packages/kbn-tinymath/grammar/grammar.peggy index 70f275776e45d..cbcb0b91bfea9 100644 --- a/packages/kbn-tinymath/grammar/grammar.peggy +++ b/packages/kbn-tinymath/grammar/grammar.peggy @@ -43,7 +43,7 @@ Literal "literal" // Quoted variables are interpreted as strings // but unquoted variables are more restrictive Variable - = _ Quote chars:(ValidChar / Space)* Quote _ { + = _ [\'] chars:(ValidChar / Space / [\"])* [\'] _ { return { type: 'variable', value: chars.join(''), @@ -51,6 +51,14 @@ Variable text: text() }; } + / _ [\"] chars:(ValidChar / Space / [\'])* [\"] _ { + return { + type: 'variable', + value: chars.join(''), + location: simpleLocation(location()), + text: text() + }; + } / _ rest:ValidChar+ _ { return { type: 'variable', @@ -103,10 +111,9 @@ Argument_List "arguments" } String - = [\"] value:(ValidChar)+ [\"] { return value.join(''); } - / [\'] value:(ValidChar)+ [\'] { return value.join(''); } - / value:(ValidChar)+ { return value.join(''); } - + = '"' chars:("\\\"" { return "\""; } / [^"])* '"' { return chars.join(''); } + / "'" chars:("\\\'" { return "\'"; } / [^'])* "'" { return chars.join(''); } + / chars:(ValidChar)+ { return chars.join(''); } Argument = name:[a-zA-Z_]+ _ '=' _ value:(Number / String) _ { diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js index 5ddf1b049b8d4..bf1c7a9dbc5fb 100644 --- a/packages/kbn-tinymath/test/library.test.js +++ b/packages/kbn-tinymath/test/library.test.js @@ -73,6 +73,7 @@ describe('Parser', () => { expect(parse('"foo bar"')).toEqual(variableEqual('foo bar')); expect(parse('"foo bar fizz buzz"')).toEqual(variableEqual('foo bar fizz buzz')); expect(parse('"foo bar baby"')).toEqual(variableEqual('foo bar baby')); + expect(parse(`"f'oo"`)).toEqual(variableEqual(`f'oo`)); }); it('strings with single quotes', () => { @@ -88,6 +89,7 @@ describe('Parser', () => { expect(parse("' foo bar'")).toEqual(variableEqual(" foo bar")); expect(parse("'foo bar '")).toEqual(variableEqual("foo bar ")); expect(parse("'0foo'")).toEqual(variableEqual("0foo")); + expect(parse(`'f"oo'`)).toEqual(variableEqual(`f"oo`)); /* eslint-enable prettier/prettier */ }); @@ -138,10 +140,18 @@ describe('Parser', () => { ); }); + it('named argument is empty string', () => { + expect(parse('foo(q="")')).toEqual(functionEqual('foo', [namedArgumentEqual('q', '')])); + expect(parse(`foo(q='')`)).toEqual(functionEqual('foo', [namedArgumentEqual('q', '')])); + }); + it('named and positional', () => { expect(parse('foo(ref, q="bar")')).toEqual( functionEqual('foo', [variableEqual('ref'), namedArgumentEqual('q', 'bar')]) ); + expect(parse(`foo(ref, q='ba"r')`)).toEqual( + functionEqual('foo', [variableEqual('ref'), namedArgumentEqual('q', `ba"r`)]) + ); }); it('numerically named', () => { @@ -182,6 +192,21 @@ describe('Parser', () => { it('invalid named', () => { expect(() => parse('foo(offset-type="1d")')).toThrow('but "(" found'); }); + + it('named with complex strings', () => { + expect(parse(`foo(filter='😀 > "\ttab"')`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `😀 > "\ttab"`)]) + ); + }); + + it('named with escape characters', () => { + expect(parse(`foo(filter='Women\\'s Clothing')`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `Women's Clothing`)]) + ); + expect(parse(`foo(filter="\\"Quoted inner string\\"")`)).toEqual( + functionEqual('foo', [namedArgumentEqual('filter', `"Quoted inner string"`)]) + ); + }); }); it('Missing expression', () => { diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 00c6f677cd223..8b08f64ba0f62 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -12,8 +12,5 @@ "@kbn/analytics": "link:../kbn-analytics", "@kbn/i18n": "link:../kbn-i18n", "@kbn/monaco": "link:../kbn-monaco" - }, - "devDependencies": { - "@kbn/dev-utils": "link:../kbn-dev-utils" } } \ No newline at end of file diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 220bd2c91057d..3b2feeecabb7c 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -162,6 +162,7 @@ kibana_vars=( timelion.enabled vega.enableExternalUrls xpack.actions.allowedHosts + xpack.actions.customHostSettings xpack.actions.enabled xpack.actions.enabledActionTypes xpack.actions.preconfiguredAlertHistoryEsIndex diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts index 03cf14a577a50..1b876051d009b 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.ts @@ -101,7 +101,7 @@ export const getTermsBucketAgg = () => nestedSearchSource.setField('aggs', filterAgg); - const response = await nestedSearchSource + const { rawResponse: response } = await nestedSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts index b30e5740fa3fb..32775464d055f 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts @@ -11,7 +11,7 @@ import type { Filter } from '../../../es_query'; import type { IndexPattern } from '../../../index_patterns'; import type { IAggConfigs } from '../../aggs'; import type { ISearchSource } from '../../search_source'; -import { searchSourceCommonMock } from '../../search_source/mocks'; +import { searchSourceCommonMock, searchSourceInstanceMock } from '../../search_source/mocks'; import { handleRequest, RequestHandlerParams } from './request_handler'; @@ -20,12 +20,20 @@ jest.mock('../../tabify', () => ({ })); import { tabifyAggResponse } from '../../tabify'; +import { of } from 'rxjs'; describe('esaggs expression function - public', () => { let mockParams: MockedKeys; beforeEach(() => { jest.clearAllMocks(); + + searchSourceInstanceMock.fetch$ = jest.fn().mockReturnValue( + of({ + rawResponse: {}, + }) + ); + mockParams = { abortSignal: (jest.fn() as unknown) as jest.Mocked, aggs: ({ diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts index 173b2067cad6b..d152ebf159a8e 100644 --- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts +++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts @@ -111,7 +111,7 @@ export const handleRequest = async ({ inspectorAdapters.requests?.reset(); - const response = await requestSearchSource + const { rawResponse: response } = await requestSearchSource .fetch$({ abortSignal, sessionId: searchSessionId, diff --git a/src/plugins/data/common/search/poll_search.test.ts b/src/plugins/data/common/search/poll_search.test.ts index 037fd0fc059d1..38c52f5d5bec4 100644 --- a/src/plugins/data/common/search/poll_search.test.ts +++ b/src/plugins/data/common/search/poll_search.test.ts @@ -20,11 +20,13 @@ describe('pollSearch', () => { resolve({ isRunning: false, isPartial: finishWithError, + rawResponse: {}, }); } else { resolve({ isRunning: true, isPartial: true, + rawResponse: {}, }); } }); diff --git a/src/plugins/data/common/search/search_source/fetch/request_error.ts b/src/plugins/data/common/search/search_source/fetch/request_error.ts index d8c750d011b03..48e216fa05541 100644 --- a/src/plugins/data/common/search/search_source/fetch/request_error.ts +++ b/src/plugins/data/common/search/search_source/fetch/request_error.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; import { KbnError } from '../../../../../kibana_utils/common'; +import { IKibanaSearchResponse } from '../../types'; import { SearchError } from './types'; /** @@ -16,9 +16,9 @@ import { SearchError } from './types'; * @param {Object} resp - optional HTTP response */ export class RequestFailure extends KbnError { - public resp?: estypes.SearchResponse; - constructor(err: SearchError | null = null, resp?: estypes.SearchResponse) { - super(`Request to Elasticsearch failed: ${JSON.stringify(resp || err?.message)}`); + public resp?: IKibanaSearchResponse; + constructor(err: SearchError | null = null, resp?: IKibanaSearchResponse) { + super(`Request to Elasticsearch failed: ${JSON.stringify(resp?.rawResponse || err?.message)}`); this.resp = resp; } diff --git a/src/plugins/data/common/search/search_source/fetch/types.ts b/src/plugins/data/common/search/search_source/fetch/types.ts index 79aa45163b913..069b2a3117a0a 100644 --- a/src/plugins/data/common/search/search_source/fetch/types.ts +++ b/src/plugins/data/common/search/search_source/fetch/types.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type { estypes } from '@elastic/elasticsearch'; import { GetConfigFn } from '../../../types'; +import { IKibanaSearchResponse } from '../../types'; /** * @internal @@ -24,10 +24,7 @@ export interface FetchHandlers { * Callback which can be used to hook into responses, modify them, or perform * side effects like displaying UI errors on the client. */ - onResponse: ( - request: SearchRequest, - response: estypes.SearchResponse - ) => estypes.SearchResponse; + onResponse: (request: SearchRequest, response: IKibanaSearchResponse) => IKibanaSearchResponse; } export interface SearchError { diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 68e386acfd48c..a3f043a5e2657 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -903,18 +903,26 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(2); expect(complete).toBeCalledTimes(1); expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { + Array [ + Object { + "isPartial": true, + "isRunning": true, + "rawResponse": Object { "test": 1, }, - ] + }, + ] `); expect(next.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - Object { + Array [ + Object { + "isPartial": false, + "isRunning": false, + "rawResponse": Object { "test": 2, }, - ] + }, + ] `); }); @@ -958,13 +966,9 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(1); expect(error).toBeCalledTimes(1); expect(complete).toBeCalledTimes(0); - expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "test": 1, - }, - ] - `); + expect(next.mock.calls[0][0].rawResponse).toStrictEqual({ + test: 1, + }); expect(error.mock.calls[0][0]).toBe(undefined); }); }); @@ -1174,7 +1178,7 @@ describe('SearchSource', () => { expect(fetchSub.next).toHaveBeenCalledTimes(3); expect(fetchSub.complete).toHaveBeenCalledTimes(1); expect(fetchSub.error).toHaveBeenCalledTimes(0); - expect(resp).toStrictEqual({ other: 5 }); + expect(resp.rawResponse).toStrictEqual({ other: 5 }); expect(typesRegistry.get('avg').postFlightRequest).toHaveBeenCalledTimes(3); }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 585126e1184d2..5130224329ba2 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -271,7 +271,9 @@ export class SearchSource { * Fetch this source from Elasticsearch, returning an observable over the response(s) * @param options */ - fetch$(options: ISearchOptions = {}) { + fetch$( + options: ISearchOptions = {} + ): Observable>> { const { getConfig } = this.dependencies; const syncSearchByDefault = getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES); @@ -308,7 +310,11 @@ export class SearchSource { * @deprecated Use fetch$ instead */ fetch(options: ISearchOptions = {}) { - return this.fetch$(options).toPromise(); + return this.fetch$(options) + .toPromise() + .then((r) => { + return r.rawResponse as estypes.SearchResponse; + }); } /** @@ -341,7 +347,7 @@ export class SearchSource { * PRIVATE APIS ******/ - private inspectSearch(s$: Observable>, options: ISearchOptions) { + private inspectSearch(s$: Observable>, options: ISearchOptions) { const { id, title, description, adapter } = options.inspector || { title: '' }; const requestResponder = adapter?.start(title, { @@ -384,7 +390,7 @@ export class SearchSource { last(undefined, null), tap((finalResponse) => { if (finalResponse) { - requestResponder?.stats(getResponseInspectorStats(finalResponse, this)); + requestResponder?.stats(getResponseInspectorStats(finalResponse.rawResponse, this)); requestResponder?.ok({ json: finalResponse }); } }), @@ -424,8 +430,8 @@ export class SearchSource { ); } } - return response; } + return response; } /** @@ -477,7 +483,7 @@ export class SearchSource { } }); }), - map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) + map((response) => onResponse(searchRequest, response)) ); } diff --git a/src/plugins/data/common/search/utils.ts b/src/plugins/data/common/search/utils.ts index e87434cd6ca83..e11957c6fa9fc 100644 --- a/src/plugins/data/common/search/utils.ts +++ b/src/plugins/data/common/search/utils.ts @@ -12,7 +12,7 @@ import type { IKibanaSearchResponse } from './types'; * @returns true if response had an error while executing in ES */ export const isErrorResponse = (response?: IKibanaSearchResponse) => { - return !response || (!response.isRunning && response.isPartial); + return !response || !response.rawResponse || (!response.isRunning && response.isPartial); }; /** diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index cb3dfb839a023..868330ce078c7 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2446,7 +2446,7 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; - fetch$(options?: ISearchOptions): Observable>; + fetch$(options?: ISearchOptions): Observable>>; // @deprecated fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; diff --git a/src/plugins/data/public/search/fetch/handle_response.test.ts b/src/plugins/data/public/search/fetch/handle_response.test.ts index 8854bee5c7657..1a430f860f438 100644 --- a/src/plugins/data/public/search/fetch/handle_response.test.ts +++ b/src/plugins/data/public/search/fetch/handle_response.test.ts @@ -12,7 +12,7 @@ import { handleResponse } from './handle_response'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { notificationServiceMock } from '../../../../../core/public/notifications/notifications_service.mock'; import { setNotifications } from '../../services'; -import { SearchResponse } from 'elasticsearch'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; jest.mock('@kbn/i18n', () => { return { @@ -33,8 +33,10 @@ describe('handleResponse', () => { test('should notify if timed out', () => { const request = { body: {} }; const response = { - timed_out: true, - } as SearchResponse; + rawResponse: { + timed_out: true, + }, + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); @@ -46,13 +48,15 @@ describe('handleResponse', () => { test('should notify if shards failed', () => { const request = { body: {} }; const response = { - _shards: { - failed: 1, - total: 2, - successful: 1, - skipped: 1, + rawResponse: { + _shards: { + failed: 1, + total: 2, + successful: 1, + skipped: 1, + }, }, - } as SearchResponse; + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); expect(notifications.toasts.addWarning).toBeCalled(); @@ -63,7 +67,9 @@ describe('handleResponse', () => { test('returns the response', () => { const request = {}; - const response = {} as SearchResponse; + const response = { + rawResponse: {}, + } as IKibanaSearchResponse; const result = handleResponse(request, response); expect(result).toBe(response); }); diff --git a/src/plugins/data/public/search/fetch/handle_response.tsx b/src/plugins/data/public/search/fetch/handle_response.tsx index 57ee5737e50a2..58e4da6b95fae 100644 --- a/src/plugins/data/public/search/fetch/handle_response.tsx +++ b/src/plugins/data/public/search/fetch/handle_response.tsx @@ -9,14 +9,15 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer } from '@elastic/eui'; -import type { estypes } from '@elastic/elasticsearch'; +import { IKibanaSearchResponse } from 'src/plugins/data/common'; import { ShardFailureOpenModalButton } from '../../ui/shard_failure_modal'; import { toMountPoint } from '../../../../kibana_react/public'; import { getNotifications } from '../../services'; import { SearchRequest } from '..'; -export function handleResponse(request: SearchRequest, response: estypes.SearchResponse) { - if (response.timed_out) { +export function handleResponse(request: SearchRequest, response: IKibanaSearchResponse) { + const { rawResponse } = response; + if (rawResponse.timed_out) { getNotifications().toasts.addWarning({ title: i18n.translate('data.search.searchSource.fetch.requestTimedOutNotificationMessage', { defaultMessage: 'Data might be incomplete because your request timed out', @@ -24,12 +25,12 @@ export function handleResponse(request: SearchRequest, response: estypes.SearchR }); } - if (response._shards && response._shards.failed) { + if (rawResponse._shards && rawResponse._shards.failed) { const title = i18n.translate('data.search.searchSource.fetch.shardsFailedNotificationMessage', { defaultMessage: '{shardsFailed} of {shardsTotal} shards failed', values: { - shardsFailed: response._shards.failed, - shardsTotal: response._shards.total, + shardsFailed: rawResponse._shards.failed, + shardsTotal: rawResponse._shards.total, }, }); const description = i18n.translate( @@ -43,7 +44,7 @@ export function handleResponse(request: SearchRequest, response: estypes.SearchR <> {description} - + ); diff --git a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts index 89b696a80d57d..0e81f362a030d 100644 --- a/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor/search_interceptor.test.ts @@ -144,7 +144,7 @@ describe('SearchInterceptor', () => { describe('search', () => { test('Observable should resolve if fetch is successful', async () => { - const mockResponse: any = { result: 200 }; + const mockResponse: any = { rawResponse: {} }; fetchMock.mockResolvedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, @@ -233,6 +233,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -255,6 +256,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -281,6 +283,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -289,6 +292,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -325,6 +329,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -349,6 +354,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -357,6 +363,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -389,6 +396,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -433,6 +441,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -441,6 +450,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -511,7 +521,10 @@ describe('SearchInterceptor', () => { sessionId, }); - await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); + await searchInterceptor + .search(mockRequest, { sessionId }) + .toPromise() + .catch(() => {}); expect(fetchMock.mock.calls[0][0]).toEqual( expect.objectContaining({ options: { sessionId, isStored: true, isRestore: true, strategy: 'ese' }, @@ -527,7 +540,10 @@ describe('SearchInterceptor', () => { const sessionId = 'sid'; setup(null); - await searchInterceptor.search(mockRequest, { sessionId }).toPromise(); + await searchInterceptor + .search(mockRequest, { sessionId }) + .toPromise() + .catch(() => {}); expect(fetchMock.mock.calls[0][0]).toEqual( expect.not.objectContaining({ options: { sessionId }, @@ -548,6 +564,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -556,6 +573,7 @@ describe('SearchInterceptor', () => { value: { isPartial: false, isRunning: false, + rawResponse: {}, id: 1, }, }, @@ -792,6 +810,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: true, + rawResponse: {}, id: 1, }, }, @@ -838,6 +857,7 @@ describe('SearchInterceptor', () => { value: { isPartial: true, isRunning: false, + rawResponse: {}, id: 1, }, }, diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 383e09b4a6ebe..f52c622c48ed0 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -251,7 +251,7 @@ export class SearchService implements Plugin { private registerSearchStrategy = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, - SearchStrategyResponse extends IKibanaSearchResponse = IEsSearchResponse + SearchStrategyResponse extends IKibanaSearchResponse = IEsSearchResponse >( name: string, strategy: ISearchStrategy diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 4099d5e8ef7e2..c66ca19c96743 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -431,7 +431,7 @@ function discoverController($route, $scope) { }, }) .toPromise() - .then(onResults) + .then(({ rawResponse }) => onResults(rawResponse)) .catch((error) => { // If the request was aborted then no need to surface this error in the UI if (error instanceof Error && error.name === 'AbortError') return; diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts index dbaf07fed18c2..99ecb4c11eef2 100644 --- a/src/plugins/discover/public/application/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -325,7 +325,7 @@ export class SearchEmbeddable try { // Make the request - const resp = await searchSource + const { rawResponse: resp } = await searchSource .fetch$({ abortSignal: this.abortController.signal, sessionId: searchSessionId, diff --git a/vars/workers.groovy b/vars/workers.groovy index 1260f74f1bdf9..83d439934cbfa 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -71,7 +71,7 @@ def base(Map params, Closure closure) { if (config.scm) { // Try to clone from Github up to 8 times, waiting 15 secs between attempts retryWithDelay(8, 15) { - checkout scm + kibanaCheckout() } dir("kibana") { diff --git a/x-pack/package.json b/x-pack/package.json index c09db67483121..129c8d86adecc 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -27,9 +27,7 @@ "yarn": "^1.21.1" }, "devDependencies": { - "@kbn/dev-utils": "link:../packages/kbn-dev-utils", "@kbn/es": "link:../packages/kbn-es", - "@kbn/expect": "link:../packages/kbn-expect", "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/storybook": "link:../packages/kbn-storybook", "@kbn/test": "link:../packages/kbn-test" diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 76f6a62ce6597..fbd9a8cddbdcb 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -21,6 +21,7 @@ const createActionsConfigMock = () => { maxContentLength: 1000000, timeout: 360000, }), + getCustomHostSettings: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 70c8b0e8185d5..925e77ca85fb2 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -13,8 +13,14 @@ import { AllowedHosts, EnabledActionTypes, } from './actions_config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; +import { Logger } from '../../../../src/core/server'; +import { loggingSystemMock } from '../../../../src/core/server/mocks'; + import moment from 'moment'; +const mockLogger = loggingSystemMock.create().get() as jest.Mocked; + const defaultActionsConfig: ActionsConfig = { enabled: false, allowedHosts: [], @@ -355,4 +361,79 @@ describe('getProxySettings', () => { const proxySettings = getActionsConfigurationUtilities(config).getProxySettings(); expect(proxySettings?.proxyOnlyHosts).toEqual(new Set(proxyOnlyHosts)); }); + + test('getCustomHostSettings() returns undefined when no matching config', () => { + const httpsUrl = 'https://elastic.co/foo/bar'; + const smtpUrl = 'smtp://elastic.co'; + let config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + + config = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://www.elastic.co:443', + }, + ], + }); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(undefined); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(undefined); + }); + + test('getCustomHostSettings() returns matching config', () => { + const httpsUrl = 'https://elastic.co/ignoring/paths/here'; + const smtpUrl = 'smtp://elastic.co:123'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'smtp://elastic.co:123', + tls: { + rejectUnauthorized: false, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }); + + let chs = getActionsConfigurationUtilities(config).getCustomHostSettings(httpsUrl); + expect(chs).toEqual(config.customHostSettings![0]); + chs = getActionsConfigurationUtilities(config).getCustomHostSettings(smtpUrl); + expect(chs).toEqual(config.customHostSettings![1]); + }); + + test('getCustomHostSettings() returns undefined when bad url is passed in', () => { + const badUrl = 'https://elastic.co/foo/bar'; + const config: ActionsConfig = resolveCustomHosts(mockLogger, { + ...defaultActionsConfig, + }); + + const chs = getActionsConfigurationUtilities(config).getCustomHostSettings(badUrl); + expect(chs).toEqual(undefined); + }); }); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 4c73cab76f9e8..b8cd5878a8972 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -11,7 +11,8 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfig, AllowedHosts, EnabledActionTypes } from './config'; +import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; +import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings, ResponseSettings } from './types'; @@ -32,6 +33,7 @@ export interface ActionsConfigurationUtilities { isRejectUnauthorizedCertificatesEnabled: () => boolean; getProxySettings: () => undefined | ProxySettings; getResponseSettings: () => ResponseSettings; + getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -107,6 +109,27 @@ function getResponseSettingsFromConfig(config: ActionsConfig): ResponseSettings }; } +function getCustomHostSettings( + config: ActionsConfig, + targetUrl: string +): CustomHostSettings | undefined { + const customHostSettings = config.customHostSettings; + if (!customHostSettings) { + return; + } + + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(targetUrl); + } catch (err) { + // presumably this bad URL is reported elsewhere + return; + } + + const canonicalUrl = getCanonicalCustomHostUrl(parsedUrl); + return customHostSettings.find((settings) => settings.url === canonicalUrl); +} + export function getActionsConfigurationUtilities( config: ActionsConfig ): ActionsConfigurationUtilities { @@ -119,6 +142,7 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, getProxySettings: () => getProxySettingsFromConfig(config), getResponseSettings: () => getResponseSettingsFromConfig(config), + // returns the global rejectUnauthorized setting isRejectUnauthorizedCertificatesEnabled: () => config.rejectUnauthorized, ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { @@ -135,5 +159,6 @@ export function getActionsConfigurationUtilities( throw new ActionTypeDisabledError(disabledActionTypeErrorMessage(actionType), 'config'); } }, + getCustomHostSettings: (targetUrl: string) => getCustomHostSettings(config, targetUrl), }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 4596619c50940..5747b4bbb28f4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -282,6 +282,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -342,6 +343,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts new file mode 100644 index 0000000000000..80bf51e19c379 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -0,0 +1,277 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import http from 'http'; +import https from 'https'; +import axios from 'axios'; +import { duration as momentDuration } from 'moment'; +import { schema } from '@kbn/config-schema'; + +import { request } from './axios_utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { createReadySignal } from '../../../../event_log/server/lib/ready_signal'; +import { ActionsConfig } from '../../config'; +import { + ActionsConfigurationUtilities, + getActionsConfigurationUtilities, +} from '../../actions_config'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +const CERT_DIR = '../../../../../../../packages/kbn-dev-utils/certs'; + +const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt')); +const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key')); +const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt')); + +const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8'); +const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8'); +const CA = fsReadFileSync(CA_FILE, 'utf8'); + +describe('axios connections', () => { + let testServer: http.Server | https.Server; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let savedAxiosDefaultsAdapter: any; + + beforeAll(() => { + // needed to prevent the dreaded Error: Cross origin http://localhost forbidden + // see: https://github.com/axios/axios/issues/1754#issuecomment-572778305 + savedAxiosDefaultsAdapter = axios.defaults.adapter; + axios.defaults.adapter = require('axios/lib/adapters/http'); + }); + + afterAll(() => { + axios.defaults.adapter = savedAxiosDefaultsAdapter; + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterEach(() => { + testServer.close(); + }); + + describe('http', () => { + test('it works', async () => { + const { url, server } = await createServer(); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + }); + + describe('https', () => { + test('it fails with self-signed cert and no overrides', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig(); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with rejectUnauthorized false config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with rejectUnauthorized custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { rejectUnauthorized: false } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: CA } }], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with incorrect ca in custom host config', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: KIBANA_CRT } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized false', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + rejectUnauthorized: false, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it works with incorrect ca in custom host config but rejectUnauthorized config true', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + rejectUnauthorized: false, + customHostSettings: [ + { + url, + tls: { + certificateAuthoritiesData: CA, + }, + }, + ], + }); + const res = await request({ axios, url, logger, configurationUtilities }); + expect(res.status).toBe(200); + }); + + test('it fails with no matching custom host settings', async () => { + const { url, server } = await createServer(true); + const otherUrl = 'https://example.com'; + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url: otherUrl, tls: { rejectUnauthorized: false } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 1', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: 'garbage' } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + + test('it fails cleanly with a garbage CA 2', async () => { + const { url, server } = await createServer(true); + testServer = server; + + const ca = '-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----\n'; + const configurationUtilities = getACUfromConfig({ + customHostSettings: [{ url, tls: { certificateAuthoritiesData: ca } }], + }); + const fn = async () => await request({ axios, url, logger, configurationUtilities }); + await expect(fn()).rejects.toThrow('certificate'); + }); + }); +}); + +interface CreateServerResult { + url: string; + server: http.Server | https.Server; +} + +async function createServer(useHttps: boolean = false): Promise { + let server: http.Server | https.Server; + const readySignal = createReadySignal(); + + if (!useHttps) { + server = http.createServer((req, res) => { + res.writeHead(200); + res.end('http: just testing that a connection could be made'); + }); + } else { + const httpsOptions = { + cert: KIBANA_CRT, + key: KIBANA_KEY, + }; + server = https.createServer(httpsOptions, (req, res) => { + res.writeHead(200); + res.end('https: just testing that a connection could be made'); + }); + } + + server.listen(() => { + const addressInfo = server.address(); + if (addressInfo == null || typeof addressInfo === 'string') { + server.close(); + throw new Error('error getting address of server, closing'); + } + + const url = localUrlFromPort(useHttps, addressInfo.port, 'localhost'); + readySignal.signal({ server, url }); + }); + + // let the node process stop if for some reason this server isn't closed + server.unref(); + + return readySignal.wait(); +} + +const BaseActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: ['*'], + enabledActionTypes: ['*'], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyUrl: undefined, + proxyHeaders: undefined, + proxyRejectUnauthorizedCertificates: true, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + rejectUnauthorized: true, + maxResponseContentLength: ByteSizeValue.parse('1mb'), + responseTimeout: momentDuration(1000 * 30), + customHostSettings: undefined, + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, +}; + +function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { + return getActionsConfigurationUtilities({ + ...BaseActionsConfig, + ...config, + }); +} + +function localUrlFromPort(useHttps: boolean, port: number, host: string): string { + return `${useHttps ? 'https' : 'http'}://${host}:${port}`; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts index f6d1be9bffc6b..805c22806ce4c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts @@ -16,11 +16,16 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const targetHost = 'elastic.co'; const targetUrl = `https://${targetHost}/foo/bar/baz`; +const targetUrlCanonical = `https://${targetHost}:443`; const nonMatchingUrl = `https://${targetHost}m/foo/bar/baz`; describe('getCustomAgents', () => { const configurationUtilities = actionsConfigMock.create(); + beforeEach(() => { + jest.resetAllMocks(); + }); + test('get agents for valid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', @@ -106,4 +111,117 @@ describe('getCustomAgents', () => { expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); }); + + test('handles custom host settings', () => { + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles custom host settings with proxy', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + certificateAuthoritiesData: 'ca data here', + }, + }); + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + + expect(httpsAgent?.options.ca).toBe('ca data here'); + expect(httpsAgent?.options.rejectUnauthorized).toBe(false); + }); + + test('handles overriding global rejectUnauthorized false', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeFalsy(); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeFalsy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); + + test('handles overriding global rejectUnauthorized false with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: true, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); + }); + + test('handles overriding global rejectUnauthorized true with a proxy', () => { + configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + configurationUtilities.getCustomHostSettings.mockReturnValue({ + url: targetUrlCanonical, + tls: { + rejectUnauthorized: false, + }, + }); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + // note: this setting doesn't come into play, it's for the connection to + // the proxy, not the target url + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, targetUrl); + expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); + expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); + expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts index ff2d005f4d841..6ec926004e73e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts @@ -6,7 +6,7 @@ */ import { Agent as HttpAgent } from 'http'; -import { Agent as HttpsAgent } from 'https'; +import { Agent as HttpsAgent, AgentOptions } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; @@ -22,7 +22,8 @@ export function getCustomAgents( logger: Logger, url: string ): GetCustomAgentsResponse { - const proxySettings = configurationUtilities.getProxySettings(); + // the default for rejectUnauthorized is the global setting, which can + // be overridden (below) with a custom host setting const defaultAgents = { httpAgent: undefined, httpsAgent: new HttpsAgent({ @@ -30,10 +31,39 @@ export function getCustomAgents( }), }; + // Get the current proxy settings, and custom host settings for this URL. + // If there are neither of these, return the default agents + const proxySettings = configurationUtilities.getProxySettings(); + const customHostSettings = configurationUtilities.getCustomHostSettings(url); + if (!proxySettings && !customHostSettings) { + return defaultAgents; + } + + // update the defaultAgents.httpsAgent if configured + const tlsSettings = customHostSettings?.tls; + let agentOptions: AgentOptions | undefined; + if (tlsSettings) { + logger.debug(`Creating customized connection settings for: ${url}`); + agentOptions = defaultAgents.httpsAgent.options; + + if (tlsSettings.certificateAuthoritiesData) { + agentOptions.ca = tlsSettings.certificateAuthoritiesData; + } + + // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts + // This is where the global rejectUnauthorized is overridden by a custom host + if (tlsSettings.rejectUnauthorized !== undefined) { + agentOptions.rejectUnauthorized = tlsSettings.rejectUnauthorized; + } + } + + // if there weren't any proxy settings, return the currently calculated agents if (!proxySettings) { return defaultAgents; } + // there is a proxy in use, but it's possible we won't use it via custom host + // proxyOnlyHosts and proxyBypassHosts let targetUrl: URL; try { targetUrl = new URL(url); @@ -56,6 +86,7 @@ export function getCustomAgents( return defaultAgents; } } + logger.debug(`Creating proxy agents for proxy: ${proxySettings.proxyUrl}`); let proxyUrl: URL; try { @@ -65,6 +96,9 @@ export function getCustomAgents( return defaultAgents; } + // At this point, we are going to use a proxy, so we need new agents. + // We will though, copy over the calculated tls options from above, into + // the https agent. const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); const httpsAgent = (new HttpsProxyAgent({ host: proxyUrl.hostname, @@ -76,5 +110,12 @@ export function getCustomAgents( }) as unknown) as HttpsAgent; // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it + if (agentOptions) { + httpsAgent.options = { + ...httpsAgent.options, + ...agentOptions, + }; + } + return { httpAgent, httpsAgent }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 4b45c6d787cd6..cceeefde71dc2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -15,6 +15,7 @@ import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; import { actionsConfigMock } from '../../actions_config.mock'; +import { CustomHostSettings } from '../../config'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; @@ -356,16 +357,151 @@ describe('send_email module', () => { ] `); }); + + test('it handles custom host settings from config', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + }, + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // note in the object below, the rejectUnauthenticated got set to false, + // given the implementation allowing that for no auth and !secure. + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "port": 1025, + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": false, + }, + }, + ] + `); + }); + + test('it allows custom host settings to override calculated values', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + undefined, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + requireTLS: false, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + + // in this case, rejectUnauthorized is true, as the custom host settings + // overrode the calculated value of false + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "host": "example.com", + "ignoreTLS": true, + "port": 1025, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); + + test('it handles custom host settings with a proxy', async () => { + const sendEmailOptions = getSendEmailOptionsNoAuth( + { + transport: { + host: 'example.com', + port: 1025, + }, + }, + { + proxyUrl: 'https://proxy.com', + proxyRejectUnauthorizedCertificates: false, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }, + { + url: 'smtp://example.com:1025', + tls: { + certificateAuthoritiesData: 'ca cert data goes here', + rejectUnauthorized: true, + }, + smtp: { + requireTLS: true, + }, + } + ); + + const result = await sendEmail(mockLogger, sendEmailOptions); + expect(result).toBe(sendMailMockResult); + expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "headers": undefined, + "host": "example.com", + "port": 1025, + "proxy": "https://proxy.com", + "requireTLS": true, + "secure": false, + "tls": Object { + "ca": "ca cert data goes here", + "rejectUnauthorized": true, + }, + }, + ] + `); + }); }); function getSendEmailOptions( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, @@ -392,12 +528,16 @@ function getSendEmailOptions( function getSendEmailOptionsNoAuth( { content = {}, routing = {}, transport = {} } = {}, - proxySettings?: ProxySettings + proxySettings?: ProxySettings, + customHostSettings?: CustomHostSettings ) { const configurationUtilities = actionsConfigMock.create(); if (proxySettings) { configurationUtilities.getProxySettings.mockReturnValue(proxySettings); } + if (customHostSettings) { + configurationUtilities.getCustomHostSettings.mockReturnValue(customHostSettings); + } return { content: { ...content, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index c0a254967b4fe..005e73b1fc2f7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -11,6 +11,7 @@ import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { CustomHostSettings } from '../../config'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -52,7 +53,10 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom const { from, to, cc, bcc } = routing; const { subject, message } = content; - const transportConfig: Record = {}; + // The transport options do not seem to be exposed as a type, and we reference + // some deep properties, so need to use any here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transportConfig: Record = {}; const proxySettings = configurationUtilities.getProxySettings(); const rejectUnauthorized = configurationUtilities.isRejectUnauthorizedCertificatesEnabled(); @@ -73,6 +77,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom useProxy = false; } } + let customHostSettings: CustomHostSettings | undefined; if (service === JSON_TRANSPORT_SERVICE) { transportConfig.jsonTransport = true; @@ -83,6 +88,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom transportConfig.host = host; transportConfig.port = port; transportConfig.secure = !!secure; + customHostSettings = configurationUtilities.getCustomHostSettings(`smtp://${host}:${port}`); if (proxySettings && useProxy) { transportConfig.tls = { @@ -99,6 +105,33 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom } else { transportConfig.tls = { rejectUnauthorized }; } + + // finally, allow customHostSettings to override some of the settings + // see: https://nodemailer.com/smtp/ + if (customHostSettings) { + const tlsConfig: Record = {}; + const tlsSettings = customHostSettings.tls; + const smtpSettings = customHostSettings.smtp; + + if (tlsSettings?.certificateAuthoritiesData) { + tlsConfig.ca = tlsSettings?.certificateAuthoritiesData; + } + if (tlsSettings?.rejectUnauthorized !== undefined) { + tlsConfig.rejectUnauthorized = tlsSettings?.rejectUnauthorized; + } + + if (!transportConfig.tls) { + transportConfig.tls = tlsConfig; + } else { + transportConfig.tls = { ...transportConfig.tls, ...tlsConfig }; + } + + if (smtpSettings?.ignoreTLS) { + transportConfig.ignoreTLS = true; + } else if (smtpSettings?.requireTLS) { + transportConfig.requireTLS = true; + } + } } const nodemailerTransport = nodemailer.createTransport(transportConfig); diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index 8a185d353de02..95088fa5f7965 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -167,6 +167,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -230,6 +231,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index d3f059eede615..00e56303dbe22 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -290,6 +290,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], @@ -382,6 +383,7 @@ describe('execute()', () => { "ensureActionTypeEnabled": [MockFunction], "ensureHostnameAllowed": [MockFunction], "ensureUriAllowed": [MockFunction], + "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 092b5d2cce587..4c4fd143369e1 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -164,6 +164,19 @@ describe('config validation', () => { ] `); }); + + // Most of the customHostSettings tests are in ./lib/custom_host_settings.test.ts + // but this one seemed more relevant for this test suite, since url is the one + // required property. + test('validates customHostSettings contains a URL', () => { + const config: Record = { + customHostSettings: [{}], + }; + + expect(() => configSchema.validate(config)).toThrowErrorMatchingInlineSnapshot( + `"[customHostSettings.0.url]: expected value of type [string] but got [undefined]"` + ); + }); }); // object creator that ensures we can create a property named __proto__ on an diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 7225c54d57596..0dc1aed68f4d0 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -23,6 +23,30 @@ const preconfiguredActionSchema = schema.object({ secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), }); +const customHostSettingsSchema = schema.object({ + url: schema.string({ minLength: 1 }), + smtp: schema.maybe( + schema.object({ + ignoreTLS: schema.maybe(schema.boolean()), + requireTLS: schema.maybe(schema.boolean()), + }) + ), + tls: schema.maybe( + schema.object({ + rejectUnauthorized: schema.maybe(schema.boolean()), + certificateAuthoritiesFiles: schema.maybe( + schema.oneOf([ + schema.string({ minLength: 1 }), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + ]) + ), + certificateAuthoritiesData: schema.maybe(schema.string({ minLength: 1 })), + }) + ), +}); + +export type CustomHostSettings = TypeOf; + export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), allowedHosts: schema.arrayOf( @@ -50,6 +74,7 @@ export const configSchema = schema.object({ rejectUnauthorized: schema.boolean({ defaultValue: true }), maxResponseContentLength: schema.byteSize({ defaultValue: '1mb' }), responseTimeout: schema.duration({ defaultValue: '60s' }), + customHostSettings: schema.maybe(schema.arrayOf(customHostSettingsSchema)), cleanupFailedExecutionsTask: schema.object({ enabled: schema.boolean({ defaultValue: true }), cleanupInterval: schema.duration({ defaultValue: '5m' }), diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts new file mode 100644 index 0000000000000..ad07ea21d7917 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.test.ts @@ -0,0 +1,504 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync as fsReadFileSync } from 'fs'; +import { resolve as pathResolve, join as pathJoin } from 'path'; +import { schema, ByteSizeValue } from '@kbn/config-schema'; +import moment from 'moment'; + +import { ActionsConfig } from '../config'; +import { Logger } from '../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; + +import { resolveCustomHosts, getCanonicalCustomHostUrl } from './custom_host_settings'; + +const CA_DIR = '../../../../../../packages/kbn-dev-utils/certs'; +const CA_FILE1 = pathResolve(__filename, pathJoin(CA_DIR, 'ca.crt')); +const CA_CONTENTS1 = fsReadFileSync(CA_FILE1, 'utf8'); +const CA_FILE2 = pathResolve(__filename, pathJoin(CA_DIR, 'kibana.crt')); +const CA_CONTENTS2 = fsReadFileSync(CA_FILE2, 'utf8'); + +let mockLogger: Logger = loggingSystemMock.create().get(); + +function warningLogs() { + const calls = loggingSystemMock.collect(mockLogger).warn; + return calls.map((call) => `${call[0]}`); +} + +describe('custom_host_settings', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockLogger = loggingSystemMock.create().get(); + }); + + describe('getCanonicalCustomHostUrl()', () => { + test('minimal urls', () => { + expect(getCanonicalCustomHostUrl(new URL('http://elastic.com'))).toBe( + 'http://elastic.com:80' + ); + expect(getCanonicalCustomHostUrl(new URL('https://elastic.co'))).toBe( + 'https://elastic.co:443' + ); + expect(getCanonicalCustomHostUrl(new URL('smtp://mail.elastic.co'))).toBe( + 'smtp://mail.elastic.co:25' + ); + expect(warningLogs()).toEqual([]); + }); + + test('maximal urls', () => { + expect( + getCanonicalCustomHostUrl(new URL('http://user1:pass1@elastic.co:81/foo?bar#car')) + ).toBe('http://elastic.co:81'); + expect( + getCanonicalCustomHostUrl(new URL('https://user1:pass1@elastic.co:82/foo?bar#car')) + ).toBe('https://elastic.co:82'); + expect( + getCanonicalCustomHostUrl(new URL('smtp://user1:pass1@mail.elastic.co:83/foo?bar#car')) + ).toBe('smtp://mail.elastic.co:83'); + expect(warningLogs()).toEqual([]); + }); + }); + + describe('resolveCustomHosts()', () => { + const defaultActionsConfig: ActionsConfig = { + enabled: true, + allowedHosts: [], + enabledActionTypes: [], + preconfiguredAlertHistoryEsIndex: false, + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, + maxResponseContentLength: new ByteSizeValue(1000000), + responseTimeout: moment.duration(60000), + cleanupFailedExecutionsTask: { + enabled: true, + cleanupInterval: schema.duration().validate('5m'), + idleInterval: schema.duration().validate('1h'), + pageSize: 100, + }, + }; + + test('ensure it copies over the config parts that it does not touch', () => { + const config: ActionsConfig = { ...defaultActionsConfig }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles undefined customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: undefined }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles empty object customHostSettings', () => { + const config: ActionsConfig = { ...defaultActionsConfig, customHostSettings: [] }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple valid settings', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://elastic.co:443', + tls: { + certificateAuthoritiesData: 'xyz', + rejectUnauthorized: false, + }, + }, + { + url: 'smtp://mail.elastic.com:25', + tls: { + certificateAuthoritiesData: 'abc', + rejectUnauthorized: true, + }, + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + expect(resConfig).toMatchObject(config); + expect(config).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles bad url', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'this! is! not! a! url!', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, invalid URL \\"this! is! not! a! url!\\", ignoring; error: Invalid URL: this! is! not! a! url!", + ] + `); + }); + + test('handles bad port', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:0', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { ...config, customHostSettings: [] }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unable to determine port for URL \\"https://almost.purrfect.com:0\\", ignoring", + ] + `); + }); + + test('handles auth info', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://kitty:cat@almost.purrfect.com', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://kitty:cat@almost.purrfect.com\\" contains authentication information which will be ignored, but should be removed from the configuration", + ] + `); + }); + + test('handles hash', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com#important', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com#important\\" contains hash information which will be ignored", + ] + `); + }); + + test('handles path', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/about', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/about\\" contains path information which will be ignored", + ] + `); + }); + + test('handles / path same as no path, since we have no choice', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toEqual([]); + }); + + test('handles unsupported URL protocols', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'http://almost.purrfect.com/', + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, unsupported protocol used in URL \\"http://almost.purrfect.com/\\", ignoring", + ] + `); + }); + + test('handles smtp options for non-smtp urls', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"https://almost.purrfect.com/\\" contains smtp properties but does not use smtp; ignoring smtp properties", + ] + `); + }); + + test('handles ca files not found', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + certificateAuthoritiesFiles: 'this-file-does-not-exist', + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "error reading file \\"this-file-does-not-exist\\" specified in xpack.actions.customHosts, ignoring: ENOENT: no such file or directory, open 'this-file-does-not-exist'", + ] + `); + }); + + test('handles a single ca file', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: CA_FILE1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe(CA_CONTENTS1); + expect(warningLogs()).toEqual([]); + }); + + test('handles multiple ca files', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE1, CA_FILE2], + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles ca files and ca data', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + certificateAuthoritiesFiles: [CA_FILE2], + certificateAuthoritiesData: CA_CONTENTS1, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + + // not checking the full structure anymore, just ca bits + expect(resConfig?.customHostSettings?.[0].tls?.certificateAuthoritiesData).toBe( + `${CA_CONTENTS1}\n${CA_CONTENTS2}` + ); + expect(warningLogs()).toEqual([]); + }); + + test('handles smtp ignoreTLS and requireTLS both used', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com/', + smtp: { + ignoreTLS: true, + requireTLS: true, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'smtp://almost.purrfect.com:25', + smtp: { + ignoreTLS: false, + requireTLS: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, URL \\"smtp://almost.purrfect.com/\\" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false", + ] + `); + }); + + test('handles duplicate URLs', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + customHostSettings: [ + { + url: 'https://almost.purrfect.com/', + tls: { + rejectUnauthorized: true, + }, + }, + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: false, + }, + }, + ], + }; + const resConfig = resolveCustomHosts(mockLogger, config); + const expConfig = { + ...config, + customHostSettings: [ + { + url: 'https://almost.purrfect.com:443', + tls: { + rejectUnauthorized: true, + }, + }, + ], + }; + expect(resConfig).toMatchObject(expConfig); + expect(expConfig).toMatchObject(resConfig); + expect(warningLogs()).toMatchInlineSnapshot(` + Array [ + "In configuration xpack.actions.customHosts, multiple URLs match the canonical url \\"https://almost.purrfect.com:443\\"; only the first will be used", + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/custom_host_settings.ts b/x-pack/plugins/actions/server/lib/custom_host_settings.ts new file mode 100644 index 0000000000000..bfc8dad48aab6 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/custom_host_settings.ts @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync } from 'fs'; +import { cloneDeep } from 'lodash'; +import { Logger } from '../../../../../src/core/server'; +import { ActionsConfig, CustomHostSettings } from '../config'; + +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; + +type ActionsConfigWriteable = DeepWriteable; +type CustomHostSettingsWriteable = DeepWriteable; + +export function getCanonicalCustomHostUrl(url: URL): string { + const port = getActualPort(url.protocol, url.port); + + return `${url.protocol}//${url.hostname}:${port}`; +} + +const ErrorPrefix = 'In configuration xpack.actions.customHosts,'; +const ValidProtocols = new Set(['https:', 'smtp:']); +const ProtocolsForSmtp = new Set(['smtp:']); + +// converts the custom host data in config, for ease of use, and to perform +// validation we can't do in config-schema, since the cloud validation can't +// do these sorts of validations +export function resolveCustomHosts(logger: Logger, config: ActionsConfig): ActionsConfig { + const result: ActionsConfigWriteable = cloneDeep(config); + + if (!result.customHostSettings) { + return result as ActionsConfig; + } + + const savedSettings: CustomHostSettingsWriteable[] = []; + + for (const customHostSetting of result.customHostSettings) { + const originalUrl = customHostSetting.url; + let parsedUrl: URL | undefined; + try { + parsedUrl = new URL(originalUrl); + } catch (err) { + logger.warn(`${ErrorPrefix} invalid URL "${originalUrl}", ignoring; error: ${err.message}`); + continue; + } + + customHostSetting.url = getCanonicalCustomHostUrl(parsedUrl); + + if (!ValidProtocols.has(parsedUrl.protocol)) { + logger.warn(`${ErrorPrefix} unsupported protocol used in URL "${originalUrl}", ignoring`); + continue; + } + + const port = getActualPort(parsedUrl.protocol, parsedUrl.port); + if (!port) { + logger.warn(`${ErrorPrefix} unable to determine port for URL "${originalUrl}", ignoring`); + continue; + } + + if (parsedUrl.username || parsedUrl.password) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains authentication information which will be ignored, but should be removed from the configuration` + ); + } + + if (parsedUrl.hash) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains hash information which will be ignored` + ); + } + + if (parsedUrl.pathname && parsedUrl.pathname !== '/') { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains path information which will be ignored` + ); + } + + if (!ProtocolsForSmtp.has(parsedUrl.protocol) && customHostSetting.smtp) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" contains smtp properties but does not use smtp; ignoring smtp properties` + ); + delete customHostSetting.smtp; + } + + // read the specified ca files, add their content to certificateAuthoritiesData + if (customHostSetting.tls) { + let files = customHostSetting.tls?.certificateAuthoritiesFiles || []; + if (typeof files === 'string') { + files = [files]; + } + for (const file of files) { + const contents = getFileContents(logger, file); + if (contents) { + appendToCertificateAuthoritiesData(customHostSetting, contents); + } + } + } + + const customSmtpSettings = customHostSetting.smtp; + if (customSmtpSettings) { + if (customSmtpSettings.requireTLS && customSmtpSettings.ignoreTLS) { + logger.warn( + `${ErrorPrefix} URL "${originalUrl}" cannot have both requireTLS and ignoreTLS set to true; using requireTLS: true and ignoreTLS: false` + ); + customSmtpSettings.requireTLS = true; + customSmtpSettings.ignoreTLS = false; + } + } + + savedSettings.push(customHostSetting); + } + + // check to see if there are any dups on the url + const existingUrls = new Set(); + for (const customHostSetting of savedSettings) { + const url = customHostSetting.url; + if (existingUrls.has(url)) { + logger.warn( + `${ErrorPrefix} multiple URLs match the canonical url "${url}"; only the first will be used` + ); + // mark this entry to be able to delete it after processing them all + customHostSetting.url = ''; + } + existingUrls.add(url); + } + + // remove the settings we want to skip + result.customHostSettings = savedSettings.filter((setting) => setting.url !== ''); + + return result as ActionsConfig; +} + +function appendToCertificateAuthoritiesData(customHost: CustomHostSettingsWriteable, cert: string) { + const tls = customHost.tls; + if (tls) { + if (!tls.certificateAuthoritiesData) { + tls.certificateAuthoritiesData = cert; + } else { + tls.certificateAuthoritiesData += '\n' + cert; + } + } +} + +function getFileContents(logger: Logger, fileName: string): string | undefined { + try { + return readFileSync(fileName, 'utf8'); + } catch (err) { + logger.warn( + `error reading file "${fileName}" specified in xpack.actions.customHosts, ignoring: ${err.message}` + ); + return; + } +} + +// 0 isn't a valid port, so result can be checked as falsy +function getActualPort(protocol: string, port: string): number { + if (port !== '') { + const portNumber = parseInt(port, 10); + if (isNaN(portNumber)) { + return 0; + } + return portNumber; + } + + // from https://nodejs.org/dist/latest-v14.x/docs/api/url.html#url_url_port + if (protocol === 'http:') return 80; + if (protocol === 'https:') return 443; + if (protocol === 'smtp:') return 25; + return 0; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 106e41259e692..2036ed6c7d343 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -35,6 +35,7 @@ import { } from './cleanup_failed_executions'; import { ActionsConfig, getValidatedConfig } from './config'; +import { resolveCustomHosts } from './lib/custom_host_settings'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; import { createExecutionEnqueuerFunction } from './create_execute_function'; @@ -157,7 +158,10 @@ export class ActionsPlugin implements Plugin()); + this.actionsConfig = getValidatedConfig( + this.logger, + resolveCustomHosts(this.logger, initContext.config.get()) + ); this.telemetryLogger = initContext.logger.get('usage'); this.preconfiguredActions = []; this.kibanaIndexConfig = initContext.config.legacy.get(); diff --git a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx index dc071fe93bbbd..9353c37b90728 100644 --- a/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx +++ b/x-pack/plugins/apm/public/components/shared/transaction_type_select.tsx @@ -10,7 +10,6 @@ import React, { FormEvent, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; import { useApmServiceContext } from '../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../context/url_params_context/use_url_params'; import * as urlHelpers from './Links/url_helpers'; // The default transaction type (for non-RUM services) is "request". Set the @@ -21,11 +20,8 @@ const EuiSelectWithWidth = styled(EuiSelect)` `; export function TransactionTypeSelect() { - const { transactionTypes } = useApmServiceContext(); + const { transactionTypes, transactionType } = useApmServiceContext(); const history = useHistory(); - const { - urlParams: { transactionType }, - } = useUrlParams(); const handleChange = useCallback( (event: FormEvent) => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts index 992bceaa3390c..e77717d689f95 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts @@ -5,7 +5,7 @@ * 2.0. */ -import squel from 'squel'; +import squel from 'safe-squel'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; /* eslint-disable */ import { queryEsSQL } from '../../../server/lib/query_es_sql'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index 9565408f7f47c..ba9173e54ec08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -19,6 +19,7 @@ import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; import { ApiLogs } from '../api_logs'; import { CurationsRouter } from '../curations'; +import { Documents, DocumentDetail } from '../documents'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; import { ResultSettings } from '../result_settings'; @@ -103,6 +104,14 @@ describe('EngineRouter', () => { expect(wrapper.find(AnalyticsRouter)).toHaveLength(1); }); + it('renders a documents view', () => { + setMockValues({ ...values, myRole: { canViewEngineDocuments: true } }); + const wrapper = shallow(); + + expect(wrapper.find(Documents)).toHaveLength(1); + expect(wrapper.find(DocumentDetail)).toHaveLength(1); + }); + it('renders a synonyms view', () => { setMockValues({ ...values, myRole: { canManageEngineSynonyms: true } }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 80d1096237345..65769446b10db 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -17,7 +17,6 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chro import { Loading } from '../../../shared/loading'; import { AppLogic } from '../../app_logic'; -// TODO: Uncomment and add more routes as we migrate them import { ENGINES_PATH, ENGINE_ANALYTICS_PATH, @@ -50,7 +49,7 @@ export const EngineRouter: React.FC = () => { const { myRole: { canViewEngineAnalytics, - // canViewEngineDocuments, + canViewEngineDocuments, // canViewEngineSchema, // canViewEngineCrawler, canViewMetaEngineSourceEngines, @@ -93,12 +92,16 @@ export const EngineRouter: React.FC = () => { )} - - - - - - + {canViewEngineDocuments && ( + + + + )} + {canViewEngineDocuments && ( + + + + )} {canManageEngineCurations && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index d26838335d8f6..2a7f256398381 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -173,7 +173,7 @@ describe('AppSearchNav', () => { setMockValues({ myRole: { canViewSettings: true } }); const wrapper = shallow(); - expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/settings/account'); + expect(wrapper.find(SideNavLink).last().prop('to')).toEqual('/settings'); }); it('renders the Credentials link', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index a04707ad48338..727312801c610 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -12,7 +12,7 @@ export const DOCS_PREFIX = docLinks.appSearchBase; export const ROOT_PATH = '/'; export const SETUP_GUIDE_PATH = '/setup_guide'; export const LIBRARY_PATH = '/library'; -export const SETTINGS_PATH = '/settings/account'; +export const SETTINGS_PATH = '/settings'; export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '/role_mappings'; @@ -21,9 +21,7 @@ export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; export const ENGINES_PATH = '/engines'; export const ENGINE_CREATION_PATH = '/engine_creation'; - export const ENGINE_PATH = `${ENGINES_PATH}/:engineName`; -export const SAMPLE_ENGINE_PATH = `${ENGINES_PATH}/national-parks-demo`; export const ENGINE_ANALYTICS_PATH = `${ENGINE_PATH}/analytics`; export const ENGINE_ANALYTICS_TOP_QUERIES_PATH = `${ENGINE_ANALYTICS_PATH}/top_queries`; @@ -48,11 +46,11 @@ export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`; export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`; export const ENGINE_SYNONYMS_PATH = `${ENGINE_PATH}/synonyms`; -export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result-settings`; +export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result_settings`; export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; export const ENGINE_CURATIONS_NEW_PATH = `${ENGINE_CURATIONS_PATH}/new`; export const ENGINE_CURATION_PATH = `${ENGINE_CURATIONS_PATH}/:curationId`; -export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/reference_application/new`; +export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/search_ui`; export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api_logs`; diff --git a/x-pack/plugins/file_upload/public/api/index.ts b/x-pack/plugins/file_upload/public/api/index.ts index 86b2d37967daa..c2520547ddad9 100644 --- a/x-pack/plugins/file_upload/public/api/index.ts +++ b/x-pack/plugins/file_upload/public/api/index.ts @@ -92,13 +92,17 @@ export async function checkIndexExists( ): Promise { const body = JSON.stringify({ index }); const fileUploadModules = await lazyLoadModules(); - const { exists } = await fileUploadModules.getHttp().fetch<{ exists: boolean }>({ - path: `/internal/file_upload/index_exists`, - method: 'POST', - body, - query: params, - }); - return exists; + try { + const { exists } = await fileUploadModules.getHttp().fetch<{ exists: boolean }>({ + path: `/internal/file_upload/index_exists`, + method: 'POST', + body, + query: params, + }); + return exists; + } catch (error) { + return false; + } } export async function getTimeFieldRange(index: string, query: unknown, timeFieldName?: string) { diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx index 65866243a3e47..ddb0e7d9b2b22 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/geojson_upload_form.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { GeoJsonFilePicker, OnFileSelectParameters } from './geojson_file_picker'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; import { IndexNameForm } from './index_name_form'; -import { validateIndexName } from '../../util/indexing_service'; +import { validateIndexName } from '../../validate_index_name'; const GEO_FIELD_TYPE_OPTIONS = [ { @@ -32,6 +32,8 @@ interface Props { onFileSelect: (onFileSelectParameters: OnFileSelectParameters) => void; onGeoFieldTypeSelect: (geoFieldType: ES_FIELD_TYPES.GEO_POINT | ES_FIELD_TYPES.GEO_SHAPE) => void; onIndexNameChange: (name: string, error?: string) => void; + onIndexNameValidationStart: () => void; + onIndexNameValidationEnd: () => void; } interface State { @@ -40,11 +42,20 @@ interface State { } export class GeoJsonUploadForm extends Component { + private _isMounted = false; state: State = { hasFile: false, isPointsOnly: false, }; + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + _onFileSelect = async (onFileSelectParameters: OnFileSelectParameters) => { this.setState({ hasFile: true, @@ -53,7 +64,12 @@ export class GeoJsonUploadForm extends Component { this.props.onFileSelect(onFileSelectParameters); + this.props.onIndexNameValidationStart(); const indexNameError = await validateIndexName(onFileSelectParameters.indexName); + if (!this._isMounted) { + return; + } + this.props.onIndexNameValidationEnd(); this.props.onIndexNameChange(onFileSelectParameters.indexName, indexNameError); const geoFieldType = @@ -107,6 +123,8 @@ export class GeoJsonUploadForm extends Component { indexName={this.props.indexName} indexNameError={this.props.indexNameError} onIndexNameChange={this.props.onIndexNameChange} + onIndexNameValidationStart={this.props.onIndexNameValidationStart} + onIndexNameValidationEnd={this.props.onIndexNameValidationEnd} /> ) : null} diff --git a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx index a6e83cfa6f3ab..0a70111e76b23 100644 --- a/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx +++ b/x-pack/plugins/file_upload/public/components/geojson_upload_form/index_name_form.tsx @@ -5,23 +5,46 @@ * 2.0. */ +import _ from 'lodash'; import React, { ChangeEvent, Component } from 'react'; import { EuiFormRow, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { validateIndexName } from '../../util/indexing_service'; +import { validateIndexName } from '../../validate_index_name'; export interface Props { indexName: string; indexNameError?: string; onIndexNameChange: (name: string, error?: string) => void; + onIndexNameValidationStart: () => void; + onIndexNameValidationEnd: () => void; } export class IndexNameForm extends Component { - _onIndexNameChange = async (event: ChangeEvent) => { + private _isMounted = false; + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + _onIndexNameChange = (event: ChangeEvent) => { const indexName = event.target.value; + this.props.onIndexNameChange(indexName); + this._validateIndexName(indexName); + this.props.onIndexNameValidationStart(); + }; + + _validateIndexName = _.debounce(async (indexName: string) => { const indexNameError = await validateIndexName(indexName); + if (!this._isMounted || indexName !== this.props.indexName) { + return; + } + this.props.onIndexNameValidationEnd(); this.props.onIndexNameChange(indexName, indexNameError); - }; + }, 500); render() { const errors = [...(this.props.indexNameError ? [this.props.indexNameError] : [])]; diff --git a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx index 5863b18d0cea0..28e99e7ffb18b 100644 --- a/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx +++ b/x-pack/plugins/file_upload/public/components/json_upload_and_parse.tsx @@ -274,7 +274,11 @@ export class JsonUploadAndParse extends Component ); } diff --git a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts index c2bc36e3cc450..b0f1b98a9ae72 100644 --- a/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/file_upload/public/lazy_load_bundle/index.ts @@ -24,7 +24,8 @@ export interface FileUploadComponentProps { isIndexingTriggered: boolean; onFileSelect: (geojsonFile: FeatureCollection, name: string, previewCoverage: number) => void; onFileClear: () => void; - onIndexReady: (indexReady: boolean) => void; + enableImportBtn: () => void; + disableImportBtn: () => void; onUploadComplete: (results: FileUploadGeoResults) => void; onUploadError: () => void; } diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.ts b/x-pack/plugins/file_upload/public/util/indexing_service.ts deleted file mode 100644 index 4dcff3dbe7f0e..0000000000000 --- a/x-pack/plugins/file_upload/public/util/indexing_service.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { getIndexPatternService, getHttp } from '../kibana_services'; - -export const getExistingIndexNames = _.debounce( - async () => { - let indexes; - try { - indexes = await getHttp().fetch({ - path: `/api/index_management/indices`, - method: 'GET', - }); - } catch (e) { - // Log to console. Further diagnostics can be made in network request - // eslint-disable-next-line no-console - console.error(e); - } - return indexes ? indexes.map(({ name }: { name: string }) => name) : []; - }, - 10000, - { leading: true } -); - -export function checkIndexPatternValid(name: string) { - const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; - const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); - const indexPatternInvalid = - byteLength > 255 || // name can't be greater than 255 bytes - name !== name.toLowerCase() || // name should be lowercase - name === '.' || - name === '..' || // name can't be . or .. - name.match(/^[-_+]/) !== null || // name can't start with these chars - name.match(reg) !== null; // name can't contain these chars - return !indexPatternInvalid; -} - -export const validateIndexName = async (indexName: string) => { - if (!checkIndexPatternValid(indexName)) { - return i18n.translate( - 'xpack.fileUpload.util.indexingService.indexNameContainsIllegalCharactersErrorMessage', - { - defaultMessage: 'Index name contains illegal characters.', - } - ); - } - - const indexNames = await getExistingIndexNames(); - const indexPatternNames = await getIndexPatternService().getTitles(); - let indexNameError; - if (indexNames.includes(indexName)) { - indexNameError = i18n.translate( - 'xpack.fileUpload.util.indexingService.indexNameAlreadyExistsErrorMessage', - { - defaultMessage: 'Index name already exists.', - } - ); - } else if (indexPatternNames.includes(indexName)) { - indexNameError = i18n.translate( - 'xpack.fileUpload.util.indexingService.indexPatternAlreadyExistsErrorMessage', - { - defaultMessage: 'Index pattern already exists.', - } - ); - } - return indexNameError; -}; diff --git a/x-pack/plugins/file_upload/public/util/indexing_service.test.ts b/x-pack/plugins/file_upload/public/validate_index_name.test.ts similarity index 88% rename from x-pack/plugins/file_upload/public/util/indexing_service.test.ts rename to x-pack/plugins/file_upload/public/validate_index_name.test.ts index b8dfde9ccdc48..7422ced974e37 100644 --- a/x-pack/plugins/file_upload/public/util/indexing_service.test.ts +++ b/x-pack/plugins/file_upload/public/validate_index_name.test.ts @@ -5,12 +5,10 @@ * 2.0. */ -// Not all index pattern dependencies are avab. in jest context, -// prevent unrelated import errors by mocking kibana services -jest.mock('../kibana_services', () => {}); -import { checkIndexPatternValid } from './indexing_service'; +jest.mock('./kibana_services', () => {}); +import { checkIndexPatternValid } from './validate_index_name'; -describe('indexing_service', () => { +describe('checkIndexPatternValid', () => { const validNames = [ 'lowercaseletters', // Lowercase only '123', // Cannot include \, /, *, ?, ", <, >, |, " " (space character), , (comma), # diff --git a/x-pack/plugins/file_upload/public/validate_index_name.ts b/x-pack/plugins/file_upload/public/validate_index_name.ts new file mode 100644 index 0000000000000..cd190188b6a63 --- /dev/null +++ b/x-pack/plugins/file_upload/public/validate_index_name.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { getIndexPatternService } from './kibana_services'; +import { checkIndexExists } from './api'; + +export function checkIndexPatternValid(name: string) { + const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1; + const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); + const indexPatternInvalid = + byteLength > 255 || // name can't be greater than 255 bytes + name !== name.toLowerCase() || // name should be lowercase + name === '.' || + name === '..' || // name can't be . or .. + name.match(/^[-_+]/) !== null || // name can't start with these chars + name.match(reg) !== null; // name can't contain these chars + return !indexPatternInvalid; +} + +export const validateIndexName = async (indexName: string) => { + if (!checkIndexPatternValid(indexName)) { + return i18n.translate('xpack.fileUpload.indexNameContainsIllegalCharactersErrorMessage', { + defaultMessage: 'Index name contains illegal characters.', + }); + } + + const indexPatternNames = await getIndexPatternService().getTitles(); + if (indexPatternNames.includes(indexName)) { + return i18n.translate('xpack.fileUpload.indexPatternAlreadyExistsErrorMessage', { + defaultMessage: 'Index pattern already exists.', + }); + } + + const indexExists = await checkIndexExists(indexName); + if (indexExists) { + return i18n.translate('xpack.fileUpload.indexNameAlreadyExistsErrorMessage', { + defaultMessage: 'Index name already exists.', + }); + } +}; diff --git a/x-pack/plugins/file_upload/server/routes.ts b/x-pack/plugins/file_upload/server/routes.ts index f2e796ec53ce0..8e6651ed891c6 100644 --- a/x-pack/plugins/file_upload/server/routes.ts +++ b/x-pack/plugins/file_upload/server/routes.ts @@ -195,7 +195,7 @@ export function fileUploadRoutes(coreSetup: CoreSetup, logge body: schema.object({ index: schema.string() }), }, options: { - tags: ['access:fileUpload:analyzeFile'], + tags: ['access:fileUpload:import'], }, }, async (context, request, response) => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx index f3c353fd75dba..56f28ada004e2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx @@ -340,7 +340,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { onClose={onConfirmModalClose} /> )} - +

diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx index 543819aca87a5..4ff5243483a3a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx @@ -7,7 +7,14 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiTabs, EuiTab, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; +import { + EuiTabs, + EuiTab, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiPortal, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import type { Section } from '../sections'; @@ -58,11 +65,13 @@ export const DefaultLayout: React.FunctionComponent = ({ return ( <> {modal === 'settings' && ( - { - setModal(null); - }} - /> + + { + setModal(null); + }} + /> + )} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 88249f7f5d5ce..70cb6cddad5fa 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -481,10 +481,12 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return ( <> {isEnrollmentFlyoutOpen ? ( - setIsEnrollmentFlyoutOpen(false)} - /> + + setIsEnrollmentFlyoutOpen(false)} + /> + ) : null} {agentToReassign && ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx index 0ad1706e5273f..1aa88dcef4adc 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlyout, EuiFlyoutBody, @@ -37,9 +37,7 @@ interface Props { agentPolicies?: AgentPolicy[]; } -const MissingFleetServerHostCallout: React.FunctionComponent<{ onClose: () => void }> = ({ - onClose, -}) => { +const MissingFleetServerHostCallout: React.FunctionComponent = () => { const { setModal } = useUrlModal(); return ( vo fill iconType="gear" onClick={() => { - onClose(); setModal('settings'); }} > @@ -89,11 +86,21 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); + const { modal } = useUrlModal(); + const [lastModal, setLastModal] = useState(modal); const settings = useGetSettings(); const fleetServerHosts = settings.data?.item?.fleet_server_hosts || []; + // Refresh settings when there is a modal/flyout change + useEffect(() => { + if (modal !== lastModal) { + settings.resendRequest(); + setLastModal(modal); + } + }, [modal, lastModal, settings]); + return ( - +

@@ -130,7 +137,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ + ) : undefined } > diff --git a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts index aa36a3a7562bf..b408d0908e768 100644 --- a/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/data_streams/handlers.ts @@ -91,7 +91,7 @@ export const getListHandler: RequestHandler = async (context, request, response) allDashboards[pkgSavedObject.id] = dashboards; return allDashboards; }, {}); - const allDashboardSavedObjects = await context.core.savedObjects.client.bulkGet<{ + const allDashboardSavedObjectsResponse = await context.core.savedObjects.client.bulkGet<{ title?: string; }>( Object.values(dashboardIdsByPackageName).reduce( @@ -107,8 +107,19 @@ export const getListHandler: RequestHandler = async (context, request, response) [] ) ); + // Ignore dashboards not found + const allDashboardSavedObjects = allDashboardSavedObjectsResponse.saved_objects.filter((so) => { + if (so.error) { + if (so.error.statusCode === 404) { + return false; + } + throw so.error; + } + return true; + }); + const allDashboardSavedObjectsById = keyBy( - allDashboardSavedObjects.saved_objects, + allDashboardSavedObjects, (dashboardSavedObject) => dashboardSavedObject.id ); diff --git a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx index 79902cf620511..7d6f6757bef18 100644 --- a/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/file_upload_wizard/wizard.tsx @@ -26,14 +26,14 @@ export enum UPLOAD_STEPS { } enum INDEXING_STAGE { - READY = 'READY', + CONFIGURE = 'CONFIGURE', TRIGGERED = 'TRIGGERED', SUCCESS = 'SUCCESS', ERROR = 'ERROR', } interface State { - indexingStage: INDEXING_STAGE | null; + indexingStage: INDEXING_STAGE; fileUploadComponent: React.ComponentType | null; results?: FileUploadGeoResults; } @@ -42,7 +42,7 @@ export class ClientFileCreateSourceEditor extends Component { - if (!this._isMounted) { - return; - } - this.setState({ indexingStage: indexReady ? INDEXING_STAGE.READY : null }); - if (indexReady) { - this.props.enableNextBtn(); - } else { - this.props.disableNextBtn(); - } - }; - render() { if (!this.state.fileUploadComponent) { return null; @@ -181,7 +168,8 @@ export class ClientFileCreateSourceEditor extends Component diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 50043772af95b..8e31ad7855197 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -167,9 +167,8 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); - let resp; try { - resp = await searchSource + const { rawResponse: resp } = await searchSource .fetch$({ abortSignal: abortController.signal, sessionId: searchSessionId, @@ -182,6 +181,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }, }) .toPromise(); + return resp; } catch (error) { if (isSearchSourceAbortError(error)) { throw new DataRequestAbortError(); @@ -194,8 +194,6 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource }) ); } - - return resp; } async makeSearchSource( diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx index da353b52ef1c0..d00d4efc25b8d 100644 --- a/x-pack/plugins/ml/public/alerting/job_selector.tsx +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -99,10 +99,7 @@ export const JobSelectorControl: FC = ({ + } isInvalid={!!errors?.length} error={errors} diff --git a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx index dac1fad72255c..b87a447bd4b15 100644 --- a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx +++ b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx @@ -82,6 +82,7 @@ export const MlAnomalyAlertFlyout: FC = ({ interface JobListMlAnomalyAlertFlyoutProps { setShowFunction: (callback: Function) => void; unsetShowFunction: () => void; + onSave: () => void; } /** @@ -93,6 +94,7 @@ interface JobListMlAnomalyAlertFlyoutProps { export const JobListMlAnomalyAlertFlyout: FC = ({ setShowFunction, unsetShowFunction, + onSave, }) => { const [isVisible, setIsVisible] = useState(false); const [jobIds, setJobIds] = useState(); @@ -115,6 +117,7 @@ export const JobListMlAnomalyAlertFlyout: FC = onCloseFlyout={() => setIsVisible(false)} onSave={() => { setIsVisible(false); + onSave(); }} /> ) : null; @@ -122,9 +125,10 @@ export const JobListMlAnomalyAlertFlyout: FC = interface EditRuleFlyoutProps { initialAlert: MlAnomalyDetectionAlertRule; + onSave: () => void; } -export const EditAlertRule: FC = ({ initialAlert }) => { +export const EditAlertRule: FC = ({ initialAlert, onSave }) => { const [isVisible, setIsVisible] = useState(false); return ( <> @@ -136,7 +140,10 @@ export const EditAlertRule: FC = ({ initialAlert }) => { { + setIsVisible(false); + onSave(); + }} /> ) : null} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js index f8195f5747f7e..85ab2bf2f8889 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import React, { useMemo } from 'react'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useCreateADLinks } from '../../../../components/custom_hooks/use_create_ad_links'; @@ -48,34 +48,44 @@ export function ResultLinks({ jobs }) { const anomalyExplorerLink = useMemo(() => createLinkWithUserDefaults('explorer', jobs), [jobs]); return ( - + {singleMetricVisible && ( - + + + + + + )} + + - )} - - - -
- + + ); } ResultLinks.propTypes = { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js index 5b7a41e572dab..673484f08e196 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { EuiLink } from '@elastic/eui'; import { EditAlertRule } from '../../../../../alerting/ml_alerting_flyout'; -export function extractJobDetails(job, basePath) { +export function extractJobDetails(job, basePath, refreshJobList) { if (Object.keys(job).length === 0) { return {}; } @@ -82,7 +82,7 @@ export function extractJobDetails(job, basePath) { }), position: 'right', items: (job.alerting_rules ?? []).map((v) => { - return ['', ]; + return ['', ]; }), }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js index c8412a2a83d8a..812d156421c16 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js @@ -55,6 +55,8 @@ export class JobDetailsUI extends Component {
); } else { + const { showFullDetails, refreshJobList } = this.props; + const { general, customUrl, @@ -71,9 +73,8 @@ export class JobDetailsUI extends Component { jobTimingStats, datafeedTimingStats, alertRules, - } = extractJobDetails(job, basePath); + } = extractJobDetails(job, basePath, refreshJobList); - const { showFullDetails, refreshJobList } = this.props; const tabs = [ { id: 'job-settings', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/_jobs_list.scss b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/_jobs_list.scss index 28b1a4259406a..65d8ccf4ec200 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/_jobs_list.scss +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/_jobs_list.scss @@ -87,16 +87,6 @@ } } - .actions-border { - // SASSTODO: Proper calc - height: 20px; - border-right: $euiBorderThin; - width: 1px; - display: inline-block; - vertical-align: middle; - margin: 0 $euiSizeXS; - } - .job-description { display: inline-block; } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index abd0794ff2c35..bd85420397218 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -277,6 +277,7 @@ export class JobsList extends Component { defaultMessage: 'Actions', }), render: (item) => , + width: '8%', }, ]; @@ -340,6 +341,7 @@ export class JobsList extends Component { this.props.refreshJobs, this.props.showCreateAlertFlyout ), + width: '40px', }); } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index ac7224b3f3164..214b7616cf927 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -521,6 +521,7 @@ export class JobsListView extends Component { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/_multi_job_actions.scss b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/_multi_job_actions.scss index 131e4ea12ce50..306e1e9d3584b 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/_multi_job_actions.scss +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/_multi_job_actions.scss @@ -1,10 +1,5 @@ // SASSTODO: This looks like it needs some rewriting for all the pixel values .multi-select-actions { - padding-right: $euiSizeS; - padding-bottom: $euiSizeM; - display: inline-block; - white-space: nowrap; - .actions-border, .actions-border-large { height: 20px; border-right: $euiBorderThin; @@ -16,7 +11,6 @@ .actions-border-large { height: 35px; margin: 0 15px; - margin-top: -5px; } .results-button { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js index 6b3d6bc8971f5..2c73a73b77abe 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js @@ -145,7 +145,7 @@ class MultiJobActionsMenuUI extends Component { ); } - if (this.canCreateMlAlerts) { + if (this.canCreateMlAlerts && this.props.jobs.length === 1) { items.push( {jobsSelected && ( - - -

- -

-
-
- - - - - - + + + +

+ +

+
+
+ +
+ + + + + + + + + + + )}
); diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index ef83230fc2aba..0ff04e4f731d0 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -17,6 +17,12 @@ export const UNKNOWN_SPACE = '?'; export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; + +/** + * Reserved application privileges are always assigned to this "wildcard" application. + * This allows them to be applied to any Kibana "tenant" (`kibana.index`). Since reserved privileges are always assigned to reserved (built-in) roles, + * it's not possible to know the tenant ahead of time. + */ export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; /** diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index 4c657294c965c..6f00df3a4ee7b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -742,6 +742,21 @@ describe('global base read', () => { }); }); +describe('global and reserved', () => { + it('base all, reserved_foo', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { spaces: ['*'], base: [], feature: {}, _reserved: ['foo'] }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Foo', overridden: false } }, + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + ]); + }); +}); + describe('global normal feature privilege all', () => { describe('default and marketing space', () => { it('base all', () => { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index 24366a250cf11..d2385adc99162 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -285,7 +285,7 @@ describe('GET role', () => { indices: [], applications: [ { - application, + application: reservedPrivilegesApplicationWildcard, privileges: ['reserved_customApplication1', 'reserved_customApplication2'], resources: ['*'], }, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts index d490153b30394..09262d7cbbadd 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -283,7 +283,7 @@ describe('GET all roles', () => { indices: [], applications: [ { - application, + application: reservedPrivilegesApplicationWildcard, privileges: ['reserved_customApplication1', 'reserved_customApplication2'], resources: ['*'], }, @@ -1030,7 +1030,7 @@ describe('GET all roles', () => { ); getRolesTest( - `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + `reserved privilege assigned with a feature privilege returns populated kibana section`, { apiResponse: async () => ({ first_role: { @@ -1039,7 +1039,12 @@ describe('GET all roles', () => { applications: [ { application, - privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], + privileges: ['feature_foo.foo-privilege-1'], + resources: ['*'], + }, + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['reserved_foo'], resources: ['*'], }, ], @@ -1068,8 +1073,22 @@ describe('GET all roles', () => { indices: [], run_as: [], }, - kibana: [], - _transform_error: ['kibana'], + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1'], + }, + spaces: ['*'], + }, + { + base: [], + feature: {}, + _reserved: ['foo'], + spaces: ['*'], + }, + ], + _transform_error: [], _unrecognized_applications: [], }, ], diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts index 74a035cdd0cb6..fa119ca704753 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts @@ -83,13 +83,13 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - // if space privilege assigned globally, we can't transform these + // if there is a reserved privilege assigned to an application other than the reserved privileges application wildcard, we won't transform these. if ( roleKibanaApplications.some( (entry) => - entry.resources.includes(GLOBAL_RESOURCE) && + entry.application !== RESERVED_PRIVILEGES_APPLICATION_WILDCARD && entry.privileges.some((privilege) => - PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) ) ) ) { @@ -98,15 +98,13 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - // if global base or reserved privilege assigned at a space, we can't transform these + // if space privilege assigned globally, we can't transform these if ( roleKibanaApplications.some( (entry) => - !entry.resources.includes(GLOBAL_RESOURCE) && - entry.privileges.some( - (privilege) => - PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) || - PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + entry.resources.includes(GLOBAL_RESOURCE) && + entry.privileges.some((privilege) => + PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) ) ) ) { @@ -115,15 +113,15 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - // if reserved privilege assigned with feature or base privileges, we won't transform these + // if global base or reserved privilege assigned at a space, we can't transform these if ( roleKibanaApplications.some( (entry) => - entry.privileges.some((privilege) => - PrivilegeSerializer.isSerializedReservedPrivilege(privilege) - ) && + !entry.resources.includes(GLOBAL_RESOURCE) && entry.privileges.some( - (privilege) => !PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + (privilege) => + PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) || + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) ) ) ) { @@ -163,7 +161,10 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - const allResources = roleKibanaApplications.map((entry) => entry.resources).flat(); + const allResources = roleKibanaApplications + .filter((entry) => entry.application !== RESERVED_PRIVILEGES_APPLICATION_WILDCARD) + .flatMap((entry) => entry.resources); + // if we have improperly formatted resource entries, we can't transform these if ( allResources.some( diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index d9f67e31196ca..02299a5398555 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -25,3 +25,6 @@ export const TRUSTED_APPS_SUMMARY_API = '/api/endpoint/trusted_apps/summary'; export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; export const BASE_POLICY_ROUTE = `/api/endpoint/policy`; export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; + +/** Host Isolation Routes */ +export const HOST_ISOLATION_CREATE_API = `/api/endpoint/isolate`; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 8cce97ea07cb1..70ed468e61554 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -14,6 +14,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; const allowedExperimentalValues = Object.freeze({ trustedAppsByPolicyEnabled: false, eventFilteringEnabled: false, + hostIsolationEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/default_headers.ts b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/default_headers.ts index b0a497123f218..74ba4ec4a3be3 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/default_headers.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/default_headers.ts @@ -18,53 +18,53 @@ export const alertsHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, id: '@timestamp', - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.module', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, linkField: 'rule.reference', }, { columnHeaderType: defaultColumnHeaderType, id: 'event.dataset', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.category', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.severity', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'observer.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'message', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'agent.id', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'agent.type', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts index 3466298d5ede3..e2e506e6e1a3f 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts @@ -11,7 +11,7 @@ import { Dispatch } from 'redux'; import { ActionCreator } from 'typescript-fsa'; import { stopPropagationAndPreventDefault } from '../accessibility/helpers'; -import { alertsHeaders } from '../../../detections/components/alerts_table/default_config'; +import { alertsHeaders } from '../alerts_viewer/default_headers'; import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source'; import { dragAndDropActions } from '../../store/actions'; import { IdToDataProvider } from '../../store/drag_and_drop/model'; @@ -218,7 +218,7 @@ export const addFieldToTimelineColumns = ({ linkField: linkFields[fieldId] ?? undefined, type: column.type, aggregatable: column.aggregatable, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, ...initColumnHeader, }, id: timelineId, @@ -232,7 +232,7 @@ export const addFieldToTimelineColumns = ({ column: { columnHeaderType: 'not-filtered', id: fieldId, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, id: timelineId, index: result.destination != null ? result.destination.index : 0, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index 836a67441ef8a..22c2b40ed62ce 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -98,7 +98,7 @@ export const getColumns = ({ toggleColumn({ columnHeaderType: defaultColumnHeaderType, id: field, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }) } disabled={data.isObjectArray && data.type !== 'geo_point'} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx index dfbaadbeed7b1..1f12c2de5e24f 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx @@ -98,7 +98,7 @@ export const getColumnHeaderFromBrowserField = ({ id: browserField.name || '', type: browserField.type, aggregatable: browserField.aggregatable, - width, + initialWidth: width, }); /** diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx index 59d475b0b8d81..7c84a325cb667 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx @@ -16,46 +16,46 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, id: '@timestamp', - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'message', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.module', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.dataset', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.action', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'user.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'source.ip', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'destination.ip', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 538765e9fafa3..d55c3c66e699f 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -42,6 +42,7 @@ export const mockGlobalState: State = { enableExperimental: { eventFilteringEnabled: false, trustedAppsByPolicyEnabled: false, + hostIsolationEnabled: false, }, }, hosts: { diff --git a/x-pack/plugins/security_solution/public/common/mock/header.ts b/x-pack/plugins/security_solution/public/common/mock/header.ts index 45339bd0d3df6..ae7d3c9e576a8 100644 --- a/x-pack/plugins/security_solution/public/common/mock/header.ts +++ b/x-pack/plugins/security_solution/public/common/mock/header.ts @@ -22,7 +22,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: '@timestamp', type: 'date', aggregatable: true, - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, { category: 'event', @@ -33,7 +33,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'event.severity', type: 'long', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'event', @@ -44,7 +44,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'event.category', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'event', @@ -55,7 +55,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'event.action', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'host', @@ -66,7 +66,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'host.name', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'source', @@ -76,7 +76,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'source.ip', type: 'ip', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'destination', @@ -86,7 +86,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'destination.ip', type: 'ip', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { aggregatable: true, @@ -97,7 +97,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ format: 'bytes', id: 'destination.bytes', type: 'number', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'user', @@ -107,7 +107,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'user.name', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'base', @@ -117,7 +117,7 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: '_id', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { category: 'base', @@ -128,6 +128,6 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ id: 'message', type: 'text', aggregatable: false, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index c02c47d45f732..806031b07e0c9 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -1937,37 +1937,37 @@ export const mockTimelineModel: TimelineModel = { { columnHeaderType: 'not-filtered', id: '@timestamp', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -2082,14 +2082,14 @@ export const defaultTimelineProps: CreateTimelineProps = { activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, columns: [ - { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', width: 190 }, - { columnHeaderType: 'not-filtered', id: 'message', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'event.category', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'event.action', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'host.name', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'source.ip', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'destination.ip', width: 180 }, - { columnHeaderType: 'not-filtered', id: 'user.name', width: 180 }, + { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', initialWidth: 190 }, + { columnHeaderType: 'not-filtered', id: 'message', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'event.category', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'event.action', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'host.name', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'source.ip', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'destination.ip', initialWidth: 180 }, + { columnHeaderType: 'not-filtered', id: 'user.name', initialWidth: 180 }, ], dataProviders: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index d5b64a8fe27fc..08e88567b0fd0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -106,37 +106,37 @@ describe('alert actions', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 6a83039bf1ec8..478c8930b8dd3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -9,15 +9,9 @@ import { RowRendererId } from '../../../../common/types/timeline'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter } from '../../../../../../../src/plugins/data/common/es_query'; -import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { - DEFAULT_COLUMN_MIN_WIDTH, - DEFAULT_DATE_COLUMN_MIN_WIDTH, -} from '../../../timelines/components/timeline/body/constants'; -import { ColumnHeaderOptions, SubsetTimelineModel } from '../../../timelines/store/timeline/model'; +import { SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; - -import * as i18n from './translations'; +import { columns } from '../../configurations/security_solution_detections/columns'; export const buildAlertStatusFilter = (status: Status): Filter[] => [ { @@ -98,87 +92,9 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean): ] : []; -export const alertsHeaders: ColumnHeaderOptions[] = [ - { - columnHeaderType: defaultColumnHeaderType, - id: '@timestamp', - width: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.name', - label: i18n.ALERTS_HEADERS_RULE, - linkField: 'signal.rule.id', - width: DEFAULT_COLUMN_MIN_WIDTH, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.version', - label: i18n.ALERTS_HEADERS_VERSION, - width: 95, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.type', - label: i18n.ALERTS_HEADERS_METHOD, - width: 100, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.severity', - label: i18n.ALERTS_HEADERS_SEVERITY, - width: 105, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'signal.rule.risk_score', - label: i18n.ALERTS_HEADERS_RISK_SCORE, - width: 115, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'event.module', - linkField: 'rule.reference', - width: DEFAULT_COLUMN_MIN_WIDTH, - }, - { - category: 'event', - columnHeaderType: defaultColumnHeaderType, - id: 'event.action', - type: 'string', - aggregatable: true, - width: 140, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'event.category', - width: 150, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'host.name', - width: 120, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'user.name', - width: 120, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'source.ip', - width: 120, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'destination.ip', - width: 140, - }, -]; - export const alertsDefaultModel: SubsetTimelineModel = { ...timelineDefaults, - columns: alertsHeaders, + columns, showCheckboxes: true, excludedRowRendererIds: Object.values(RowRendererId), }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 2890eb912b84c..9dc83d7898963 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -48,8 +48,8 @@ import { import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { buildTimeRangeFilter } from './helpers'; -import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; +import { columns, RenderCellValue } from '../../configurations/security_solution_detections'; interface OwnProps { defaultFilters?: Filter[]; @@ -311,7 +311,10 @@ export const AlertsTableComponent: React.FC = ({ useEffect(() => { initializeTimeline({ - defaultModel: alertsDefaultModel, + defaultModel: { + ...alertsDefaultModel, + columns, + }, documentType: i18n.ALERTS_DOCUMENT_TYPE, filterManager, footerText: i18n.TOTAL_COUNT_OF_ALERTS, @@ -346,7 +349,7 @@ export const AlertsTableComponent: React.FC = ({ headerFilterGroup={headerFilterGroup} id={timelineId} onRuleChange={onRuleChange} - renderCellValue={DefaultCellRenderer} + renderCellValue={RenderCellValue} rowRenderers={defaultRowRenderers} scopeId={SourcererScopeName.detections} start={from} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index 56f6337d5a55c..2d9f947dcea67 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -60,6 +60,13 @@ export const ALERTS_HEADERS_RULE = i18n.translate( } ); +export const ALERTS_HEADERS_RULE_NAME = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.ruleNameTitle', + { + defaultMessage: 'Rule name', + } +); + export const ALERTS_HEADERS_VERSION = i18n.translate( 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.versionTitle', { @@ -81,6 +88,13 @@ export const ALERTS_HEADERS_SEVERITY = i18n.translate( } ); +export const ALERTS_HEADERS_REASON = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.reasonTitle', + { + defaultMessage: 'Reason', + } +); + export const ALERTS_HEADERS_RISK_SCORE = i18n.translate( 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.riskScoreTitle', { @@ -172,6 +186,13 @@ export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => 'Successfully closed {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.', }); +export const ALERT_DURATION = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.alertDurationTitle', + { + defaultMessage: 'Alert duration', + } +); + export const OPENED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => i18n.translate('xpack.securitySolution.detectionEngine.alerts.openedAlertSuccessToastMessage', { values: { totalAlerts }, @@ -216,3 +237,17 @@ export const MORE_ACTIONS = i18n.translate( defaultMessage: 'More actions', } ); + +export const STATUS = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.statusTitle', + { + defaultMessage: 'Status', + } +); + +export const TRIGGERED = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.triggeredTitle', + { + defaultMessage: 'Triggered', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx new file mode 100644 index 0000000000000..30ee7e77f3a7d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useState, useCallback } from 'react'; +import { find } from 'lodash/fp'; +import { + EuiCallOut, + EuiTitle, + EuiText, + EuiTextArea, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useHostIsolation } from '../../containers/detection_engine/alerts/use_host_isolation'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; +import { + CANCEL, + CASES_ASSOCIATED_WITH_ALERT, + COMMENT, + COMMENT_PLACEHOLDER, + CONFIRM, + RETURN_TO_ALERT_DETAILS, +} from './translations'; +import { Maybe } from '../../../../../observability/common/typings'; + +export const HostIsolationPanel = React.memo( + ({ + details, + cancelCallback, + }: { + details: Maybe; + cancelCallback: () => void; + }) => { + const [comment, setComment] = useState(''); + const [isIsolated, setIsIsolated] = useState(false); + + const agentId = useMemo(() => { + const findAgentId = find({ category: 'agent', field: 'agent.id' }, details)?.values; + return findAgentId ? findAgentId[0] : ''; + }, [details]); + + const hostName = useMemo(() => { + const findHostName = find({ category: 'host', field: 'host.name' }, details)?.values; + return findHostName ? findHostName[0] : ''; + }, [details]); + + const alertRule = useMemo(() => { + const findAlertRule = find({ category: 'signal', field: 'signal.rule.name' }, details) + ?.values; + return findAlertRule ? findAlertRule[0] : ''; + }, [details]); + + const { loading, isolateHost } = useHostIsolation({ agentId, comment }); + + const confirmHostIsolation = useCallback(async () => { + const hostIsolated = await isolateHost(); + setIsIsolated(hostIsolated); + }, [isolateHost]); + + const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]); + + // a placeholder until we get the case count returned from a new case route in a future pr + const caseCount: number = 0; + + const hostIsolated = useMemo(() => { + return ( + <> + + + {caseCount > 0 && ( + <> + +

+ +

+
+ +
    +
  • + +
  • +
+
+ + )} +
+ + + + +

{RETURN_TO_ALERT_DETAILS}

+
+
+
+
+ + ); + }, [backToAlertDetails, hostName]); + + const hostNotIsolated = useMemo(() => { + return ( + <> + + +

+ {hostName}, + cases: ( + + {caseCount} + {CASES_ASSOCIATED_WITH_ALERT} + {alertRule} + + ), + }} + /> +

+
+ + +

{COMMENT}

+
+ ) => + setComment(event.target.value) + } + /> + + + + {CANCEL} + + + + {CONFIRM} + + + + + ); + }, [alertRule, backToAlertDetails, comment, confirmHostIsolation, hostName, loading]); + + return isIsolated ? hostIsolated : hostNotIsolated; + } +); + +HostIsolationPanel.displayName = 'HostIsolationContent'; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx new file mode 100644 index 0000000000000..36f2553e1b927 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { EuiContextMenuItem, EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; +import { ISOLATE_HOST } from './translations'; +import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations'; + +export const TakeActionDropdown = React.memo( + ({ onChange }: { onChange: (action: 'isolateHost') => void }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const closePopoverHandler = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const takeActionItems = useMemo(() => { + return [ + { + setIsPopoverOpen(false); + onChange('isolateHost'); + }} + > + {ISOLATE_HOST} + , + ]; + }, [onChange]); + + const takeActionButton = useMemo(() => { + return ( + { + setIsPopoverOpen(!isPopoverOpen); + }} + > + {TAKE_ACTION} + + ); + }, [isPopoverOpen]); + + return ( + + + + ); + } +); + +TakeActionDropdown.displayName = 'TakeActionDropdown'; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts new file mode 100644 index 0000000000000..97a1a278952a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ISOLATE_HOST = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.isolateHost', + { + defaultMessage: 'Isolate host', + } +); + +export const COMMENT = i18n.translate('xpack.securitySolution.endpoint.hostIsolation.comment', { + defaultMessage: 'Comment', +}); + +export const COMMENT_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.comment.placeholder', + { defaultMessage: 'You may leave an optional note here.' } +); + +export const CANCEL = i18n.translate('xpack.securitySolution.endpoint.hostIsolation.cancel', { + defaultMessage: 'Cancel', +}); + +export const CONFIRM = i18n.translate('xpack.securitySolution.endpoint.hostIsolation.confirm', { + defaultMessage: 'Confirm', +}); + +export const CASES_ASSOCIATED_WITH_ALERT = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWihtAlert', + { + defaultMessage: ' cases associated with the rule ', + } +); + +export const RETURN_TO_ALERT_DETAILS = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.returnToAlertDetails', + { defaultMessage: 'Return to alert details' } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/severity/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/severity/index.test.tsx new file mode 100644 index 0000000000000..946a59b5bdf0a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/severity/index.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { Severity } from '.'; + +interface Expected { + color: string; + severity: 'low' | 'medium' | 'high' | 'critical' | 'any-other-severity'; + textColor: string; +} + +describe('Severity', () => { + const expected: Expected[] = [ + { + color: '#C5CFD8', + severity: 'low', + textColor: 'default', + }, + { + color: '#EFC44C', + severity: 'medium', + textColor: 'default', + }, + { + color: '#FF7E62', + severity: 'high', + textColor: 'ghost', + }, + { + color: '#C3505E', + severity: 'critical', + textColor: 'ghost', + }, + { + color: 'hollow', + severity: 'any-other-severity', + textColor: 'default', + }, + ]; + + test('it capitalizes the provided `severity`', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="severity-badge"]').first()).toHaveStyleRule( + 'text-transform', + 'capitalize' + ); + }); + + test('it renders the provided `severity`', () => { + const wrapper = mount(); + + expect(wrapper.text()).toBe('critical'); + }); + + expected.forEach(({ severity, color, textColor }) => { + test(`it renders the expected badge color when severity is ${severity}`, () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="severity-badge"]').first().props().color).toEqual( + color + ); + }); + + test(`it renders the expected text color when severity is ${severity}`, () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="severity-text"]').first().props().color).toEqual( + textColor + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/severity/index.tsx b/x-pack/plugins/security_solution/public/detections/components/severity/index.tsx new file mode 100644 index 0000000000000..23361e3ceed59 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/severity/index.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +const SeverityBadge = styled(EuiBadge)` + align-items: center; + display: inline-flex; + height: 40px; + text-transform: capitalize; +`; + +const getBadgeColorFromSeverity = (severity: string) => { + switch (`${severity}`.toLowerCase()) { + case 'low': + return '#C5CFD8'; + case 'medium': + return '#EFC44C'; + case 'high': + return '#FF7E62'; + case 'critical': + return '#C3505E'; + default: + return 'hollow'; + } +}; + +const getTextColorFromSeverity = (severity: string) => { + switch (`${severity}`.toLowerCase()) { + case 'critical': // fall through + case 'high': + return 'ghost'; + default: + return 'default'; + } +}; + +interface Props { + severity: string; +} + +const SeverityComponent: React.FC = ({ severity }) => ( + + + {severity} + + +); + +export const Severity = React.memo(SeverityComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/status/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/status/index.test.tsx new file mode 100644 index 0000000000000..1df652a9b09af --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/status/index.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; + +import { Status } from '.'; + +interface Expected { + badgeColor: string; + iconType: 'check' | 'alert'; + status: 'active' | 'recovered' | 'any-other-status'; +} + +describe('Status', () => { + const expected: Expected[] = [ + { + badgeColor: 'danger', + iconType: 'alert', + status: 'active', + }, + { + badgeColor: 'hollow', + iconType: 'check', + status: 'recovered', + }, + { + badgeColor: 'danger', + iconType: 'alert', + status: 'any-other-status', + }, + ]; + + expected.forEach(({ status, badgeColor, iconType }) => { + test(`it renders the expected badge color when status is ${status}`, () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="status-icon"]').first().props().color).toEqual( + badgeColor + ); + }); + + test(`it renders the expected icon type when status is ${status}`, () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="status-icon"]').first().props().type).toEqual(iconType); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/status/index.tsx b/x-pack/plugins/security_solution/public/detections/components/status/index.tsx new file mode 100644 index 0000000000000..c9ece19d1b1e2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/status/index.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiIcon } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +export const STATUS_CLASS_NAME = 'alert-status-icon'; + +const StatusContainer = styled.span` + display: inline-flex; + justify-content: center; + width: 100%; +`; + +export const getBadgeColorFromStatus = (status: string) => { + switch (`${status}`.toLowerCase()) { + case 'recovered': + return 'hollow'; + default: + return 'danger'; + } +}; + +export const getIconTypeFromStatus = (status: string) => { + switch (`${status}`.toLowerCase()) { + case 'recovered': + return 'check'; + default: + return 'alert'; + } +}; + +interface Props { + status: string; +} + +const StatusComponent: React.FC = ({ status }) => ( + + + +); + +export const Status = React.memo(StatusComponent); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts new file mode 100644 index 0000000000000..8cbb532501a2c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn } from '@elastic/eui'; + +import { defaultColumnHeaderType } from '../../../../timelines/components/timeline/body/column_headers/default_headers'; +import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../../timelines/components/timeline/body/constants'; +import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; + +import * as i18n from '../../../components/alerts_table/translations'; + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.STATUS, + id: 'kibana.rac.alert.status', + initialWidth: 74, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.TRIGGERED, + id: '@timestamp', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERT_DURATION, + id: 'kibana.rac.alert.duration.us', + initialWidth: 116, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_SEVERITY, + id: 'signal.rule.severity', + initialWidth: 102, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_REASON, + id: 'signal.reason', + initialWidth: 644, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/index.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/index.ts new file mode 100644 index 0000000000000..dfd4d9499f6e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { columns } from './columns'; +import { RenderCellValue } from './render_cell_value'; + +export { columns, RenderCellValue }; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx new file mode 100644 index 0000000000000..9c2114a4ef085 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { cloneDeep } from 'lodash/fp'; +import React from 'react'; + +import { mockBrowserFields } from '../../../../common/containers/source/mock'; +import { DragDropContextWrapper } from '../../../../common/components/drag_and_drop/drag_drop_context_wrapper'; +import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock'; +import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; +import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; + +import { RenderCellValue } from '.'; + +describe('RenderCellValue', () => { + const columnId = '@timestamp'; + const eventId = '_id-123'; + const linkValues = ['foo', 'bar', '@baz']; + const rowIndex = 5; + const timelineId = 'test'; + + let data: TimelineNonEcsData[]; + let header: ColumnHeaderOptions; + let props: CellValueElementProps; + + beforeEach(() => { + data = cloneDeep(mockTimelineData[0].data); + header = cloneDeep(defaultHeaders[0]); + props = { + columnId, + data, + eventId, + header, + isDetails: false, + isExpandable: false, + isExpanded: false, + linkValues, + rowIndex, + setCellProps: jest.fn(), + timelineId, + }; + }); + + test('it renders a custom alert status', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="alert-status"]').exists()).toBe(true); + }); + + test('it renders a custom alert duration', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="alert-duration"]').exists()).toBe(true); + }); + + test('it renders a custom rule severity', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="rule-severity"]').exists()).toBe(true); + }); + + test('it renders a custom reason', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="reason"]').exists()).toBe(true); + }); + + test('it forwards the `CellValueElementProps` to the `DefaultCellRenderer` for any other field', () => { + const aRandomFieldName = 'a.random.field.name'; + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(DefaultCellRenderer).first().props()).toEqual({ + ...props, + columnId: aRandomFieldName, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx new file mode 100644 index 0000000000000..bc8c4bd6bfe69 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridCellValueElementProps, EuiLink } from '@elastic/eui'; +import { random } from 'lodash/fp'; +import moment from 'moment'; +import React from 'react'; + +import { TruncatableText } from '../../../../common/components/truncatable_text'; +import { Severity } from '../../../components/severity'; +import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; +import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { Status } from '../../../components/status'; + +const reason = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const RenderCellValue: React.FC< + EuiDataGridCellValueElementProps & CellValueElementProps +> = ({ + columnId, + data, + eventId, + header, + isDetails, + isExpandable, + isExpanded, + linkValues, + rowIndex, + setCellProps, + timelineId, +}) => { + const value = + getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]) ?? ''; + + switch (columnId) { + case 'kibana.rac.alert.status': + return ( + + ); + case 'kibana.rac.alert.duration.us': + return {moment().fromNow(true)}; + case 'signal.rule.severity': + return ; + case 'signal.reason': + return ( + + {reason} + + ); + default: + // NOTE: we're using `DefaultCellRenderer` in this example configuration as a fallback, but + // using `DefaultCellRenderer` here is entirely optional + return ( + + ); + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts new file mode 100644 index 0000000000000..96d2d870b1270 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn } from '@elastic/eui'; + +import { defaultColumnHeaderType } from '../../../../timelines/components/timeline/body/column_headers/default_headers'; +import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../../timelines/components/timeline/body/constants'; +import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; + +import * as i18n from '../../../components/alerts_table/translations'; + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: defaultColumnHeaderType, + id: '@timestamp', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.name', + displayAsText: i18n.ALERTS_HEADERS_RULE_NAME, + linkField: 'signal.rule.id', + initialWidth: 212, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.rule.severity', + displayAsText: i18n.ALERTS_HEADERS_SEVERITY, + initialWidth: 104, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'signal.reason', + displayAsText: i18n.ALERTS_HEADERS_REASON, + initialWidth: 644, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/index.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/index.ts new file mode 100644 index 0000000000000..dfd4d9499f6e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { columns } from './columns'; +import { RenderCellValue } from './render_cell_value'; + +export { columns, RenderCellValue }; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx new file mode 100644 index 0000000000000..aa4eb543a3d9b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { cloneDeep } from 'lodash/fp'; +import React from 'react'; + +import { mockBrowserFields } from '../../../../common/containers/source/mock'; +import { DragDropContextWrapper } from '../../../../common/components/drag_and_drop/drag_drop_context_wrapper'; +import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock'; +import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; +import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { ColumnHeaderOptions } from '../../../../timelines/store/timeline/model'; + +import { RenderCellValue } from '.'; + +describe('RenderCellValue', () => { + const columnId = '@timestamp'; + const eventId = '_id-123'; + const linkValues = ['foo', 'bar', '@baz']; + const rowIndex = 5; + const timelineId = 'test'; + + let data: TimelineNonEcsData[]; + let header: ColumnHeaderOptions; + let props: CellValueElementProps; + + beforeEach(() => { + data = cloneDeep(mockTimelineData[0].data); + header = cloneDeep(defaultHeaders[0]); + props = { + columnId, + data, + eventId, + header, + isDetails: false, + isExpandable: false, + isExpanded: false, + linkValues, + rowIndex, + setCellProps: jest.fn(), + timelineId, + }; + }); + + test('it renders a custom severity', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="custom-severity"]').exists()).toBe(true); + }); + + test('it renders a custom reason', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="custom-reason"]').exists()).toBe(true); + }); + + test('it forwards the `CellValueElementProps` to the `DefaultCellRenderer` for any other field', () => { + const aRandomFieldName = 'a.random.field.name'; + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(DefaultCellRenderer).first().props()).toEqual({ + ...props, + columnId: aRandomFieldName, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx new file mode 100644 index 0000000000000..097cb54a7b0ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import React from 'react'; + +import { DefaultDraggable } from '../../../../common/components/draggables'; +import { TruncatableText } from '../../../../common/components/truncatable_text'; +import { Severity } from '../../../components/severity'; +import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns'; +import { CellValueElementProps } from '../../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; + +const reason = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const RenderCellValue: React.FC< + EuiDataGridCellValueElementProps & CellValueElementProps +> = ({ + columnId, + data, + eventId, + header, + isDetails, + isExpandable, + isExpanded, + linkValues, + rowIndex, + setCellProps, + timelineId, +}) => { + const value = + getMappedNonEcsValue({ + data, + fieldName: columnId, + })?.reduce((x) => x[0]) ?? ''; + const draggableId = `${timelineId}-${eventId}-${columnId}-${value}`; + + switch (columnId) { + case 'signal.rule.severity': + return ( + + + + ); + case 'signal.reason': + return {reason}; + default: + return ( + + ); + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts new file mode 100644 index 0000000000000..23a0740294e84 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridColumn } from '@elastic/eui'; + +import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + DEFAULT_DATE_COLUMN_MIN_WIDTH, +} from '../../../timelines/components/timeline/body/constants'; +import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; + +import * as i18n from '../../components/alerts_table/translations'; + +/** + * columns implements a subset of `EuiDataGrid`'s `EuiDataGridColumn` interface, + * plus additional TGrid column properties + */ +export const columns: Array< + Pick & ColumnHeaderOptions +> = [ + { + columnHeaderType: defaultColumnHeaderType, + id: '@timestamp', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH + 5, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_RULE, + id: 'signal.rule.name', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + linkField: 'signal.rule.id', + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_VERSION, + id: 'signal.rule.version', + initialWidth: 95, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_METHOD, + id: 'signal.rule.type', + initialWidth: 100, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_SEVERITY, + id: 'signal.rule.severity', + initialWidth: 105, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_RISK_SCORE, + id: 'signal.rule.risk_score', + initialWidth: 115, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'event.module', + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + linkField: 'rule.reference', + }, + { + aggregatable: true, + category: 'event', + columnHeaderType: defaultColumnHeaderType, + id: 'event.action', + initialWidth: 140, + type: 'string', + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'event.category', + initialWidth: 150, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'host.name', + initialWidth: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'user.name', + initialWidth: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'source.ip', + initialWidth: 120, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'destination.ip', + initialWidth: 140, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/index.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/index.ts new file mode 100644 index 0000000000000..dfd4d9499f6e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { columns } from './columns'; +import { RenderCellValue } from './render_cell_value'; + +export { columns, RenderCellValue }; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx new file mode 100644 index 0000000000000..18350c102c049 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { cloneDeep } from 'lodash/fp'; +import React from 'react'; + +import { mockBrowserFields } from '../../../common/containers/source/mock'; +import { DragDropContextWrapper } from '../../../common/components/drag_and_drop/drag_drop_context_wrapper'; +import { defaultHeaders, mockTimelineData, TestProviders } from '../../../common/mock'; +import { TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; +import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; + +import { RenderCellValue } from '.'; + +describe('RenderCellValue', () => { + const columnId = '@timestamp'; + const eventId = '_id-123'; + const linkValues = ['foo', 'bar', '@baz']; + const rowIndex = 5; + const timelineId = 'test'; + + let data: TimelineNonEcsData[]; + let header: ColumnHeaderOptions; + let props: CellValueElementProps; + + beforeEach(() => { + data = cloneDeep(mockTimelineData[0].data); + header = cloneDeep(defaultHeaders[0]); + props = { + columnId, + data, + eventId, + header, + isDetails: false, + isExpandable: false, + isExpanded: false, + linkValues, + rowIndex, + setCellProps: jest.fn(), + timelineId, + }; + }); + + test('it forwards the `CellValueElementProps` to the `DefaultCellRenderer`', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(DefaultCellRenderer).first().props()).toEqual(props); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx new file mode 100644 index 0000000000000..e9bfdefa433c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import React from 'react'; + +import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; +import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; + +/** + * This implementation of `EuiDataGrid`'s `renderCellValue` + * accepts `EuiDataGridCellValueElementProps`, plus `data` + * from the TGrid + */ +export const RenderCellValue: React.FC< + EuiDataGridCellValueElementProps & CellValueElementProps +> = ({ + columnId, + data, + eventId, + header, + isDetails, + isExpandable, + isExpanded, + linkValues, + rowIndex, + setCellProps, + timelineId, +}) => ( + +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 9dc433df0a226..6c9f1fd16a704 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -12,6 +12,7 @@ import { DETECTION_ENGINE_INDEX_URL, DETECTION_ENGINE_PRIVILEGES_URL, } from '../../../../../common/constants'; +import { HOST_ISOLATION_CREATE_API } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; import { BasicSignals, @@ -20,6 +21,7 @@ import { AlertSearchResponse, AlertsIndex, UpdateAlertStatusProps, + HostIsolationResponse, } from './types'; /** @@ -101,3 +103,26 @@ export const createSignalIndex = async ({ signal }: BasicSignals): Promise => + KibanaServices.get().http.fetch(HOST_ISOLATION_CREATE_API, { + method: 'POST', + body: JSON.stringify({ + agent_ids: [agentId], + comment, + }), + }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts index 01e18324fe6b0..a4cc80debc1ba 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts @@ -34,3 +34,8 @@ export const SIGNAL_POST_FAILURE = i18n.translate( defaultMessage: 'Failed to create signal index', } ); + +export const HOST_ISOLATION_FAILURE = i18n.translate( + 'xpack.securitySolution.endpoint.hostIsolation.failedToIsolate.title', + { defaultMessage: 'Failed to isolate host' } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts index 26108ca939a57..4aad6da29dfe8 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts @@ -48,6 +48,10 @@ export interface AlertsIndex { index_mapping_outdated: boolean; } +export interface HostIsolationResponse { + action: string; +} + export interface Privilege { username: string; has_all_requested: boolean; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx new file mode 100644 index 0000000000000..684bc6af5d2c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useState } from 'react'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { HOST_ISOLATION_FAILURE } from './translations'; +import { createHostIsolation } from './api'; + +interface HostIsolationStatus { + loading: boolean; + isolateHost: () => Promise; +} + +interface UseHostIsolationProps { + agentId: string; + comment: string; +} + +export const useHostIsolation = ({ + agentId, + comment, +}: UseHostIsolationProps): HostIsolationStatus => { + const [loading, setLoading] = useState(false); + const { addError } = useAppToasts(); + + const isolateHost = useCallback(async () => { + try { + setLoading(true); + const isolationStatus = await createHostIsolation({ agentId, comment }); + setLoading(false); + return isolationStatus.action ? true : false; + } catch (error) { + setLoading(false); + addError(error.message, { title: HOST_ISOLATION_FAILURE }); + return false; + } + }, [agentId, comment, addError]); + return { loading, isolateHost }; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx index f55af79a5dbee..07911541bb2fe 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx @@ -34,7 +34,7 @@ const columnHeaders: ColumnHeaderOptions[] = [ id: '@timestamp', type: 'date', aggregatable: true, - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, ]; @@ -207,7 +207,7 @@ describe('field_items', () => { expect(toggleColumn).toBeCalledWith({ columnHeaderType: 'not-filtered', id: '@timestamp', - width: 180, + initialWidth: 180, }); }); @@ -266,7 +266,7 @@ describe('field_items', () => { expect(toggleColumn).toBeCalledWith({ columnHeaderType: 'not-filtered', id: 'signal.rule.name', - width: 180, + initialWidth: 180, }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx index e50f5a6e39ee3..a2db284e51790 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx @@ -228,7 +228,7 @@ export const getFieldItems = ({ toggleColumn({ columnHeaderType: defaultColumnHeaderType, id: field.name ?? '', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, ...getAlertColumnHeader(timelineId, field.name ?? ''), }) } diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx index 576ce6239645f..4d06632d6441d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/helpers.tsx @@ -16,7 +16,7 @@ import { } from '../../../common/components/accessibility/helpers'; import { TimelineId } from '../../../../common/types/timeline'; import { BrowserField, BrowserFields } from '../../../common/containers/source'; -import { alertsHeaders } from '../../../detections/components/alerts_table/default_config'; +import { alertsHeaders } from '../../../common/components/alerts_viewer/default_headers'; import { DEFAULT_CATEGORY_NAME, defaultHeaders, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 1222f168b2ae9..c06c3f076e097 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -252,42 +252,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -363,42 +363,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -474,42 +474,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -583,42 +583,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -698,7 +698,7 @@ describe('helpers', () => { id: '@timestamp', placeholder: undefined, type: 'number', - width: 190, + initialWidth: 190, }, { aggregatable: undefined, @@ -709,7 +709,7 @@ describe('helpers', () => { id: 'message', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -720,7 +720,7 @@ describe('helpers', () => { id: 'event.category', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -731,7 +731,7 @@ describe('helpers', () => { id: 'host.name', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -742,7 +742,7 @@ describe('helpers', () => { id: 'source.ip', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -753,7 +753,7 @@ describe('helpers', () => { id: 'destination.ip', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, { aggregatable: undefined, @@ -764,7 +764,7 @@ describe('helpers', () => { id: 'user.name', placeholder: undefined, type: undefined, - width: 180, + initialWidth: 180, }, ], version: '1', @@ -870,37 +870,37 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], version: '1', @@ -1018,42 +1018,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], @@ -1129,42 +1129,42 @@ describe('helpers', () => { columnHeaderType: 'not-filtered', id: '@timestamp', type: 'number', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [], diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 8c4eb2112640f..e45a1a117769b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -113,7 +113,8 @@ const setTimelineColumn = (col: ColumnHeaderResult) => { columnHeaderType: defaultColumnHeaderType, id: col.id != null ? col.id : 'unknown', - width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: + col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 9a4684193b997..de4795869f4b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -5,16 +5,30 @@ * 2.0. */ -import { some } from 'lodash/fp'; -import { EuiFlyoutHeader, EuiFlyoutBody, EuiSpacer } from '@elastic/eui'; -import React from 'react'; +import { find, some } from 'lodash/fp'; +import { + EuiButtonEmpty, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiText, +} from '@elastic/eui'; +import React, { useState, useCallback, useMemo } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; - import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; import { ExpandableEvent, ExpandableEventTitle } from './expandable_event'; import { useTimelineEventsDetails } from '../../../containers/details'; import { TimelineTabs } from '../../../../../common/types/timeline'; +import { HostIsolationPanel } from '../../../../detections/components/host_isolation'; +import { TakeActionDropdown } from '../../../../detections/components/host_isolation/take_action_dropdown'; +import { ISOLATE_HOST } from '../../../../detections/components/host_isolation/translations'; +import { ALERT_DETAILS } from './translations'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflow { @@ -56,8 +70,47 @@ const EventDetailsPanelComponent: React.FC = ({ skip: !expandedEvent.eventId, }); + const isHostIsolationEnabled = useIsExperimentalFeatureEnabled('hostIsolationEnabled'); + + const [isHostIsolationPanelOpen, setIsHostIsolationPanel] = useState(false); + + const showAlertDetails = useCallback(() => { + setIsHostIsolationPanel(false); + }, []); + + const showHostIsolationPanel = useCallback((action) => { + if (action === 'isolateHost') { + setIsHostIsolationPanel(true); + } + }, []); + const isAlert = some({ category: 'signal', field: 'signal.rule.id' }, detailsData); + const isEndpointAlert = useMemo(() => { + const findEndpointAlert = find({ category: 'agent', field: 'agent.type' }, detailsData)?.values; + return findEndpointAlert ? findEndpointAlert[0] === 'endpoint' : false; + }, [detailsData]); + + const backToAlertDetailsLink = useMemo(() => { + return ( + <> + showAlertDetails()} + > + +

{ALERT_DETAILS}

+
+
+ +

{ISOLATE_HOST}

+
+ + ); + }, [showAlertDetails]); + if (!expandedEvent?.eventId) { return null; } @@ -65,19 +118,38 @@ const EventDetailsPanelComponent: React.FC = ({ return isFlyoutView ? ( <> - + {isHostIsolationPanelOpen ? ( + backToAlertDetailsLink + ) : ( + + )} - + {isHostIsolationPanelOpen ? ( + + ) : ( + + )} + {isHostIsolationEnabled && isEndpointAlert && isHostIsolationPanelOpen === false && ( + + + + + + + + + + )} ) : ( <> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 9fe8b954657a3..6a56d1b16238a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -465,43 +465,43 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "@timestamp", + "initialWidth": 190, "type": "number", - "width": 190, }, Object { "columnHeaderType": "not-filtered", "id": "message", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "event.category", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "event.action", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "host.name", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "source.ip", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "destination.ip", - "width": 180, + "initialWidth": 180, }, Object { "columnHeaderType": "not-filtered", "id": "user.name", - "width": 180, + "initialWidth": 180, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx index 7d203fab9e88f..3ab4d564391f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx @@ -14,6 +14,7 @@ import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { useDraggableKeyboardWrapper } from '../../../../../common/components/drag_and_drop/draggable_keyboard_wrapper_hook'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../constants'; import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, getDraggableFieldId, @@ -76,10 +77,10 @@ const ColumnHeaderComponent: React.FC = ({ const dispatch = useDispatch(); const resizableSize = useMemo( () => ({ - width: header.width, + width: header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH, height: 'auto', }), - [header.width] + [header.initialWidth] ); const resizableStyle: { position: 'absolute' | 'relative'; @@ -220,7 +221,7 @@ const ColumnHeaderComponent: React.FC = ({ ref={dragProvided.innerRef} > - + = ({ ), - [handleClosePopOverTrigger, headerButton, header.width, hoverActionsOwnFocus, panels] + [handleClosePopOverTrigger, headerButton, header.initialWidth, hoverActionsOwnFocus, panels] ); const onFocus = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts index c4f49b240b6e6..fea65d0499a13 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/default_headers.ts @@ -15,42 +15,42 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ columnHeaderType: defaultColumnHeaderType, id: '@timestamp', type: 'number', - width: DEFAULT_DATE_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'message', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.category', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.action', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'source.ip', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'destination.ip', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'user.name', - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx index 64b3598fa5d89..bdf4cc42fa794 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/filter/index.tsx @@ -8,6 +8,7 @@ import { noop } from 'lodash/fp'; import React from 'react'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../../constants'; import { OnFilterChange } from '../../../events'; import { ColumnHeaderOptions } from '../../../../../../timelines/store/timeline/model'; import { TextFilter } from '../text_filter'; @@ -24,7 +25,7 @@ export const Filter = React.memo(({ header, onFilterChange = noop }) => { return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap index 4cd2193f148a3..50da19c3d48f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap @@ -7,8 +7,8 @@ exports[`Header renders correctly against snapshot 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "@timestamp", + "initialWidth": 190, "type": "number", - "width": 190, } } isLoading={false} @@ -30,8 +30,8 @@ exports[`Header renders correctly against snapshot 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "@timestamp", + "initialWidth": 190, "type": "number", - "width": 190, } } isLoading={false} @@ -52,8 +52,8 @@ exports[`Header renders correctly against snapshot 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "@timestamp", + "initialWidth": 190, "type": "number", - "width": 190, } } onFilterChange={[Function]} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx index 393594c69bb81..484cb78417c2f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx @@ -46,7 +46,11 @@ const HeaderContentComponent: React.FC = ({ data-test-subj="header-tooltip" content={} > - <>{header.label ?? header.id} + <> + {React.isValidElement(header.display) + ? header.display + : header.displayAsText ?? header.id} + @@ -63,7 +67,11 @@ const HeaderContentComponent: React.FC = ({ data-test-subj="header-tooltip" content={} > - <>{header.label ?? header.id} + <> + {React.isValidElement(header.display) + ? header.display + : header.displayAsText ?? header.id} + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx index b0198e60f3b9a..f2496484c25ea 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx @@ -65,9 +65,9 @@ describe('Header', () => { ).toEqual(columnHeader.id); }); - test('it renders the header text alias when label is provided', () => { - const label = 'Timestamp'; - const headerWithLabel = { ...columnHeader, label }; + test('it renders the header text alias when displayAsText is provided', () => { + const displayAsText = 'Timestamp'; + const headerWithLabel = { ...columnHeader, displayAsText }; const wrapper = mount( @@ -76,7 +76,52 @@ describe('Header', () => { expect( wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() - ).toEqual(label); + ).toEqual(displayAsText); + }); + + test('it renders the header as a `ReactNode` when `display` is provided', () => { + const display: React.ReactNode = ( +
+ {'The display property renders the column heading as a ReactNode'} +
+ ); + const headerWithLabel = { ...columnHeader, display }; + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="rendered-via-display"]`).exists()).toBe(true); + }); + + test('it prefers to render `display` instead of `displayAsText` when both are provided', () => { + const displayAsText = 'this text should NOT be rendered'; + const display: React.ReactNode = ( +
{'this text is rendered via display'}
+ ); + const headerWithLabel = { ...columnHeader, display, displayAsText }; + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toBe('this text is rendered via display'); + }); + + test('it falls back to rendering header.id when `display` is not a valid React node', () => { + const display = {}; // a plain object is NOT a `ReactNode` + const headerWithLabel = { ...columnHeader, display }; + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`).first().text() + ).toEqual(columnHeader.id); }); test('it renders a sort indicator', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts index 1260f59be3621..2fcfed6489eb2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts @@ -67,7 +67,7 @@ describe('helpers', () => { name: '@timestamp', searchable: true, type: 'date', - width: 190, + initialWidth: 190, }, { aggregatable: true, @@ -81,7 +81,7 @@ describe('helpers', () => { name: 'source.ip', searchable: true, type: 'ip', - width: 180, + initialWidth: 180, }, { aggregatable: true, @@ -96,7 +96,7 @@ describe('helpers', () => { name: 'destination.ip', searchable: true, type: 'ip', - width: 180, + initialWidth: 180, }, ]; const mockHeader = defaultHeaders.filter((h) => diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index 99fb6c3dd8907..efb076337864b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -170,7 +170,7 @@ export const ColumnHeadersComponent = ({ > - + @@ -218,10 +218,10 @@ export const ColumnHeadersComponent = ({ const myColumns = useMemo( () => - columnHeaders.map(({ aggregatable, label, id, type }) => ({ + columnHeaders.map(({ aggregatable, displayAsText, id, type }) => ({ id, isSortable: aggregatable, - displayAsText: label, + displayAsText, schema: type, })), [columnHeaders] @@ -254,7 +254,7 @@ export const ColumnHeadersComponent = ({ [onSortColumns, sort] ); const displayValues = useMemo( - () => columnHeaders.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.label ?? ch.id }), {}), + () => columnHeaders.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.displayAsText ?? ch.id }), {}), [columnHeaders] ); const ColumnSorting = useDataGridColumnSorting(myColumns, sortedColumns, {}, [], displayValues); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts index e33efe75e6895..445211229574b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts @@ -21,5 +21,9 @@ export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH /** The default minimum width of a column (when a width for the column type is not specified) */ export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px + +/** The minimum width of a resized column */ +export const RESIZED_COLUMN_MIN_WITH = 70; // px + /** The default minimum width of a column of type `date` */ export const DEFAULT_DATE_COLUMN_MIN_WIDTH = 190; // px diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap index 91d039a19495c..9cba2f98428a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/__snapshots__/index.test.tsx.snap @@ -87,7 +87,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "message", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -179,7 +179,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "event.category", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -271,7 +271,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "event.action", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -363,7 +363,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "host.name", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -455,7 +455,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "source.ip", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -547,7 +547,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "destination.ip", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} @@ -639,7 +639,7 @@ exports[`Columns it renders the expected columns 1`] = ` Object { "columnHeaderType": "not-filtered", "id": "user.name", - "width": 180, + "initialWidth": 180, } } linkValues={Array []} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx index aeb9af46ea2ec..e5012ec3522b0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.tsx @@ -98,7 +98,7 @@ export const DataDrivenColumns = React.memo( onKeyDown={onKeyDown} role="button" tabIndex={0} - width={header.width} + width={header.initialWidth} > <> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 59c0610c544e9..4d5f773b73e1d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -12,6 +12,7 @@ import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; import { CellValueElementProps } from '../cell_rendering'; +import { DEFAULT_COLUMN_MIN_WIDTH } from './constants'; import { RowRendererId, TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; import { FIRST_ARIA_INDEX, @@ -162,7 +163,10 @@ export const BodyComponent = React.memo( const columnWidths = useMemo( () => - columnHeaders.reduce((totalWidth, header) => totalWidth + header.width, actionsColumnWidth), + columnHeaders.reduce( + (totalWidth, header) => totalWidth + (header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH), + actionsColumnWidth + ), [actionsColumnWidth, columnHeaders] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index bc9c774b40413..d104dc3a85f72 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -28,6 +28,13 @@ export const COPY_TO_CLIPBOARD = i18n.translate( } ); +export const INVESTIGATE = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.investigateLabel', + { + defaultMessage: 'Investigate', + } +); + export const UNPINNED = i18n.translate( 'xpack.securitySolution.timeline.body.pinning.unpinnedTooltip', { @@ -74,6 +81,13 @@ export const VIEW_DETAILS = i18n.translate( } ); +export const VIEW_SUMMARY = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.viewSummaryLabel', + { + defaultMessage: 'View summary', + } +); + export const VIEW_DETAILS_FOR_ROW = ({ ariaRowindex, columnValues, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/__snapshots__/index.test.tsx.snap index 9ec1fa7071277..78f19e390ae28 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/__snapshots__/index.test.tsx.snap @@ -14,8 +14,8 @@ For log events this is the date/time when the event was generated, and not when Required field for all events.", "example": "2016-05-23T08:05:34.853Z", "id": "@timestamp", + "initialWidth": 190, "type": "date", - "width": 190, }, Object { "aggregatable": true, @@ -24,8 +24,8 @@ Required field for all events.", "description": "Severity describes the severity of the event. What the different severity values mean can very different between use cases. It's up to the implementer to make sure severities are consistent across events.", "example": "7", "id": "event.severity", + "initialWidth": 180, "type": "long", - "width": 180, }, Object { "aggregatable": true, @@ -35,8 +35,8 @@ Required field for all events.", This contains high-level information about the contents of the event. It is more generic than \`event.action\`, in the sense that typically a category contains multiple actions. Warning: In future versions of ECS, we plan to provide a list of acceptable values for this field, please use with caution.", "example": "user-management", "id": "event.category", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -46,8 +46,8 @@ This contains high-level information about the contents of the event. It is more This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", "example": "user-password-change", "id": "event.action", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -57,8 +57,8 @@ This describes the information in the event. It is more specific than \`event.ca It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", "example": "", "id": "host.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -68,8 +68,8 @@ It can contain what \`hostname\` returns on Unix systems, the fully qualified do Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "source.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -79,8 +79,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "destination.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -90,8 +90,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "example": "123", "format": "bytes", "id": "destination.bytes", + "initialWidth": 180, "type": "number", - "width": 180, }, Object { "aggregatable": true, @@ -100,8 +100,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Short name or login of the user.", "example": "albert", "id": "user.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -110,8 +110,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Each document has an _id that uniquely identifies it", "example": "Y-6TfmcB0WOhS6qyMv3s", "id": "_id", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": false, @@ -121,8 +121,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", In other use cases the message field can be used to concatenate different values which are then freely searchable. If multiple messages exist, they can be combined into one message.", "example": "Hello World", "id": "message", + "initialWidth": 180, "type": "text", - "width": 180, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap index ce59d191a472d..2ccf562c9ca6f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/__snapshots__/index.test.tsx.snap @@ -13,8 +13,8 @@ For log events this is the date/time when the event was generated, and not when Required field for all events.", "example": "2016-05-23T08:05:34.853Z", "id": "@timestamp", + "initialWidth": 190, "type": "date", - "width": 190, }, Object { "aggregatable": true, @@ -23,8 +23,8 @@ Required field for all events.", "description": "Severity describes the severity of the event. What the different severity values mean can very different between use cases. It's up to the implementer to make sure severities are consistent across events.", "example": "7", "id": "event.severity", + "initialWidth": 180, "type": "long", - "width": 180, }, Object { "aggregatable": true, @@ -34,8 +34,8 @@ Required field for all events.", This contains high-level information about the contents of the event. It is more generic than \`event.action\`, in the sense that typically a category contains multiple actions. Warning: In future versions of ECS, we plan to provide a list of acceptable values for this field, please use with caution.", "example": "user-management", "id": "event.category", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -45,8 +45,8 @@ This contains high-level information about the contents of the event. It is more This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", "example": "user-password-change", "id": "event.action", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -56,8 +56,8 @@ This describes the information in the event. It is more specific than \`event.ca It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", "example": "", "id": "host.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -67,8 +67,8 @@ It can contain what \`hostname\` returns on Unix systems, the fully qualified do Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "source.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -78,8 +78,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "destination.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -89,8 +89,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "example": "123", "format": "bytes", "id": "destination.bytes", + "initialWidth": 180, "type": "number", - "width": 180, }, Object { "aggregatable": true, @@ -99,8 +99,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Short name or login of the user.", "example": "albert", "id": "user.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -109,8 +109,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Each document has an _id that uniquely identifies it", "example": "Y-6TfmcB0WOhS6qyMv3s", "id": "_id", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": false, @@ -120,8 +120,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", In other use cases the message field can be used to concatenate different values which are then freely searchable. If multiple messages exist, they can be combined into one message.", "example": "Hello World", "id": "message", + "initialWidth": 180, "type": "text", - "width": 180, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index f6ff6b50221b7..5f529ba827c45 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -14,8 +14,8 @@ For log events this is the date/time when the event was generated, and not when Required field for all events.", "example": "2016-05-23T08:05:34.853Z", "id": "@timestamp", + "initialWidth": 190, "type": "date", - "width": 190, }, Object { "aggregatable": true, @@ -24,8 +24,8 @@ Required field for all events.", "description": "Severity describes the severity of the event. What the different severity values mean can very different between use cases. It's up to the implementer to make sure severities are consistent across events.", "example": "7", "id": "event.severity", + "initialWidth": 180, "type": "long", - "width": 180, }, Object { "aggregatable": true, @@ -35,8 +35,8 @@ Required field for all events.", This contains high-level information about the contents of the event. It is more generic than \`event.action\`, in the sense that typically a category contains multiple actions. Warning: In future versions of ECS, we plan to provide a list of acceptable values for this field, please use with caution.", "example": "user-management", "id": "event.category", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -46,8 +46,8 @@ This contains high-level information about the contents of the event. It is more This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer.", "example": "user-password-change", "id": "event.action", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -57,8 +57,8 @@ This describes the information in the event. It is more specific than \`event.ca It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.", "example": "", "id": "host.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -68,8 +68,8 @@ It can contain what \`hostname\` returns on Unix systems, the fully qualified do Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "source.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -79,8 +79,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", Can be one or multiple IPv4 or IPv6 addresses.", "example": "", "id": "destination.ip", + "initialWidth": 180, "type": "ip", - "width": 180, }, Object { "aggregatable": true, @@ -90,8 +90,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "example": "123", "format": "bytes", "id": "destination.bytes", + "initialWidth": 180, "type": "number", - "width": 180, }, Object { "aggregatable": true, @@ -100,8 +100,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Short name or login of the user.", "example": "albert", "id": "user.name", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": true, @@ -110,8 +110,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", "description": "Each document has an _id that uniquely identifies it", "example": "Y-6TfmcB0WOhS6qyMv3s", "id": "_id", + "initialWidth": 180, "type": "keyword", - "width": 180, }, Object { "aggregatable": false, @@ -121,8 +121,8 @@ Can be one or multiple IPv4 or IPv6 addresses.", In other use cases the message field can be used to concatenate different values which are then freely searchable. If multiple messages exist, they can be combined into one message.", "example": "Hello World", "id": "message", + "initialWidth": 180, "type": "text", - "width": 180, }, ] } diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts index 2846d95051b17..f1b5f6a944678 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts @@ -5,8 +5,11 @@ * 2.0. */ +import { cloneDeep } from 'lodash/fp'; import { LOCAL_STORAGE_TIMELINE_KEY, + migrateColumnWidthToInitialWidth, + migrateColumnLabelToDisplayAsText, useTimelinesStorage, getTimelinesInStorageByIds, getAllTimelinesInStorage, @@ -16,11 +19,15 @@ import { import { TimelineId } from '../../../../common/types/timeline'; import { mockTimelineModel, createSecuritySolutionStorageMock } from '../../../common/mock'; import { useKibana } from '../../../common/lib/kibana'; +import { TimelineModel } from '../../store/timeline/model'; jest.mock('../../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; +const getExpectedColumns = (model: TimelineModel) => + model.columns.map(migrateColumnWidthToInitialWidth).map(migrateColumnLabelToDisplayAsText); + describe('SiemLocalStorage', () => { const { localStorage, storage } = createSecuritySolutionStorageMock(); @@ -122,6 +129,179 @@ describe('SiemLocalStorage', () => { [TimelineId.hostsPageEvents]: mockTimelineModel, }); }); + + it('migrates columns saved to localstorage with a `width` to `initialWidth`', () => { + const timelineStorage = useTimelinesStorage(); + + // create a mock that mimics a column saved to localstoarge in the "old" format, with `width` instead of `initialWidth` + const unmigratedMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: mockTimelineModel.columns.map((c) => ({ + ...c, + width: 98765, // create a legacy `width` column + initialWidth: undefined, // `initialWidth` must be undefined, otherwise the migration will not occur + })), + }; + timelineStorage.addTimeline(TimelineId.hostsPageEvents, unmigratedMockTimelineModel); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + // all legacy `width` values are migrated to `initialWidth`: + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: mockTimelineModel.columns.map((c) => ({ + ...c, + displayAsText: undefined, + initialWidth: 98765, + width: 98765, + })), + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); + + it('does NOT migrate columns saved to localstorage with a `width` to `initialWidth` when `initialWidth` is valid', () => { + const timelineStorage = useTimelinesStorage(); + + // create a mock that mimics a column saved to localstoarge in the "old" format, with `width` instead of `initialWidth` + const unmigratedMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: mockTimelineModel.columns.map((c) => ({ + ...c, + width: 98765, // create a legacy `width` column + })), + }; + timelineStorage.addTimeline(TimelineId.hostsPageEvents, unmigratedMockTimelineModel); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: mockTimelineModel.columns.map((c) => ({ + ...c, + displayAsText: undefined, + initialWidth: c.initialWidth, // initialWidth is unchanged + width: 98765, + })), + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); + + it('migrates columns saved to localstorage with a `label` to `displayAsText`', () => { + const timelineStorage = useTimelinesStorage(); + + // create a mock that mimics a column saved to localstoarge in the "old" format, with `label` instead of `displayAsText` + const unmigratedMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: mockTimelineModel.columns.map((c, i) => ({ + ...c, + label: `A legacy label ${i}`, // create a legacy `label` column + })), + }; + timelineStorage.addTimeline(TimelineId.hostsPageEvents, unmigratedMockTimelineModel); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + // all legacy `label` values are migrated to `displayAsText`: + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: mockTimelineModel.columns.map((c, i) => ({ + ...c, + displayAsText: `A legacy label ${i}`, + label: `A legacy label ${i}`, + })), + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); + + it('does NOT migrate columns saved to localstorage with a `label` to `displayAsText` when `displayAsText` is valid', () => { + const timelineStorage = useTimelinesStorage(); + + // create a mock that mimics a column saved to localstoarge in the "old" format, with `label` instead of `displayAsText` + const unmigratedMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: mockTimelineModel.columns.map((c, i) => ({ + ...c, + displayAsText: + 'Label will NOT be migrated to displayAsText, because displayAsText already has a value', + label: `A legacy label ${i}`, // create a legacy `label` column + })), + }; + timelineStorage.addTimeline(TimelineId.hostsPageEvents, unmigratedMockTimelineModel); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: mockTimelineModel.columns.map((c, i) => ({ + ...c, + displayAsText: + 'Label will NOT be migrated to displayAsText, because displayAsText already has a value', + label: `A legacy label ${i}`, + })), + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); + + it('does NOT migrate `columns` when `columns` is not an array', () => { + const timelineStorage = useTimelinesStorage(); + + const invalidColumnsMockTimelineModel = { + ...cloneDeep(mockTimelineModel), + columns: 'this is NOT an array', + }; + timelineStorage.addTimeline( + TimelineId.hostsPageEvents, + (invalidColumnsMockTimelineModel as unknown) as TimelineModel + ); + timelineStorage.addTimeline(TimelineId.hostsPageExternalAlerts, mockTimelineModel); + const timelines = getTimelinesInStorageByIds(storage, [ + TimelineId.hostsPageEvents, + TimelineId.hostsPageExternalAlerts, + ]); + + expect(timelines).toStrictEqual({ + [TimelineId.hostsPageEvents]: { + ...mockTimelineModel, + columns: 'this is NOT an array', + }, + [TimelineId.hostsPageExternalAlerts]: { + ...mockTimelineModel, + columns: getExpectedColumns(mockTimelineModel), + }, + }); + }); }); describe('getAllTimelinesInStorage', () => { @@ -159,4 +339,103 @@ describe('SiemLocalStorage', () => { }); }); }); + + describe('migrateColumnWidthToInitialWidth', () => { + it('migrates the `width` property to `initialWidth` for older columns saved to localstorage', () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), + width: 1234, // the column `width` was saved to localstorage before the `initialWidth` property existed + initialWidth: undefined, // `initialWidth` did not exist when this column was saved to localstorage + }; + + expect(migrateColumnWidthToInitialWidth(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 1234, // migrated from `width` + width: 1234, + }); + }); + + it("leaves `initialWidth` unchanged when the column read from localstorage doesn't have a `width`", () => { + const column = cloneDeep(mockTimelineModel.columns[0]); // `column.width` does not exist + + expect(migrateColumnWidthToInitialWidth(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 190, // unchanged, because there is no `width` to migrate + }); + }); + + it('does NOT migrate the `width` property to `initialWidth` when the column read from localstorage already has a valid `initialWidth`', () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), // `column.initialWidth` already exists + width: 1234, + }; + + expect(migrateColumnWidthToInitialWidth(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 190, // unchanged, because the column read from localstorge already has a valid `initialWidth` + width: 1234, + }); + }); + }); + + describe('migrateColumnLabelToDisplayAsText', () => { + it('migrates the `label` property to `displayAsText` for older columns saved to localstorage', () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), + label: 'A legacy label', // the column `label` was saved to localstorage before the `displayAsText` property existed + }; + + expect(migrateColumnLabelToDisplayAsText(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + displayAsText: 'A legacy label', // migrated from `label` + id: '@timestamp', + initialWidth: 190, + label: 'A legacy label', + }); + }); + + it("leaves `displayAsText` undefined when the column read from localstorage doesn't have a `label`", () => { + const column = cloneDeep(mockTimelineModel.columns[0]); // `column.label` does not exist + + expect(migrateColumnLabelToDisplayAsText(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + displayAsText: undefined, // undefined, because there is no `label` to migrate + id: '@timestamp', + initialWidth: 190, + }); + }); + + it("leaves `displayAsText` unchanged when the column read from localstorage doesn't have a `label`", () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), + displayAsText: 'Do NOT update this', + }; + + expect(migrateColumnLabelToDisplayAsText(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + displayAsText: 'Do NOT update this', // unchanged, because there is no `label` to migrate + id: '@timestamp', + initialWidth: 190, + }); + }); + + it('does NOT migrate the `label` property to `displayAsText` when the column read from localstorage already has a valid `displayAsText`', () => { + const column = { + ...cloneDeep(mockTimelineModel.columns[0]), + displayAsText: 'Already valid', + label: 'A legacy label', + }; + + expect(migrateColumnLabelToDisplayAsText(column)).toStrictEqual({ + columnHeaderType: 'not-filtered', + displayAsText: 'Already valid', // unchanged, because the column read from localstorge already has a valid `displayAsText` + label: 'A legacy label', + id: '@timestamp', + initialWidth: 190, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx index 19ccc0bc6ef85..38eb6d3d222f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx @@ -5,10 +5,11 @@ * 2.0. */ +import { isEmpty } from 'lodash/fp'; import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { TimelinesStorage } from './types'; import { useKibana } from '../../../common/lib/kibana'; -import { TimelineModel } from '../../store/timeline/model'; +import { ColumnHeaderOptions, TimelineModel } from '../../store/timeline/model'; import { TimelineIdLiteral } from '../../../../common/types/timeline'; export const LOCAL_STORAGE_TIMELINE_KEY = 'timelines'; @@ -16,6 +17,32 @@ const EMPTY_TIMELINE = {} as { [K in TimelineIdLiteral]: TimelineModel; }; +/** + * Migrates the value of the column's `width` property to `initialWidth` + * when `width` is valid, and `initialWidth` is invalid + */ +export const migrateColumnWidthToInitialWidth = ( + column: ColumnHeaderOptions & { width?: number } +) => ({ + ...column, + initialWidth: + Number.isInteger(column.width) && !Number.isInteger(column.initialWidth) + ? column.width + : column.initialWidth, +}); + +/** + * Migrates the value of the column's `label` property to `displayAsText` + * when `label` is valid, and `displayAsText` is `undefined` + */ +export const migrateColumnLabelToDisplayAsText = ( + column: ColumnHeaderOptions & { label?: string } +) => ({ + ...column, + displayAsText: + !isEmpty(column.label) && column.displayAsText == null ? column.label : column.displayAsText, +}); + export const getTimelinesInStorageByIds = (storage: Storage, timelineIds: TimelineIdLiteral[]) => { const allTimelines = storage.get(LOCAL_STORAGE_TIMELINE_KEY); @@ -38,6 +65,13 @@ export const getTimelinesInStorageByIds = (storage: Storage, timelineIds: Timeli ...(timelineModel.sort != null && !Array.isArray(timelineModel.sort) ? { sort: [timelineModel.sort] } : {}), + ...(Array.isArray(timelineModel.columns) + ? { + columns: timelineModel.columns + .map(migrateColumnWidthToInitialWidth) + .map(migrateColumnLabelToDisplayAsText), + } + : {}), }, }; }, {} as { [K in TimelineIdLiteral]: TimelineModel }); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts index c8e8e00caf530..eabcdd53fb994 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.test.ts @@ -21,42 +21,42 @@ describe('Epic Timeline', () => { { columnHeaderType: 'not-filtered', id: '@timestamp', - width: 190, + initialWidth: 190, }, { columnHeaderType: 'not-filtered', id: 'message', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.category', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'event.action', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'host.name', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'source.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'destination.ip', - width: 180, + initialWidth: 180, }, { columnHeaderType: 'not-filtered', id: 'user.name', - width: 180, + initialWidth: 180, }, ], dataProviders: [ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 30d09da2f736d..5f5d76990b5ff 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -360,7 +360,9 @@ export const convertTimelineAsInput = ( } else if (key === 'columns' && get(key, timeline) != null) { return set( key, - get(key, timeline).map((col: ColumnHeaderOptions) => omit(['width', '__typename'], col)), + get(key, timeline).map((col: ColumnHeaderOptions) => + omit(['initialWidth', 'width', '__typename'], col) + ), acc ); } else if (key === 'filters' && get(key, timeline) != null) { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 135cbb3f73281..2172cf8562c97 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -11,7 +11,6 @@ import uuid from 'uuid'; import { ToggleDetailPanel } from './actions'; import { Filter } from '../../../../../../../src/plugins/data/public'; -import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; import { Sort } from '../../../timelines/components/timeline/body/sort'; import { DataProvider, @@ -42,6 +41,10 @@ import { DEFAULT_FROM_MOMENT, DEFAULT_TO_MOMENT, } from '../../../common/utils/default_date_settings'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + RESIZED_COLUMN_MIN_WITH, +} from '../../components/timeline/body/constants'; import { activeTimeline } from '../../containers/active_timeline_context'; export const isNotNull = (value: T | null): value is T => value !== null; @@ -495,13 +498,14 @@ export const applyDeltaToTimelineColumnWidth = ({ }, }; } - const minWidthPixels = getColumnWidthFromType(timeline.columns[columnIndex].type!); - const requestedWidth = timeline.columns[columnIndex].width + delta; // raw change in width - const width = Math.max(minWidthPixels, requestedWidth); // if the requested width is smaller than the min, use the min + + const requestedWidth = + (timeline.columns[columnIndex].initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH) + delta; // raw change in width + const initialWidth = Math.max(RESIZED_COLUMN_MIN_WITH, requestedWidth); // if the requested width is smaller than the min, use the min const columnWithNewWidth = { ...timeline.columns[columnIndex], - width, + initialWidth, }; const columns = [ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index faece61cf9b7e..559cec57dd55c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { EuiDataGridColumn } from '@elastic/eui'; + import { Filter, IFieldSubType } from '../../../../../../../src/plugins/data/public'; import { DataProvider } from '../../components/timeline/data_providers/data_provider'; @@ -32,21 +34,21 @@ export type ColumnHeaderType = 'not-filtered' | 'text-filter'; export type ColumnId = string; /** The specification of a column header */ -export interface ColumnHeaderOptions { +export type ColumnHeaderOptions = Pick< + EuiDataGridColumn, + 'display' | 'displayAsText' | 'id' | 'initialWidth' +> & { aggregatable?: boolean; category?: string; columnHeaderType: ColumnHeaderType; description?: string; example?: string; format?: string; - id: ColumnId; - label?: string; linkField?: string; placeholder?: string; subType?: IFieldSubType; type?: string; - width: number; -} +}; export interface TimelineModel { /** The selected tab to displayed in the timeline */ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index d467747346b8b..1c65c01a0bdfc 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -20,8 +20,10 @@ import { DataProvidersAnd, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; -import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; +import { + DEFAULT_COLUMN_MIN_WIDTH, + RESIZED_COLUMN_MIN_WITH, +} from '../../../timelines/components/timeline/body/constants'; import { defaultHeaders } from '../../../common/mock'; import { @@ -278,7 +280,7 @@ describe('Timeline', () => { id: 'event.action', type: 'keyword', aggregatable: true, - width: DEFAULT_COLUMN_MIN_WIDTH, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }; mockWithExistingColumns = { ...timelineById, @@ -600,12 +602,12 @@ describe('Timeline', () => { expect(update).not.toBe(timelineByIdMock); }); - test('should update (just) the specified column of type `date` when the id matches, and the result of applying the delta is greater than the min width for a date column', () => { + test('should update initialWidth with the specified delta when the delta is positive', () => { const aDateColumn = columnsMock[0]; const delta = 50; const expectedToHaveNewWidth = { ...aDateColumn, - width: getColumnWidthFromType(aDateColumn.type!) + delta, + initialWidth: Number(aDateColumn.initialWidth) + 50, }; const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; @@ -619,12 +621,12 @@ describe('Timeline', () => { expect(update.foo.columns).toEqual(expectedColumns); }); - test('should NOT update (just) the specified column of type `date` when the id matches, because the result of applying the delta is less than the min width for a date column', () => { + test('should update initialWidth with the specified delta when the delta is negative, and the resulting width is greater than the min column width', () => { const aDateColumn = columnsMock[0]; - const delta = -50; // this will be less than the min + const delta = 50 * -1; // the result will still be above the min column size const expectedToHaveNewWidth = { ...aDateColumn, - width: getColumnWidthFromType(aDateColumn.type!), // we expect the minimum + initialWidth: Number(aDateColumn.initialWidth) - 50, }; const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; @@ -638,37 +640,18 @@ describe('Timeline', () => { expect(update.foo.columns).toEqual(expectedColumns); }); - test('should update (just) the specified non-date column when the id matches, and the result of applying the delta is greater than the min width for the column', () => { - const aNonDateColumn = columnsMock[1]; - const delta = 50; - const expectedToHaveNewWidth = { - ...aNonDateColumn, - width: getColumnWidthFromType(aNonDateColumn.type!) + delta, - }; - const expectedColumns = [columnsMock[0], expectedToHaveNewWidth, columnsMock[2]]; - - const update = applyDeltaToTimelineColumnWidth({ - id: 'foo', - columnId: aNonDateColumn.id, - delta, - timelineById: mockWithExistingColumns, - }); - - expect(update.foo.columns).toEqual(expectedColumns); - }); - - test('should NOT update the specified non-date column when the id matches, because the result of applying the delta is less than the min width for the column', () => { - const aNonDateColumn = columnsMock[1]; - const delta = -50; + test('should set initialWidth to `RESIZED_COLUMN_MIN_WITH` when the requested delta results in a column that is too small ', () => { + const aDateColumn = columnsMock[0]; + const delta = (Number(aDateColumn.initialWidth) - 5) * -1; // the requested delta would result in a width of just 5 pixels, which is too small const expectedToHaveNewWidth = { - ...aNonDateColumn, - width: getColumnWidthFromType(aNonDateColumn.type!), + ...aDateColumn, + initialWidth: RESIZED_COLUMN_MIN_WITH, // we expect the minimum }; - const expectedColumns = [columnsMock[0], expectedToHaveNewWidth, columnsMock[2]]; + const expectedColumns = [expectedToHaveNewWidth, columnsMock[1], columnsMock[2]]; const update = applyDeltaToTimelineColumnWidth({ id: 'foo', - columnId: aNonDateColumn.id, + columnId: aDateColumn.id, delta, timelineById: mockWithExistingColumns, }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts new file mode 100644 index 0000000000000..9dec4fb2cbb79 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './isolation'; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts new file mode 100644 index 0000000000000..d34945d92b6e1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { IRouter } from 'src/core/server'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers the Host-(un-)isolation routes + */ +export function registerHostIsolationRoutes(router: IRouter, endpointContext: EndpointAppContext) { + // perform isolation + router.post( + { + path: `/api/endpoint/isolate`, + validate: { + body: schema.object({ + agent_ids: schema.nullable(schema.arrayOf(schema.string())), + endpoint_ids: schema.nullable(schema.arrayOf(schema.string())), + alert_ids: schema.nullable(schema.arrayOf(schema.string())), + case_ids: schema.nullable(schema.arrayOf(schema.string())), + comment: schema.nullable(schema.string()), + }), + }, + options: { authRequired: true }, + }, + async (context, req, res) => { + if ( + (req.body.agent_ids === null || req.body.agent_ids.length === 0) && + (req.body.endpoint_ids === null || req.body.endpoint_ids.length === 0) + ) { + return res.badRequest({ + body: { + message: 'At least one agent ID or endpoint ID is required', + }, + }); + } + + return res.ok({ + body: { + action: '713085d6-ab45-4e9e-b41d-96563cafdd97', + }, + }); + } + ); + + // perform UN-isolate + router.post( + { + path: `/api/endpoint/unisolate`, + validate: { + body: schema.object({ + agent_ids: schema.nullable(schema.arrayOf(schema.string())), + endpoint_ids: schema.nullable(schema.arrayOf(schema.string())), + alert_ids: schema.nullable(schema.arrayOf(schema.string())), + case_ids: schema.nullable(schema.arrayOf(schema.string())), + comment: schema.nullable(schema.string()), + }), + }, + options: { authRequired: true }, + }, + async (context, req, res) => { + if ( + (req.body.agent_ids === null || req.body.agent_ids.length === 0) && + (req.body.endpoint_ids === null || req.body.endpoint_ids.length === 0) + ) { + return res.badRequest({ + body: { + message: 'At least one agent ID or endpoint ID is required', + }, + }); + } + return res.ok({ + body: { + action: '53ba1dd1-58a7-407e-b2a9-6843d9980068', + }, + }); + } + ); +} diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 2b5a25ec1b316..8987c8be00cf0 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -58,6 +58,7 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; +import { registerHostIsolationRoutes } from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -205,6 +206,7 @@ export class Plugin implements IPlugin { @@ -69,7 +72,11 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ); const proxyPort = - process.env.ALERTING_PROXY_PORT ?? (await getPort({ port: getPort.makeRange(6200, 6300) })); + process.env.ALERTING_PROXY_PORT ?? (await getPort({ port: getPort.makeRange(6200, 6299) })); + + // Create URLs of identical simple webhook servers using TLS, but we'll + // create custom host settings for them below. + const tlsWebhookServers = await getTlsWebhookServerUrls(6300, 6399); // If testing with proxy, also test proxyOnlyHosts for this proxy; // all the actions are assumed to be acccessing localhost anyway. @@ -89,6 +96,32 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.proxyBypassHosts=${JSON.stringify(proxyHosts)}`, ]; + // set up custom host settings for webhook ports; don't set one for noCustom + const customHostSettingsValue = [ + { + url: tlsWebhookServers.rejectUnauthorizedFalse, + tls: { + rejectUnauthorized: false, + }, + }, + { + url: tlsWebhookServers.rejectUnauthorizedTrue, + tls: { + rejectUnauthorized: true, + }, + }, + { + url: tlsWebhookServers.caFile, + tls: { + rejectUnauthorized: true, + certificateAuthoritiesFiles: [CA_CERT_PATH], + }, + }, + ]; + const customHostSettings = customizeLocalHostTls + ? [`--xpack.actions.customHostSettings=${JSON.stringify(customHostSettingsValue)}`] + : []; + return { testFiles: [require.resolve(`../${name}/tests/`)], servers, @@ -119,7 +152,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, ...actionsProxyUrl, - + ...customHostSettings, '--xpack.eventLog.logEntries=true', `--xpack.actions.preconfiguredAlertHistoryEsIndex=${preconfiguredAlertHistoryEsIndex}`, `--xpack.actions.preconfigured=${JSON.stringify({ @@ -162,6 +195,34 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) encrypted: 'this-is-also-ignored-and-also-required', }, }, + 'custom.tls.noCustom': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.noCustom}`, + config: { + url: tlsWebhookServers.noCustom, + }, + }, + 'custom.tls.rejectUnauthorizedFalse': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.rejectUnauthorizedFalse}`, + config: { + url: tlsWebhookServers.rejectUnauthorizedFalse, + }, + }, + 'custom.tls.rejectUnauthorizedTrue': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.rejectUnauthorizedTrue}`, + config: { + url: tlsWebhookServers.rejectUnauthorizedTrue, + }, + }, + 'custom.tls.caFile': { + actionTypeId: '.webhook', + name: `${tlsWebhookServers.caFile}`, + config: { + url: tlsWebhookServers.caFile, + }, + }, })}`, ...disabledPlugins.map((key) => `--xpack.${key}.enabled=false`), ...plugins.map( diff --git a/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts b/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts new file mode 100644 index 0000000000000..026cf21cb5920 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/lib/get_tls_webhook_servers.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fs from 'fs'; +import https from 'https'; +import getPort from 'get-port'; +import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils'; + +interface TlsWebhookURLs { + noCustom: string; + rejectUnauthorizedFalse: string; + rejectUnauthorizedTrue: string; + caFile: string; +} + +const ServerCert = fs.readFileSync(KBN_CERT_PATH, 'utf8'); +const ServerKey = fs.readFileSync(KBN_KEY_PATH, 'utf8'); + +export async function getTlsWebhookServerUrls( + portRangeStart: number, + portRangeEnd: number +): Promise { + let port: number; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const noCustom = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const rejectUnauthorizedFalse = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const rejectUnauthorizedTrue = `https://localhost:${port}`; + + port = await getPort({ port: getPort.makeRange(portRangeStart, portRangeEnd) }); + const caFile = `https://localhost:${port}`; + + return { + noCustom, + rejectUnauthorizedFalse, + rejectUnauthorizedTrue, + caFile, + }; +} + +export async function createTlsWebhookServer(port: string): Promise { + const httpsOptions = { + cert: ServerCert, + key: ServerKey, + }; + + const server = https.createServer(httpsOptions, async (req, res) => { + if (req.method === 'POST' || req.method === 'PUT') { + const allRead = new Promise((resolve) => { + req.on('data', (chunk) => {}); + req.on('end', () => resolve(null)); + }); + await allRead; + } + + res.writeHead(200); + res.end('https: just testing that a connection could be made'); + }); + const listening = new Promise((resolve) => { + server.listen(port, () => { + resolve(null); + }); + }); + await listening; + + // let node exit even if we don't close this server + server.unref(); + + return server; +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts index 059ef59fc614a..9a3a78342c5aa 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts @@ -60,7 +60,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all at space1': case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -168,7 +174,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'space_1_all at space1': case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: createdAction.id, is_preconfigured: false, @@ -252,7 +264,13 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { case 'global_read at space1': case 'superuser at space1': expect(response.statusCode).to.eql(200); - expect(response.body).to.eql([ + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-es-index-action', is_preconfigured: true, diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index 49d5f52869b89..3b3a15b6d62e4 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -13,5 +13,6 @@ export default createTestConfig('spaces_only', { license: 'trial', enableActionsProxy: false, rejectUnauthorized: false, + customizeLocalHostTls: true, preconfiguredAlertHistoryEsIndex: true, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts index 8ef573a3ae2c3..4af33136cd42c 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts @@ -15,6 +15,7 @@ import { getWebhookServer, getHttpsWebhookServer, } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; +import { createTlsWebhookServer } from '../../../../common/lib/get_tls_webhook_servers'; // eslint-disable-next-line import/no-default-export export default function webhookTest({ getService }: FtrProviderContext) { @@ -47,6 +48,19 @@ export default function webhookTest({ getService }: FtrProviderContext) { return createdAction.id; } + async function getPortOfConnector(connectorId: string): Promise { + const response = await supertest.get(`/api/actions/connectors`).expect(200); + const connector = response.body.find((conn: { id: string }) => conn.id === connectorId); + if (connector === undefined) { + throw new Error(`unable to find connector with id ${connectorId}`); + } + + // server URL is the connector name + const url = connector.name; + const parsedUrl = new URL(url); + return parsedUrl.port; + } + describe('webhook action', () => { describe('with http endpoint', () => { let webhookSimulatorURL: string = ''; @@ -108,5 +122,80 @@ export default function webhookTest({ getService }: FtrProviderContext) { webhookServer.close(); }); }); + + describe('tls customization', () => { + it('should handle the xpack.actions.rejectUnauthorized: false', async () => { + const connectorId = 'custom.tls.noCustom'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + + it('should handle the customized rejectUnauthorized: false', async () => { + const connectorId = 'custom.tls.rejectUnauthorizedFalse'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.rejectUnauthorizedFalse/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + + it('should handle the customized rejectUnauthorized: true', async () => { + const connectorId = 'custom.tls.rejectUnauthorizedTrue'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.rejectUnauthorizedTrue/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('error'); + expect(body.service_message.indexOf('certificate')).to.be.greaterThan(0); + }); + + it('should handle the customized ca file', async () => { + const connectorId = 'custom.tls.caFile'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.caFile/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts index 28abd0b79c57c..e7f500f2771e3 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all.ts @@ -5,6 +5,7 @@ * 2.0. */ +import expect from '@kbn/expect'; import { Spaces } from '../../scenarios'; import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; @@ -35,7 +36,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connectors`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -102,7 +113,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.other.id)}/api/actions/connectors`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', @@ -159,7 +180,17 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { .expect(200); objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); - await supertest.get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`).expect(200, [ + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/api/actions`) + .expect(200); + + // the custom tls connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomTlsConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.tls.') + ); + + expect(nonCustomTlsConnectors).to.eql([ { id: 'preconfigured-alert-history-es-index', name: 'Alert history Elasticsearch index', diff --git a/x-pack/test/functional/apps/maps/blended_vector_layer.js b/x-pack/test/functional/apps/maps/blended_vector_layer.js index 6d4fca1b0b7c0..e207410eb2281 100644 --- a/x-pack/test/functional/apps/maps/blended_vector_layer.js +++ b/x-pack/test/functional/apps/maps/blended_vector_layer.js @@ -27,20 +27,20 @@ export default function ({ getPageObjects, getService }) { }); it('should request documents when zoomed to smaller regions showing less data', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); // Allow a range of hits to account for variances in browser window size. expect(response.hits.hits.length).to.be.within(30, 40); }); it('should request clusters when zoomed to larger regions showing lots of data', async () => { await PageObjects.maps.setView(20, -90, 2); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(17); }); it('should request documents when query narrows data', async () => { await PageObjects.maps.setAndSubmitQuery('bytes > 19000'); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.hits.hits.length).to.equal(75); }); }); diff --git a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js index 1d6477b243cdf..fb0fdcf333cf2 100644 --- a/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js +++ b/x-pack/test/functional/apps/maps/documents_source/docvalue_fields.js @@ -23,7 +23,7 @@ export default function ({ getPageObjects, getService }) { it('should only fetch geo_point field and nothing else when source does not have data driven styling', async () => { await PageObjects.maps.loadSavedMap('document example'); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); const firstHit = response.hits.hits[0]; expect(firstHit).to.only.have.keys(['_id', '_index', '_score', 'fields']); expect(firstHit.fields).to.only.have.keys(['geo.coordinates']); @@ -31,7 +31,7 @@ export default function ({ getPageObjects, getService }) { it('should only fetch geo_point field and data driven styling fields', async () => { await PageObjects.maps.loadSavedMap('document example with data driven styles'); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); const firstHit = response.hits.hits[0]; expect(firstHit).to.only.have.keys(['_id', '_index', '_score', 'fields']); expect(firstHit.fields).to.only.have.keys(['bytes', 'geo.coordinates', 'hour_of_day']); @@ -39,7 +39,7 @@ export default function ({ getPageObjects, getService }) { it('should format date fields as epoch_millis when data driven styling is applied to a date field', async () => { await PageObjects.maps.loadSavedMap('document example with data driven styles on date field'); - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); const firstHit = response.hits.hits[0]; expect(firstHit).to.only.have.keys(['_id', '_index', '_score', 'fields']); expect(firstHit.fields).to.only.have.keys(['@timestamp', 'bytes', 'geo.coordinates']); diff --git a/x-pack/test/functional/apps/maps/embeddable/dashboard.js b/x-pack/test/functional/apps/maps/embeddable/dashboard.js index 860273bc23cc1..6c962c98c6a98 100644 --- a/x-pack/test/functional/apps/maps/embeddable/dashboard.js +++ b/x-pack/test/functional/apps/maps/embeddable/dashboard.js @@ -83,7 +83,7 @@ export default function ({ getPageObjects, getService }) { }); it('should apply container state (time, query, filters) to embeddable when loaded', async () => { - const response = await PageObjects.maps.getResponseFromDashboardPanel( + const { rawResponse: response } = await PageObjects.maps.getResponseFromDashboardPanel( 'geo grid vector grid example' ); expect(response.aggregations.gridSplit.buckets.length).to.equal(6); @@ -95,12 +95,12 @@ export default function ({ getPageObjects, getService }) { await filterBar.selectIndexPattern('meta_for_geo_shapes*'); await filterBar.addFilter('shape_name', 'is', 'alpha'); // runtime fields do not have autocomplete - const gridResponse = await PageObjects.maps.getResponseFromDashboardPanel( + const { rawResponse: gridResponse } = await PageObjects.maps.getResponseFromDashboardPanel( 'geo grid vector grid example' ); expect(gridResponse.aggregations.gridSplit.buckets.length).to.equal(1); - const joinResponse = await PageObjects.maps.getResponseFromDashboardPanel( + const { rawResponse: joinResponse } = await PageObjects.maps.getResponseFromDashboardPanel( 'join example', 'meta_for_geo_shapes*.runtime_shape_name' ); diff --git a/x-pack/test/functional/apps/maps/es_geo_grid_source.js b/x-pack/test/functional/apps/maps/es_geo_grid_source.js index 6dee4b87bceea..27949ca720e34 100644 --- a/x-pack/test/functional/apps/maps/es_geo_grid_source.js +++ b/x-pack/test/functional/apps/maps/es_geo_grid_source.js @@ -141,7 +141,7 @@ export default function ({ getPageObjects, getService }) { }); it('should apply query to geotile_grid aggregation request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(1); }); }); @@ -152,7 +152,7 @@ export default function ({ getPageObjects, getService }) { }); it('should contain geotile_grid aggregation elasticsearch request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(4); }); @@ -204,7 +204,7 @@ export default function ({ getPageObjects, getService }) { }); it('should apply query to geotile_grid aggregation request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(1); }); }); @@ -215,7 +215,7 @@ export default function ({ getPageObjects, getService }) { }); it('should contain geotile_grid aggregation elasticsearch request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(4); }); @@ -244,7 +244,7 @@ export default function ({ getPageObjects, getService }) { }); it('should contain geotile_grid aggregation elasticsearch request', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.gridSplit.buckets.length).to.equal(13); }); }); diff --git a/x-pack/test/functional/apps/maps/es_pew_pew_source.js b/x-pack/test/functional/apps/maps/es_pew_pew_source.js index 66406cd6d8f91..ea94ee3bc67d8 100644 --- a/x-pack/test/functional/apps/maps/es_pew_pew_source.js +++ b/x-pack/test/functional/apps/maps/es_pew_pew_source.js @@ -24,7 +24,7 @@ export default function ({ getPageObjects, getService }) { }); it('should request source clusters for destination locations', async () => { - const response = await PageObjects.maps.getResponse(); + const { rawResponse: response } = await PageObjects.maps.getResponse(); expect(response.aggregations.destSplit.buckets.length).to.equal(2); }); diff --git a/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js b/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js index 7bdaa3898aa47..4b973b9f66edd 100644 --- a/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js +++ b/x-pack/test/functional/apps/maps/import_geojson/add_layer_import_panel.js @@ -14,10 +14,15 @@ export default function ({ getPageObjects, getService }) { const FILE_LOAD_DIR = 'test_upload_files'; const DEFAULT_LOAD_FILE_NAME = 'point.json'; const security = getService('security'); + const retry = getService('retry'); describe('GeoJSON import layer panel', () => { before(async () => { - await security.testUser.setRoles(['global_maps_all', 'global_index_pattern_management_all']); + await security.testUser.setRoles([ + 'global_maps_all', + 'geoall_data_writer', + 'global_index_pattern_management_all', + ]); await PageObjects.maps.openNewMap(); }); @@ -87,23 +92,23 @@ export default function ({ getPageObjects, getService }) { }); it('should prevent import button from activating unless valid index name provided', async () => { - // Set index to invalid name await PageObjects.maps.setIndexName('NoCapitalLetters'); - // Check button - let importButtonActive = await PageObjects.maps.importFileButtonEnabled(); - expect(importButtonActive).to.be(false); + await retry.try(async () => { + const importButtonActive = await PageObjects.maps.importFileButtonEnabled(); + expect(importButtonActive).to.be(false); + }); - // Set index to valid name await PageObjects.maps.setIndexName('validindexname'); - // Check button - importButtonActive = await PageObjects.maps.importFileButtonEnabled(); - expect(importButtonActive).to.be(true); + await retry.try(async () => { + const importButtonActive = await PageObjects.maps.importFileButtonEnabled(); + expect(importButtonActive).to.be(true); + }); - // Set index back to invalid name await PageObjects.maps.setIndexName('?noquestionmarks?'); - // Check button - importButtonActive = await PageObjects.maps.importFileButtonEnabled(); - expect(importButtonActive).to.be(false); + await retry.try(async () => { + const importButtonActive = await PageObjects.maps.importFileButtonEnabled(); + expect(importButtonActive).to.be(false); + }); }); }); } diff --git a/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js b/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js index a5b376cbb33a5..1ce4ccdcec97f 100644 --- a/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js +++ b/x-pack/test/functional/apps/maps/import_geojson/file_indexing_panel.js @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }) { const log = getService('log'); const security = getService('security'); const browser = getService('browser'); + const retry = getService('retry'); const IMPORT_FILE_PREVIEW_NAME = 'Import File'; const FILE_LOAD_DIR = 'test_upload_files'; @@ -32,6 +33,10 @@ export default function ({ getService, getPageObjects }) { const indexName = uuid(); await PageObjects.maps.setIndexName(indexName); + await retry.try(async () => { + const importButtonActive = await PageObjects.maps.importFileButtonEnabled(); + expect(importButtonActive).to.be(true); + }); await PageObjects.maps.clickImportFileButton(); return indexName; } diff --git a/x-pack/test/functional/apps/maps/joins.js b/x-pack/test/functional/apps/maps/joins.js index 181b6928e0ec0..a3210e61f86a9 100644 --- a/x-pack/test/functional/apps/maps/joins.js +++ b/x-pack/test/functional/apps/maps/joins.js @@ -121,7 +121,7 @@ export default function ({ getPageObjects, getService }) { }); it('should not apply query to source and apply query to join', async () => { - const joinResponse = await PageObjects.maps.getResponse( + const { rawResponse: joinResponse } = await PageObjects.maps.getResponse( 'meta_for_geo_shapes*.runtime_shape_name' ); expect(joinResponse.aggregations.join.buckets.length).to.equal(2); @@ -138,7 +138,7 @@ export default function ({ getPageObjects, getService }) { }); it('should apply query to join request', async () => { - const joinResponse = await PageObjects.maps.getResponse( + const { rawResponse: joinResponse } = await PageObjects.maps.getResponse( 'meta_for_geo_shapes*.runtime_shape_name' ); expect(joinResponse.aggregations.join.buckets.length).to.equal(1); diff --git a/yarn.lock b/yarn.lock index f1fe9d56ad78a..91b6791b2c80f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1389,6 +1389,20 @@ version "0.0.0" uid "" +"@elastic/ecs-helpers@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@elastic/ecs-helpers/-/ecs-helpers-1.1.0.tgz#ee7e6f870f75a2222c5d7179b36a628f1db4779e" + integrity sha512-MDLb2aFeGjg46O5mLpdCzT5yOUDnXToJSrco2ShqGIXxNJaM8uJjX+4nd+hRYV4Vex8YJyDtOFEVBldQct6ndg== + dependencies: + fast-json-stringify "^2.4.1" + +"@elastic/ecs-pino-format@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@elastic/ecs-pino-format/-/ecs-pino-format-1.1.1.tgz#f996a7a0074155cb6d63499332092bc9c74ac5e4" + integrity sha512-I7SzS0JYA8tdfsw4aTR+33HWWCaU7QY759kzt4sXm+O1waILaUWMzW3C2RL0ihQ66M99t+XMhRrA4cKStkHNXg== + dependencies: + "@elastic/ecs-helpers" "^1.1.0" + "@elastic/elasticsearch@npm:@elastic/elasticsearch-canary@^8.0.0-canary.4": version "8.0.0-canary.4" resolved "https://registry.yarnpkg.com/@elastic/elasticsearch-canary/-/elasticsearch-canary-8.0.0-canary.4.tgz#6f1a592974941baae347eb8c66a2006848349717" @@ -2609,7 +2623,7 @@ version "0.0.0" uid "" -"@kbn/dev-utils@link:packages/kbn-dev-utils": +"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils/npm_module": version "0.0.0" uid "" @@ -2653,7 +2667,7 @@ version "0.0.0" uid "" -"@kbn/logging@link:packages/kbn-logging": +"@kbn/logging@link:bazel-bin/packages/kbn-logging/npm_module": version "0.0.0" uid "" @@ -6545,7 +6559,7 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.5.5, ajv@ json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^6.12.5: +ajv@^6.11.0, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -7331,6 +7345,11 @@ atob@^2.1.1, atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + attr-accept@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52" @@ -7381,11 +7400,6 @@ available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2: dependencies: array-filter "^1.0.0" -await-event@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/await-event/-/await-event-2.1.0.tgz#78e9f92684bae4022f9fa0b5f314a11550f9aa76" - integrity sha1-eOn5JoS65AIvn6C18xShFVD5qnY= - aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -11969,48 +11983,47 @@ ejs@^3.1.2, ejs@^3.1.5, ejs@^3.1.6: dependencies: jake "^10.6.1" -elastic-apm-http-client@^9.4.2: - version "9.4.2" - resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-9.4.2.tgz#b479817b13ef38020991ccf1c9af9e335f92314a" - integrity sha512-zhOf0+cIO45tJgvQw3fWjXRWqO2MizCC9cvnQpMH2NNsQItXnZfJilhmiYJr8XYi50FxnlOvaav8koZ6tcObmw== +elastic-apm-http-client@^9.8.0: + version "9.8.0" + resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-9.8.0.tgz#caa738c2663b3ec8521ebede86cc841e4c77863c" + integrity sha512-JrlQbijs4dY8539zH+QNKLqLDCNyNymyy720tDaj+/i5pcwWYz5ipPARAdrKkor56AmKBxib8Fd6KsSWtIYjcA== dependencies: breadth-filter "^2.0.0" container-info "^1.0.1" end-of-stream "^1.4.4" fast-safe-stringify "^2.0.7" fast-stream-to-buffer "^1.0.0" - pump "^3.0.0" + object-filter-sequence "^1.0.0" readable-stream "^3.4.0" stream-chopper "^3.0.1" unicode-byte-truncate "^1.0.0" -elastic-apm-node@^3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.10.0.tgz#2b061613a2fbeb3bba4e3b87040dab55df1d8583" - integrity sha512-H1DOrpr0CwX88awQqSM4UbHGdfsk7xJ4GM6R1uYuFk1zILX/eozylcm6dYSKirpXwwMLxGSRFTOCaMa8fqiLjQ== +elastic-apm-node@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.14.0.tgz#942d6e86bd9d3710f51f0e43f04965d63c3fefd3" + integrity sha512-B7Xkz6UL44mm+2URdZy2yxpEB2C5CvZLOP3sGpf2h/hepXr4NgrVoRxGqO1F2b2wCB48smPv4a3v35b396VSwA== dependencies: + "@elastic/ecs-pino-format" "^1.1.0" after-all-results "^2.0.0" async-value-promise "^1.1.1" basic-auth "^2.0.1" - console-log-level "^1.4.1" cookie "^0.4.0" core-util-is "^1.0.2" - elastic-apm-http-client "^9.4.2" + elastic-apm-http-client "^9.8.0" end-of-stream "^1.4.4" error-stack-parser "^2.0.6" escape-string-regexp "^4.0.0" fast-safe-stringify "^2.0.7" http-headers "^3.0.2" - http-request-to-url "^1.0.0" is-native "^1.0.1" measured-reporting "^1.51.1" monitor-event-loop-delay "^1.0.0" object-filter-sequence "^1.0.0" object-identity-map "^1.0.2" original-url "^1.2.3" + pino "^6.11.2" read-pkg-up "^7.0.1" relative-microtime "^2.0.0" - require-ancestors "^1.0.0" require-in-the-middle "^5.0.3" semver "^6.3.0" set-cookie-serde "^1.0.0" @@ -13246,6 +13259,16 @@ fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@~2.1.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-json-stringify@^2.4.1: + version "2.6.0" + resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-2.6.0.tgz#3dcb4835b63d4e17dbd17411594aa63df8c0f95b" + integrity sha512-xTZtZRopWp2Aun7sGX2EB2mFw4bMQ+xnR8BmD5Rn4K0hKXGkbcZAzTtxEX0P4KNaNx1RAwvf+FESfuM0+F4WZg== + dependencies: + ajv "^6.11.0" + deepmerge "^4.2.2" + rfdc "^1.2.0" + string-similarity "^4.0.1" + fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -13256,6 +13279,11 @@ fast-memoize@^2.5.1: resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.1.tgz#c3519241e80552ce395e1a32dcdde8d1fd680f5d" integrity sha512-xdmw296PCL01tMOXx9mdJSmWY29jQgxyuZdq0rEHMu+Tpe1eOEtCycoG6chzlcrWsNgpZP7oL8RiQr7+G6Bl6g== +fast-redact@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.0.0.tgz#ac2f9e36c9f4976f5db9fb18c6ffbaf308cf316d" + integrity sha512-a/S/Hp6aoIjx7EmugtzLqXmcNsyFszqbt6qQ99BdG61QjBZF6shNis0BYR6TsZOQ1twYc0FN2Xdhwwbv6+KD0w== + fast-safe-stringify@2.x.x, fast-safe-stringify@^2.0.4, fast-safe-stringify@^2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" @@ -13628,6 +13656,11 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== +flatstr@^1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/flatstr/-/flatstr-1.0.12.tgz#c2ba6a08173edbb6c9640e3055b95e287ceb5931" + integrity sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw== + flatted@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916" @@ -15462,14 +15495,6 @@ http-proxy@^1.17.0, http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" -http-request-to-url@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/http-request-to-url/-/http-request-to-url-1.0.0.tgz#e56b9418f79f29d344fed05cfe2c56ccb8cc79ac" - integrity sha512-YYx0lKXG9+T1fT2q3ZgXLczMI3jW09g9BvIA6L3BG0tFqGm83Ka/+RUZGANRG7Ut/yueD7LPcZQ/+pA5ndNajw== - dependencies: - await-event "^2.1.0" - socket-location "^1.0.0" - http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -21329,6 +21354,23 @@ pinkie@^2.0.0: resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= +pino-std-serializers@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz#b56487c402d882eb96cd67c257868016b61ad671" + integrity sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg== + +pino@^6.11.2: + version "6.11.3" + resolved "https://registry.yarnpkg.com/pino/-/pino-6.11.3.tgz#0c02eec6029d25e6794fdb6bbea367247d74bc29" + integrity sha512-drPtqkkSf0ufx2gaea3TryFiBHdNIdXKf5LN0hTM82SXI4xVIve2wLwNg92e1MT6m3jASLu6VO7eGY6+mmGeyw== + dependencies: + fast-redact "^3.0.0" + fast-safe-stringify "^2.0.7" + flatstr "^1.0.12" + pino-std-serializers "^3.1.0" + quick-format-unescaped "^4.0.3" + sonic-boom "^1.0.2" + pirates@^4.0.0, pirates@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" @@ -22369,6 +22411,11 @@ queue@6.0.1: dependencies: inherits "~2.0.3" +quick-format-unescaped@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.3.tgz#6d6b66b8207aa2b35eef12be1421bb24c428f652" + integrity sha512-MaL/oqh02mhEo5m5J2rwsVL23Iw2PEaGVHgT2vFt8AAsr0lfvQA5dpXo9TPu0rz7tSBdUPgkbam0j/fj5ZM8yg== + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -24048,11 +24095,6 @@ request@2.81.0, request@^2.44.0, request@^2.87.0, request@^2.88.0, request@^2.88 tunnel-agent "^0.6.0" uuid "^3.3.2" -require-ancestors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/require-ancestors/-/require-ancestors-1.0.0.tgz#807831f8f8081fb12863da81ddb15c8f2a73a004" - integrity sha512-Nqeo9Gfp0KvnxTixnxLGEbThMAi+YYgnwRoigtOs1Oo3eGBYfqCd3dagq1vBCVVuc1EnIt3Eu1eGemwOOEZozw== - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -24286,6 +24328,11 @@ reusify@^1.0.0: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfdc@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" + integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== + rgb-regex@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" @@ -24450,6 +24497,13 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" +safe-squel@^5.12.5: + version "5.12.5" + resolved "https://registry.yarnpkg.com/safe-squel/-/safe-squel-5.12.5.tgz#9597cec498dc184a15fe94082b7bcc80cb4d048b" + integrity sha512-ls4iMpRE+/yTJ3W9GDRAT9rjqNVl220ng+N55udJXNu9ubcidMFA66Nung5UuE3xFlSSwvkV9PaMR5HZvShuvw== + dependencies: + sql-escape-string "^1.1.0" + safefs@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/safefs/-/safefs-4.1.0.tgz#f82aeb4bdd7ae51f653eb20f6728b3058c8d6445" @@ -25083,13 +25137,6 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^2.0.0" -socket-location@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/socket-location/-/socket-location-1.0.0.tgz#6f0c6f891c9a61c9a750265c14921d12196d266f" - integrity sha512-TwxpRM0pPE/3b24XQGLx8zq2J8kOwTy40FtiNC1KrWvl/Tsf7RYXruE9icecMhQwicXMo/HUJlGap8DNt2cgYw== - dependencies: - await-event "^2.1.0" - sockjs-client@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.4.0.tgz#c9f2568e19c8fd8173b4997ea3420e0bb306c7d5" @@ -25111,6 +25158,14 @@ sockjs@0.3.20: uuid "^3.4.0" websocket-driver "0.6.5" +sonic-boom@^1.0.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-1.3.0.tgz#5c77c846ce6c395dddf2eb8e8e65f9cc576f2e76" + integrity sha512-4nX6OYvOYr6R76xfQKi6cZpTO3YSWe/vd+QdIfoH0lBy0MnPkeAbb2rRWgmgADkXUeCKPwO1FZAKlAVWAadELw== + dependencies: + atomic-sleep "^1.0.0" + flatstr "^1.0.12" + sort-keys@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" @@ -25370,16 +25425,16 @@ sprintf-js@^1.0.3, sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +sql-escape-string@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/sql-escape-string/-/sql-escape-string-1.1.0.tgz#fe744b8514868c0eb4bfb9e4a989271d40f30eb9" + integrity sha1-/nRLhRSGjA60v7nkqYknHUDzDrk= + sql-summary@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/sql-summary/-/sql-summary-1.0.1.tgz#a2dddb5435bae294eb11424a7330dc5bafe09c2b" integrity sha512-IpCr2tpnNkP3Jera4ncexsZUp0enJBLr+pHCyTweMUBrbJsTgQeLWx1FXLhoBj/MvcnUQpkgOn2EY8FKOkUzww== -squel@^5.13.0: - version "5.13.0" - resolved "https://registry.yarnpkg.com/squel/-/squel-5.13.0.tgz#09cc73e91f0d0e326482605ee76e3b7ac881ddf6" - integrity sha512-Fzd8zqbuqNwzodO3yO6MkX8qiDoVBuwqAaa3eKNz4idhBf24IQHbatBhLUiHAGGl962eGvPVRxzRuFWZlSf49w== - sshpk@^1.7.0: version "1.15.2" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.15.2.tgz#c946d6bd9b1a39d0e8635763f5242d6ed6dcb629" @@ -25695,6 +25750,11 @@ string-replace-loader@^2.2.0: loader-utils "^1.2.3" schema-utils "^1.0.0" +string-similarity@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.3.tgz#ef52d6fc59c8a0fc93b6307fbbc08cc6e18cde21" + integrity sha512-QEwJzNFCqq+5AGImk5z4vbsEPTN/+gtyKfXBVLBcbPBRPNganZGfQnIuf9yJ+GiwSnD65sT8xrw/uwU1Q1WmfQ== + string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"