diff --git a/NOTICE.txt b/NOTICE.txt index 4eec329b7a603..4ede43610ca7b 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -149,17 +149,17 @@ SOFTWARE. --- Detection Rules -Copyright 2020 Elasticsearch B.V. +Copyright 2021 Elasticsearch B.V. --- This product bundles rules based on https://github.com/BlueTeamLabs/sentinel-attack -which is available under a "MIT" license. The files based on this license are: +which is available under a "MIT" license. The rules based on this license are: -- defense_evasion_via_filter_manager -- discovery_process_discovery_via_tasklist_command -- persistence_priv_escalation_via_accessibility_features -- persistence_via_application_shimming -- defense_evasion_execution_via_trusted_developer_utilities +- "Potential Evasion via Filter Manager" (06dceabf-adca-48af-ac79-ffdf4c3b1e9a) +- "Process Discovery via Tasklist" (cc16f774-59f9-462d-8b98-d27ccd4519ec) +- "Potential Modification of Accessibility Binaries" (7405ddf1-6c8e-41ce-818f-48bea6bcaed8) +- "Potential Application Shimming via Sdbinst" (fd4a992d-6130-4802-9ff8-829b89ae801f) +- "Trusted Developer Application Usage" (9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae1) MIT License @@ -185,9 +185,9 @@ SOFTWARE. --- This product bundles rules based on https://github.com/FSecureLABS/leonidas -which is available under a "MIT" license. The files based on this license are: +which is available under a "MIT" license. The rules based on this license are: -- credential_access_secretsmanager_getsecretvalue.toml +- "AWS Access Secret in Secrets Manager" (a00681e3-9ed6-447c-ab2c-be648821c622) MIT License @@ -235,6 +235,10 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- +Portions of this code are licensed under the following license: +For license information please see https://edge.fullstory.com/s/fs.js.LICENSE.txt + --- This product bundles bootstrap@3.3.6 which is available under a "MIT" license. diff --git a/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md b/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md new file mode 100644 index 0000000000000..217066481d33c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.cspconfig.__private_.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CspConfig](./kibana-plugin-core-server.cspconfig.md) > ["\#private"](./kibana-plugin-core-server.cspconfig.__private_.md) + +## CspConfig."\#private" property + +Signature: + +```typescript +#private; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.cspconfig.md b/docs/development/core/server/kibana-plugin-core-server.cspconfig.md index 9f4f3211ea2b1..0337a1f4d3301 100644 --- a/docs/development/core/server/kibana-plugin-core-server.cspconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.cspconfig.md @@ -20,6 +20,7 @@ The constructor for this class is marked as internal. Third-party code should no | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| ["\#private"](./kibana-plugin-core-server.cspconfig.__private_.md) | | | | | [DEFAULT](./kibana-plugin-core-server.cspconfig.default.md) | static | CspConfig | | | [disableEmbedding](./kibana-plugin-core-server.cspconfig.disableembedding.md) | | boolean | | | [header](./kibana-plugin-core-server.cspconfig.header.md) | | string | | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md index b875b1fce4288..444132024596e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md @@ -36,6 +36,7 @@ | [isSavedObjectEmbeddableInput(input)](./kibana-plugin-plugins-embeddable-public.issavedobjectembeddableinput.md) | | | [openAddPanelFlyout(options)](./kibana-plugin-plugins-embeddable-public.openaddpanelflyout.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-embeddable-public.plugin.md) | | +| [useEmbeddableFactory({ input, factory, onInputUpdated, })](./kibana-plugin-plugins-embeddable-public.useembeddablefactory.md) | | ## Interfaces diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md new file mode 100644 index 0000000000000..9af20cacc2cee --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.useembeddablefactory.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [useEmbeddableFactory](./kibana-plugin-plugins-embeddable-public.useembeddablefactory.md) + +## useEmbeddableFactory() function + +Signature: + +```typescript +export declare function useEmbeddableFactory({ input, factory, onInputUpdated, }: EmbeddableRendererWithFactory): readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| { input, factory, onInputUpdated, } | EmbeddableRendererWithFactory<I> | | + +Returns: + +`readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]` + diff --git a/docs/settings/task-manager-settings.asciidoc b/docs/settings/task-manager-settings.asciidoc index 87f5b700870eb..7f4dbb3a96e6b 100644 --- a/docs/settings/task-manager-settings.asciidoc +++ b/docs/settings/task-manager-settings.asciidoc @@ -29,7 +29,13 @@ Task Manager runs background tasks by polling for work on an interval. You can | The maximum number of tasks that this Kibana instance will run simultaneously. Defaults to 10. Starting in 8.0, it will not be possible to set the value greater than 100. - | `xpack.task_manager.monitored_stats_warn_delayed_task_start_in_seconds` + | `xpack.task_manager.` + `monitored_stats_health_verbose_log.enabled` + | This flag will enable automatic warn and error logging if task manager self detects a performance issue, such as the time between when a task is scheduled to execute and when it actually executes. Defaults to false. + + | `xpack.task_manager.` + `monitored_stats_health_verbose_log.` + `warn_delayed_task_start_in_seconds` | The amount of seconds we allow a task to delay before printing a warning server log. Defaults to 60. |=== diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index c3c29adcea18f..bcaa86d73adc4 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -36,11 +36,57 @@ Set to `false` to disable Console. *Default: `true`* <>. | `csp.rules:` - | A https://w3c.github.io/webappsec-csp/[content-security-policy] template + | deprecated:[7.14.0,"In 8.0 and later, this setting will no longer be supported."] +A https://w3c.github.io/webappsec-csp/[Content Security Policy] template that disables certain unnecessary and potentially insecure capabilities in the browser. It is strongly recommended that you keep the default CSP rules that ship with {kib}. +| `csp.script_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src[Content Security Policy `script-src` directive]. + +| `csp.worker_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/worker-src[Content Security Policy `worker-src` directive]. + +| `csp.style_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src[Content Security Policy `style-src` directive]. + +| `csp.connect_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src[Content Security Policy `connect-src` directive]. + +| `csp.default_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src[Content Security Policy `default-src` directive]. + +| `csp.font_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/font-src[Content Security Policy `font-src` directive]. + +| `csp.frame_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-src[Content Security Policy `frame-src` directive]. + +| `csp.img_src:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src[Content Security Policy `img-src` directive]. + +| `csp.frame_ancestors:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors[Content Security Policy `frame-ancestors` directive]. + +|=== + +[NOTE] +============ +The `frame-ancestors` directive can also be configured by using +<>. In that case, that takes precedence and any values in `csp.frame_ancestors` +are ignored. +============ + +[cols="2*<"] +|=== + +| `csp.report_uri:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri[Content Security Policy `report-uri` directive]. + +| `csp.report_to:` +| Add sources for the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-to[Content Security Policy `report-to` directive]. + |[[csp-strict]] `csp.strict:` | Blocks {kib} access to any browser that does not enforce even rudimentary CSP rules. In practice, this disables @@ -538,8 +584,7 @@ a|`server.securityResponseHeaders:` is used in all responses to the client from the {kib} server, and specifies what value is used. Allowed values are any text value or `null`. To disable, set to `null`. *Default:* `null` -[[server-securityResponseHeaders-disableEmbedding]] -a|`server.securityResponseHeaders:` +|[[server-securityResponseHeaders-disableEmbedding]]`server.securityResponseHeaders:` `disableEmbedding:` | Controls whether the https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy[`Content-Security-Policy`] and https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options[`X-Frame-Options`] headers are configured to disable embedding diff --git a/docs/setup/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index fdcd71791ad3a..947043b21ef50 100644 --- a/docs/setup/upgrade/upgrade-migrations.asciidoc +++ b/docs/setup/upgrade/upgrade-migrations.asciidoc @@ -55,22 +55,55 @@ This section highlights common causes of {kib} upgrade failures and how to preve There is a known issue in v7.12.0 for users who tried the fleet beta. Upgrade migrations fail because of a large number of documents in the `.kibana` index. This can cause Kibana to log errors like: -> Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [receive_timeout_transport_exception]: [instance-0000000002][10.32.1.112:19541][cluster:monitor/task/get] request_id [2648] timed out after [59940ms] -> Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [timeout_exception]: Timed out waiting for completion of [org.elasticsearch.index.reindex.BulkByScrollTask@6a74c54] + +[source,sh] +-------------------------------------------- +Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [receive_timeout_transport_exception]: [instance-0000000002][10.32.1.112:19541][cluster:monitor/task/get] request_id [2648] timed out after [59940ms] + +Error: Unable to complete saved object migrations for the [.kibana] index. Please check the health of your Elasticsearch cluster and try again. Error: [timeout_exception]: Timed out waiting for completion of [org.elasticsearch.index.reindex.BulkByScrollTask@6a74c54] +-------------------------------------------- See https://github.com/elastic/kibana/issues/95321 for instructions to work around this issue. [float] ===== Corrupt saved objects -We highly recommend testing your {kib} upgrade in a development cluster to discover and remedy problems caused by corrupt documents, especially when there are custom integrations creating saved objects in your environment. Saved objects that were corrupted through manual editing or integrations will cause migration failures with a log message like `Failed to transform document. Transform: index-pattern:7.0.0\n Doc: {...}` or `Unable to migrate the corrupt Saved Object document ...`. Corrupt documents will have to be fixed or deleted before an upgrade migration can succeed. +We highly recommend testing your {kib} upgrade in a development cluster to discover and remedy problems caused by corrupt documents, especially when there are custom integrations creating saved objects in your environment. + +Saved objects that were corrupted through manual editing or integrations will cause migration failures with a log message like `Failed to transform document. Transform: index-pattern:7.0.0\n Doc: {...}` or `Unable to migrate the corrupt Saved Object document ...`. Corrupt documents will have to be fixed or deleted before an upgrade migration can succeed. For example, given the following error message: -> Unable to migrate the corrupt saved object document with _id: 'marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275'. To allow migrations to proceed, please delete this document from the [.kibana_7.12.0_001] index. -The following steps must be followed to allow the upgrade migration to succeed. -Please be aware the Dashboard having ID `e3c5fc71-ac71-4805-bcab-2bcc9cc93275` belonging to the space `marketing_space` will no more be available: -1. Delete the corrupt document with `DELETE .kibana_7.12.0_001/_doc/marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275` -2. Restart {kib} +[source,sh] +-------------------------------------------- +Unable to migrate the corrupt saved object document with _id: 'marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275'. To allow migrations to proceed, please delete this document from the [.kibana_7.12.0_001] index. +-------------------------------------------- + +The following steps must be followed to delete the document that is causing the migration to fail: + +. Remove the write block which the migration system has placed on the previous index: ++ +[source,sh] +-------------------------------------------- +PUT .kibana_7.12.1_001/_settings +{ + "index": { + "blocks.write": false + } +} +-------------------------------------------- + +. Delete the corrupt document: ++ +[source,sh] +-------------------------------------------- +DELETE .kibana_7.12.0_001/_doc/marketing_space:dashboard:e3c5fc71-ac71-4805-bcab-2bcc9cc93275 +-------------------------------------------- + +. Restart {kib}. + +In this example, the Dashboard with ID `e3c5fc71-ac71-4805-bcab-2bcc9cc93275` that belongs to the space `marketing_space` **will no longer be available**. + +Be sure you have a snapshot before you delete the corrupt document. If restoring from a snapshot is not an option, it is recommended to also delete the `temp` and `target` indices the migration created before restarting {kib} and retrying. [float] ===== User defined index templates that causes new `.kibana*` indices to have incompatible settings or mappings diff --git a/docs/siem/index.asciidoc b/docs/siem/index.asciidoc index 18895f0533fd7..05b1ec0b5b797 100644 --- a/docs/siem/index.asciidoc +++ b/docs/siem/index.asciidoc @@ -1,60 +1,164 @@ +[chapter] [role="xpack"] [[xpack-siem]] -= Elastic Security += Elastic Security overview +++++ +Security +++++ -[partintro] --- +https://www.elastic.co/security[Elastic Security] combines SIEM threat detection features with endpoint +prevention and response capabilities in one solution. These analytical and +protection capabilities, leveraged by the speed and extensibility of +Elasticsearch, enable analysts to defend their organization from threats before +damage and loss occur. -Elastic Security combines SIEM threat detection features with endpoint -prevention and response capabilities in one solution, including: +Elastic Security provides the following security benefits and capabilities: -* A detection engine to identify attacks and system misconfiguration +* A detection engine to identify attacks and system misconfigurations * A workspace for event triage and investigations * Interactive visualizations to investigate process relationships -* Embedded case management and automated actions -* Detection of signatureless attacks with prebuilt {ml} anomaly jobs and -detection rules +* Inbuilt case management with automated actions +* Detection of signatureless attacks with prebuilt machine learning anomaly jobs +and detection rules -[role="screenshot"] -image::siem/images/overview-ui.png[Elastic Security in Kibana] - -[float] -== Add data - -Kibana provides step-by-step instructions to help you add data. The -{security-guide}[Security Guide] is a good source for more -detailed information and instructions. - -[float] -=== {Beats} - -https://www.elastic.co/products/beats/auditbeat[{auditbeat}], -https://www.elastic.co/products/beats/filebeat[{filebeat}], -https://www.elastic.co/products/beats/winlogbeat[{winlogbeat}], and -https://www.elastic.co/products/beats/packetbeat[{packetbeat}] -send security events and other data to Elasticsearch. +[discrete] +== Elastic Security components and workflow -The default index patterns for Elastic Security events are `auditbeat-*`, `winlogbeat-*`, -`filebeat-*`, `packetbeat-*`, `endgame-*`, `logs-*`, and `apm-*-transaction*`. To change the default pattern patterns, go to *Stack Management > Advanced Settings > securitySolution:defaultIndex*. +The following diagram provides a comprehensive illustration of the Elastic Security workflow. -[float] -=== Elastic Security endpoint agent - -The agent detects and protects against malware, and ships host and network -events directly to Elastic Security. - -[float] -=== Elastic Common Schema (ECS) for normalizing data - -The {ecs-ref}[Elastic Common Schema (ECS)] defines a common set of fields to be -used for storing event data in Elasticsearch. ECS helps users normalize their -event data to better analyze, visualize, and correlate the data represented in -their events. - -Elastic Security can ingest and normalize events from ECS-compatible data sources. +[role="screenshot"] +image::../siem/images/workflow.png[] + +Here's an overview of the flow and its components: + +* Data is shipped from your hosts to {es} via beat modules and the Elastic https://www.elastic.co/endpoint-security/[Endpoint Security agent integration]. This integration provides capabilities such as collecting events, detecting and preventing {security-guide}/detection-engine-overview.html#malware-prevention[malicious activity], and artifact delivery. The {fleet-guide}/fleet-overview.html[{fleet}] app is used to +install and manage agents and integrations on your hosts. ++ +The Endpoint Security integration ships the following data sets: ++ +*** *Windows*: Process, network, file, DNS, registry, DLL and driver loads, +malware security detections +*** *Linux/macOS*: Process, network, file ++ +* https://www.elastic.co/integrations?solution=security[Beat modules]: {beats} +are lightweight data shippers. Beat modules provide a way of collecting and +parsing specific data sets from common sources, such as cloud and OS events, +logs, and metrics. Common security-related modules are listed {security-guide}/ingest-data.html#enable-beat-modules[here]. +* The {security-app} in {kib} is used to manage the *Detection engine*, +*Cases*, and *Timeline*, as well as administer hosts running Endpoint Security: +** Detection engine: Automatically searches for suspicious host and network +activity via the following: +*** {security-guide}/detection-engine-overview.html#detection-engine-overview[Detection rules]: Periodically search the data +({es} indices) sent from your hosts for suspicious events. When a suspicious +event is discovered, a detection alert is generated. External systems, such as +Slack and email, can be used to send notifications when alerts are generated. +You can create your own rules and make use of our {security-guide}/prebuilt-rules.html[prebuilt ones]. +*** {security-guide}/detections-ui-exceptions.html[Exceptions]: Reduce noise and the number of +false positives. Exceptions are associated with rules and prevent alerts when +an exception's conditions are met. *Value lists* contain source event +values that can be used as part of an exception's conditions. When +Elastic {endpoint-sec} is installed on your hosts, you can add malware exceptions +directly to the endpoint from the Security app. +*** {security-guide}/machine-learning.html#included-jobs[{ml-cap} jobs]: Automatic anomaly detection of host and +network events. Anomaly scores are provided per host and can be used with +detection rules. +** {security-guide}/timelines-ui.html[Timeline]: Workspace for investigating alerts and events. +Timelines use queries and filters to drill down into events related to +a specific incident. Timeline templates are attached to rules and use predefined +queries when alerts are investigated. Timelines can be saved and shared with +others, as well as attached to Cases. +** {security-guide}/cases-overview.html[Cases]: An internal system for opening, tracking, and sharing +security issues directly in the Security app. Cases can be integrated with +external ticketing systems. +** {security-guide}/admin-page-ov.html[Administration]: View and manage hosts running {endpoint-sec}. + +{security-guide}/ingest-data.html[Ingest data to Elastic Security] and {security-guide}/install-endpoint.html[Configure and install the Elastic Endpoint integration] describe how to ship security-related +data to {es}. + + +For more background information, see: + +* https://www.elastic.co/products/elasticsearch[{es}]: A real-time, +distributed storage, search, and analytics engine. {es} excels at indexing +streams of semi-structured data, such as logs or metrics. +* https://www.elastic.co/products/kibana[{kib}]: An open-source analytics and +visualization platform designed to work with {es}. You use {kib} to search, +view, and interact with data stored in {es} indices. You can easily compile +advanced data analysis and visualize your data in a variety of charts, tables, +and maps. + +[discrete] +=== Compatibility with cold tier nodes + +Cold tier is a {ref}/data-tiers.html[data tier] that holds time-series data that is accessed only occasionally. In {stack} version >=7.11.0, {elastic-sec} supports cold tier data for the following {es} indices: + +* Index patterns specified in `securitySolution:defaultIndex` +* Index patterns specified in the definitions of detection rules, except for indicator match rules +* Index patterns specified in the data sources selector on various {security-app} pages + +{elastic-sec} does NOT support cold tier data for the following {es} indices: + +* Index patterns controlled by {elastic-sec}, including signals and list indices +* Index patterns specified in indicator match rules + +Using cold tier data for unsupported indices may result in detection rule timeouts and overall performance degradation. + +[discrete] +[[self-protection]] +==== Elastic Endpoint self-protection + +Self-protection means that {elastic-endpoint} has guards against users and attackers that may try to interfere with its functionality. This protection feature is consistently enhanced to prevent attackers who may attempt to use newer, more sophisticated tactics to interfere with the {elastic-endpoint}. Self-protection is enabled by default when {elastic-endpoint} installs on supported platforms, listed below. + +Self-protection is enabled on the following 64-bit Windows versions: + +* Windows 8.1 +* Windows 10 +* Windows Server 2012 R2 +* Windows Server 2016 +* Windows Server 2019 + +And on the following macOS versions: + +* macOS 10.15 (Catalina) +* macOS 11 (Big Sur) + +NOTE: Other Windows and macOS variants (and all Linux distributions) do not have self-protection. + +For {stack} version >= 7.11.0, self-protection defines the following permissions: + +* Users -- even Administrator/root -- *cannot* delete {elastic-endpoint} files (located at `c:\Program Files\Elastic\Endpoint` on Windows, and `/Library/Elastic/Endpoint` on macOS). +* Users *cannot* terminate the {elastic-endpoint} program or service. +* Administrator/root users *can* read the endpoint's files. On Windows, the easiest way to read Endpoint files is to start an Administrator `cmd.exe` prompt. On macOS, an Administrator can use the `sudo` command. +* Administrator/root users *can* stop the {elastic-agent}'s service. On Windows, run the `sc stop "Elastic Agent"` command. On macOS, run the `sudo launchctl stop elastic-agent` command. + + +[discrete] +[[siem-integration]] +=== Integration with other Elastic products + +You can use {elastic-sec} with other Elastic products and features to help you +identify and investigate suspicious activity: + +* https://www.elastic.co/products/stack/machine-learning[{ml-cap}] +* https://www.elastic.co/products/stack/alerting[Alerting] +* https://www.elastic.co/products/stack/canvas[Canvas] + +[discrete] +[[data-sources]] +=== APM transaction data sources + +By default, {elastic-sec} monitors {apm-app-ref}/apm-getting-started.html[APM] +`apm-*-transaction*` indices. To add additional APM indices, update the +index patterns in the `securitySolution:defaultIndex` setting ({kib} -> Stack Management -> Advanced Settings -> `securitySolution:defaultIndex`). --- +[discrete] +[[ecs-compliant-reqs]] +=== ECS compliance data requirements +The {ecs-ref}[Elastic Common Schema (ECS)] defines a common set of fields used for +storing event data in Elasticsearch. ECS helps users normalize their event data +to better analyze, visualize, and correlate the data represented in their +events. {elastic-sec} supports events and indicator index data from any ECS-compliant data source. -include::siem-ui.asciidoc[] -include::machine-learning.asciidoc[] +IMPORTANT: {elastic-sec} requires {ecs-ref}[ECS-compliant data]. If you use third-party data collectors to ship data to {es}, the data must be mapped to ECS. +{security-guide}/siem-field-reference.html[Elastic Security ECS field reference] lists ECS fields used in {elastic-sec}. diff --git a/docs/user/dashboard/lens-advanced.asciidoc b/docs/user/dashboard/lens-advanced.asciidoc index ec8d90aa4920e..33e0e362058f4 100644 --- a/docs/user/dashboard/lens-advanced.asciidoc +++ b/docs/user/dashboard/lens-advanced.asciidoc @@ -104,7 +104,7 @@ To quickly create many copies of a percentile metric that shows distribution of . From the *Chart Type* dropdown, select *Line*. + [role="screenshot"] -image::images/lens_advanced_2_1.png[Chart type menu with Line selected] +image::images/lens_advanced_2_1.png[Chart type menu with Line selected, width=50%] . From the *Available fields* list, drag and drop *products.price* to the visualization builder. @@ -239,12 +239,11 @@ For each category type that you want to break down, create a filter. Change the legend position to the top of the chart. . From the *Legend* dropdown, select the top position. - + [role="screenshot"] image::images/lens_advanced_4_1.png[Prices share by category] - Click *Save and return*. +. Click *Save and return*. [discrete] [[view-the-cumulative-number-of-products-sold-on-weekends]] @@ -299,7 +298,8 @@ image::images/lens_advanced_5_2.png[Line chart with cumulative sum of orders mad [[compare-time-ranges]] === Compare time ranges -*Lens* allows you to compare the currently selected time range with historical data using the *Time shift* option. +*Lens* allows you to compare the currently selected time range with historical data using the *Time shift* option. To calculate the percent +change, use *Formula*. Time shifts can be used on any metric. The special shift *previous* will show the time window preceding the currently selected one, spanning the same duration. For example, if *Last 7 days* is selected in the time filter, *previous* will show data from 14 days ago to 7 days ago. @@ -326,9 +326,32 @@ To compare current sales numbers with sales from a week ago, follow these steps: .. Click *Time shift* .. Click the *1 week* option. You can also define custom shifts by typing amount followed by time unit (like *1w* for a one week shift), then hit enter. - ++ [role="screenshot"] -image::images/lens_time_shift.png[Line chart with week-over-week sales comparison] +image::images/lens_time_shift.png[Line chart with week-over-week sales comparison, width=50%] + +. Click *Save and return*. + +[float] +[[compare-time-as-percent]] +==== Compare time ranges as a percent change + +To view the percent change in sales between the current time and the previous week, use a *Formula*: + +. Open *Lens*. + +. From the *Available fields* list, drag and drop *Records* to the visualization builder. + +. Click *Count of Records*, then click *Formula*. + +. Type `count() / count(shift='1w') - 1`. To learn more about the formula +syntax, click *Help*. + +. Click *Value format* and select *Percent* with 0 decimals. + +. In the *Display name* field, enter `Percent change`, then click *Close*. + +. Click *Save and return*. [discrete] [[view-customers-over-time-by-continents]] @@ -366,18 +389,14 @@ To split the customers count by continent: . From the *Available fields* list, drag and drop *geoip.continent_name* to the *Columns* field of the editor. + [role="screenshot"] -image::images/lens_advanced_6_1.png[Table with daily customers by continent configuration] +image::images/lens_advanced_6_1.png[Table with daily customers by continent configuration, width=50%] . Click *Save and return*. + [discrete] === Save the dashboard -By default the dashboard attempts to match the palette across panels, but in this case there's no need for that, so it can be disabled. - -[role="screenshot"] -image::images/lens_advanced_7_1.png[Disable palette sync in dashboard] - Now that you have a complete overview of your ecommerce sales data, save the dashboard. . In the toolbar, click *Save*. diff --git a/package.json b/package.json index ecedb64c343ec..ceb178d068519 100644 --- a/package.json +++ b/package.json @@ -128,25 +128,26 @@ "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics", "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader", "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils", + "@kbn/common-utils": "link:bazel-bin/packages/kbn-common-utils", "@kbn/config": "link:bazel-bin/packages/kbn-config", "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema", "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto", - "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", "@kbn/interpreter": "link:bazel-bin/packages/kbn-interpreter", "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", "@kbn/logging": "link:bazel-bin/packages/kbn-logging", + "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", "@kbn/rule-data-utils": "link:bazel-bin/packages/kbn-rule-data-utils", - "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", "@kbn/securitysolution-hook-utils": "link:bazel-bin/packages/kbn-securitysolution-hook-utils", - "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types", "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types", + "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils", "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api", + "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks", "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils", "@kbn/securitysolution-t-grid": "link:bazel-bin/packages/kbn-securitysolution-t-grid", @@ -158,7 +159,6 @@ "@kbn/ui-framework": "link:bazel-bin/packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:bazel-bin/packages/kbn-ui-shared-deps", "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", - "@kbn/common-utils": "link:bazel-bin/packages/kbn-common-utils", "@kbn/utils": "link:bazel-bin/packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", @@ -273,6 +273,7 @@ "jquery": "^3.5.0", "js-levenshtein": "^1.1.6", "js-search": "^1.4.3", + "js-sha256": "^0.9.0", "js-yaml": "^3.14.0", "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", diff --git a/packages/kbn-ui-shared-deps/src/entry.js b/packages/kbn-ui-shared-deps/src/entry.js index b8d21a473c65f..0e91c45ae6392 100644 --- a/packages/kbn-ui-shared-deps/src/entry.js +++ b/packages/kbn-ui-shared-deps/src/entry.js @@ -40,6 +40,7 @@ export const ElasticEui = require('@elastic/eui'); export const ElasticEuiLibServices = require('@elastic/eui/lib/services'); export const ElasticEuiLibServicesFormat = require('@elastic/eui/lib/services/format'); export const ElasticEuiChartsTheme = require('@elastic/eui/dist/eui_charts_theme'); +export const ReactBeautifulDnD = require('react-beautiful-dnd'); export const Theme = require('./theme.ts'); export const Lodash = require('lodash'); export const LodashFp = require('lodash/fp'); diff --git a/packages/kbn-ui-shared-deps/src/index.js b/packages/kbn-ui-shared-deps/src/index.js index c5853dc091875..36c2e6b02879e 100644 --- a/packages/kbn-ui-shared-deps/src/index.js +++ b/packages/kbn-ui-shared-deps/src/index.js @@ -85,6 +85,8 @@ exports.externals = { '@elastic/eui/dist/eui_charts_theme': '__kbnSharedDeps__.ElasticEuiChartsTheme', '@elastic/eui/dist/eui_theme_light.json': '__kbnSharedDeps__.Theme.euiLightVars', '@elastic/eui/dist/eui_theme_dark.json': '__kbnSharedDeps__.Theme.euiDarkVars', + // transient dep of eui + 'react-beautiful-dnd': '__kbnSharedDeps__.ReactBeautifulDnD', lodash: '__kbnSharedDeps__.Lodash', 'lodash/fp': '__kbnSharedDeps__.LodashFp', fflate: '__kbnSharedDeps__.Fflate', diff --git a/src/core/server/csp/config.test.ts b/src/core/server/csp/config.test.ts index 8036ebeeaad31..6db93addb7da8 100644 --- a/src/core/server/csp/config.test.ts +++ b/src/core/server/csp/config.test.ts @@ -9,11 +9,469 @@ import { config } from './config'; describe('config.validate()', () => { - test(`does not allow "disableEmbedding" to be set to true`, () => { + it(`does not allow "disableEmbedding" to be set to true`, () => { // This is intentionally not editable in the raw CSP config. // Users should set `server.securityResponseHeaders.disableEmbedding` to control this config property. expect(() => config.schema.validate({ disableEmbedding: true })).toThrowError( '[disableEmbedding]: expected value to equal [false]' ); }); + + describe(`"script_src"`, () => { + it(`throws if containing 'unsafe-inline' when 'strict' is true`, () => { + expect(() => + config.schema.validate({ + strict: true, + warnLegacyBrowsers: false, + script_src: [`'self'`, `unsafe-inline`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.strict\` is true"` + ); + + expect(() => + config.schema.validate({ + strict: true, + warnLegacyBrowsers: false, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.strict\` is true"` + ); + }); + + it(`throws if containing 'unsafe-inline' when 'warnLegacyBrowsers' is true`, () => { + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: true, + script_src: [`'self'`, `unsafe-inline`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.warnLegacyBrowsers\` is true"` + ); + + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: true, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"cannot use \`unsafe-inline\` for \`script_src\` when \`csp.warnLegacyBrowsers\` is true"` + ); + }); + + it(`does not throw if containing 'unsafe-inline' when 'strict' and 'warnLegacyBrowsers' are false`, () => { + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: false, + script_src: [`'self'`, `unsafe-inline`], + }) + ).not.toThrow(); + + expect(() => + config.schema.validate({ + strict: false, + warnLegacyBrowsers: false, + script_src: [`'self'`, `'unsafe-inline'`], + }) + ).not.toThrow(); + }); + + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + script_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + script_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + script_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + script_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[script_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"worker_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + worker_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + worker_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + worker_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + worker_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[worker_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"style_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + style_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + style_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + style_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + style_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[style_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"connect_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + connect_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + connect_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + connect_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + connect_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connect_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"default_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + default_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + default_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + default_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + default_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[default_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"font_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + font_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + font_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + font_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + font_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[font_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"frame_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + frame_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + frame_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + frame_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + frame_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"img_src"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + img_src: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + img_src: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + img_src: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + img_src: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[img_src]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); + + describe(`"frame_ancestors"`, () => { + it(`throws if 'rules' is also specified`, () => { + expect(() => + config.schema.validate({ + rules: [ + `script-src 'unsafe-eval' 'self'`, + `worker-src 'unsafe-eval' 'self'`, + `style-src 'unsafe-eval' 'self'`, + ], + frame_ancestors: [`'self'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\\"csp.rules\\" cannot be used when specifying per-directive additions such as \\"script_src\\", \\"worker_src\\" or \\"style_src\\""` + ); + }); + + it('throws if using an `nonce-*` value', () => { + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `nonce-foo`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"nonce-*\\" is considered insecure and is not allowed"` + ); + }); + it("throws if using `none` or `'none'`", () => { + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `none`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + + expect(() => + config.schema.validate({ + frame_ancestors: [`hello`, `'none'`], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[frame_ancestors]: using \\"none\\" would conflict with Kibana's default csp configuration and is not allowed"` + ); + }); + }); }); diff --git a/src/core/server/csp/config.ts b/src/core/server/csp/config.ts index a61fa1b03a45c..3a7cb20985cea 100644 --- a/src/core/server/csp/config.ts +++ b/src/core/server/csp/config.ts @@ -7,28 +7,150 @@ */ import { TypeOf, schema } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '../internal_types'; + +interface DirectiveValidationOptions { + allowNone: boolean; + allowNonce: boolean; +} + +const getDirectiveValidator = (options: DirectiveValidationOptions) => { + const validateValue = getDirectiveValueValidator(options); + return (values: string[]) => { + for (const value of values) { + const error = validateValue(value); + if (error) { + return error; + } + } + }; +}; + +const getDirectiveValueValidator = ({ allowNone, allowNonce }: DirectiveValidationOptions) => { + return (value: string) => { + if (!allowNonce && value.startsWith('nonce-')) { + return `using "nonce-*" is considered insecure and is not allowed`; + } + if (!allowNone && (value === `none` || value === `'none'`)) { + return `using "none" would conflict with Kibana's default csp configuration and is not allowed`; + } + }; +}; + +const configSchema = schema.object( + { + rules: schema.maybe(schema.arrayOf(schema.string())), + script_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + worker_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + style_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + connect_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + default_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + font_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + frame_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + img_src: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + frame_ancestors: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: false, allowNonce: false }), + }), + report_uri: schema.arrayOf(schema.string(), { + defaultValue: [], + validate: getDirectiveValidator({ allowNone: true, allowNonce: false }), + }), + report_to: schema.arrayOf(schema.string(), { + defaultValue: [], + }), + strict: schema.boolean({ defaultValue: true }), + warnLegacyBrowsers: schema.boolean({ defaultValue: true }), + disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }), + }, + { + validate: (cspConfig) => { + if (cspConfig.rules && hasDirectiveSpecified(cspConfig)) { + return `"csp.rules" cannot be used when specifying per-directive additions such as "script_src", "worker_src" or "style_src"`; + } + const hasUnsafeInlineScriptSrc = + cspConfig.script_src.includes(`unsafe-inline`) || + cspConfig.script_src.includes(`'unsafe-inline'`); + + if (cspConfig.strict && hasUnsafeInlineScriptSrc) { + return 'cannot use `unsafe-inline` for `script_src` when `csp.strict` is true'; + } + if (cspConfig.warnLegacyBrowsers && hasUnsafeInlineScriptSrc) { + return 'cannot use `unsafe-inline` for `script_src` when `csp.warnLegacyBrowsers` is true'; + } + }, + } +); + +const hasDirectiveSpecified = (rawConfig: CspConfigType): boolean => { + return Boolean( + rawConfig.script_src.length || + rawConfig.worker_src.length || + rawConfig.style_src.length || + rawConfig.connect_src.length || + rawConfig.default_src.length || + rawConfig.font_src.length || + rawConfig.frame_src.length || + rawConfig.img_src.length || + rawConfig.frame_ancestors.length || + rawConfig.report_uri.length || + rawConfig.report_to.length + ); +}; /** * @internal */ -export type CspConfigType = TypeOf; +export type CspConfigType = TypeOf; -export const config = { +export const config: ServiceConfigDescriptor = { // TODO: Move this to server.csp using config deprecations // ? https://github.com/elastic/kibana/pull/52251 path: 'csp', - schema: schema.object({ - rules: schema.arrayOf(schema.string(), { - defaultValue: [ - `script-src 'unsafe-eval' 'self'`, - `worker-src blob: 'self'`, - `style-src 'unsafe-inline' 'self'`, - ], - }), - strict: schema.boolean({ defaultValue: true }), - warnLegacyBrowsers: schema.boolean({ defaultValue: true }), - disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }), - }), + schema: configSchema, + deprecations: () => [ + (rawConfig, fromPath, addDeprecation) => { + const cspConfig = rawConfig[fromPath]; + if (cspConfig?.rules) { + addDeprecation({ + message: + '`csp.rules` is deprecated in favor of directive specific configuration. Please use `csp.connect_src`, ' + + '`csp.default_src`, `csp.font_src`, `csp.frame_ancestors`, `csp.frame_src`, `csp.img_src`, ' + + '`csp.report_uri`, `csp.report_to`, `csp.script_src`, `csp.style_src`, and `csp.worker_src` instead.', + correctiveActions: { + manualSteps: [ + `Remove "csp.rules" from the Kibana config file."`, + `Add directive specific configurations to the config file using "csp.connect_src", "csp.default_src", "csp.font_src", ` + + `"csp.frame_ancestors", "csp.frame_src", "csp.img_src", "csp.report_uri", "csp.report_to", "csp.script_src", ` + + `"csp.style_src", and "csp.worker_src".`, + ], + }, + }); + } + }, + ], }; - -export const FRAME_ANCESTORS_RULE = `frame-ancestors 'self'`; // only used by CspConfig when embedding is disabled diff --git a/src/core/server/csp/csp_config.test.ts b/src/core/server/csp/csp_config.test.ts index 1e023c6f08ea8..a1bac7d4ae73e 100644 --- a/src/core/server/csp/csp_config.test.ts +++ b/src/core/server/csp/csp_config.test.ts @@ -7,7 +7,7 @@ */ import { CspConfig } from './csp_config'; -import { FRAME_ANCESTORS_RULE } from './config'; +import { config as cspConfig, CspConfigType } from './config'; // CSP rules aren't strictly additive, so any change can potentially expand or // restrict the policy in a way we consider a breaking change. For that reason, @@ -23,6 +23,12 @@ import { FRAME_ANCESTORS_RULE } from './config'; // the nature of a change in defaults during a PR review. describe('CspConfig', () => { + let defaultConfig: CspConfigType; + + beforeEach(() => { + defaultConfig = cspConfig.schema.validate({}); + }); + test('DEFAULT', () => { expect(CspConfig.DEFAULT).toMatchInlineSnapshot(` CspConfig { @@ -40,50 +46,129 @@ describe('CspConfig', () => { }); test('defaults from config', () => { - expect(new CspConfig()).toEqual(CspConfig.DEFAULT); + expect(new CspConfig(defaultConfig)).toEqual(CspConfig.DEFAULT); }); describe('partial config', () => { test('allows "rules" to be set and changes header', () => { - const rules = ['foo', 'bar']; - const config = new CspConfig({ rules }); + const rules = [`foo 'self'`, `bar 'self'`]; + const config = new CspConfig({ ...defaultConfig, rules }); expect(config.rules).toEqual(rules); - expect(config.header).toMatchInlineSnapshot(`"foo; bar"`); + expect(config.header).toMatchInlineSnapshot(`"foo 'self'; bar 'self'"`); }); test('allows "strict" to be set', () => { - const config = new CspConfig({ strict: false }); + const config = new CspConfig({ ...defaultConfig, strict: false }); expect(config.strict).toEqual(false); expect(config.strict).not.toEqual(CspConfig.DEFAULT.strict); }); test('allows "warnLegacyBrowsers" to be set', () => { const warnLegacyBrowsers = false; - const config = new CspConfig({ warnLegacyBrowsers }); + const config = new CspConfig({ ...defaultConfig, warnLegacyBrowsers }); expect(config.warnLegacyBrowsers).toEqual(warnLegacyBrowsers); expect(config.warnLegacyBrowsers).not.toEqual(CspConfig.DEFAULT.warnLegacyBrowsers); }); + test('allows "worker_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + worker_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`worker-src foo bar`]); + expect(config.header).toEqual(`worker-src foo bar`); + }); + + test('allows "style_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + style_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`style-src foo bar`]); + expect(config.header).toEqual(`style-src foo bar`); + }); + + test('allows "script_src" to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + script_src: ['foo', 'bar'], + }); + expect(config.rules).toEqual([`script-src foo bar`]); + expect(config.header).toEqual(`script-src foo bar`); + }); + + test('allows all directives to be set and changes header', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: [], + script_src: ['script', 'foo'], + worker_src: ['worker', 'bar'], + style_src: ['style', 'dolly'], + }); + expect(config.rules).toEqual([ + `script-src script foo`, + `worker-src worker bar`, + `style-src style dolly`, + ]); + expect(config.header).toEqual( + `script-src script foo; worker-src worker bar; style-src style dolly` + ); + }); + + test('applies defaults when `rules` is undefined', () => { + const config = new CspConfig({ + ...defaultConfig, + rules: undefined, + script_src: ['script'], + worker_src: ['worker'], + style_src: ['style'], + }); + expect(config.rules).toEqual([ + `script-src 'unsafe-eval' 'self' script`, + `worker-src blob: 'self' worker`, + `style-src 'unsafe-inline' 'self' style`, + ]); + expect(config.header).toEqual( + `script-src 'unsafe-eval' 'self' script; worker-src blob: 'self' worker; style-src 'unsafe-inline' 'self' style` + ); + }); + describe('allows "disableEmbedding" to be set', () => { const disableEmbedding = true; test('and changes rules/header if custom rules are not defined', () => { - const config = new CspConfig({ disableEmbedding }); + const config = new CspConfig({ ...defaultConfig, disableEmbedding }); expect(config.disableEmbedding).toEqual(disableEmbedding); expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); - expect(config.rules).toEqual(expect.arrayContaining([FRAME_ANCESTORS_RULE])); + expect(config.rules).toEqual(expect.arrayContaining([`frame-ancestors 'self'`])); expect(config.header).toMatchInlineSnapshot( `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'"` ); }); test('and does not change rules/header if custom rules are defined', () => { - const rules = ['foo', 'bar']; - const config = new CspConfig({ disableEmbedding, rules }); + const rules = [`foo 'self'`, `bar 'self'`]; + const config = new CspConfig({ ...defaultConfig, disableEmbedding, rules }); expect(config.disableEmbedding).toEqual(disableEmbedding); expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); expect(config.rules).toEqual(rules); - expect(config.header).toMatchInlineSnapshot(`"foo; bar"`); + expect(config.header).toMatchInlineSnapshot(`"foo 'self'; bar 'self'"`); + }); + + test('and overrides `frame-ancestors` if set', () => { + const config = new CspConfig({ + ...defaultConfig, + disableEmbedding: true, + frame_ancestors: ['foo.com'], + }); + expect(config.disableEmbedding).toEqual(disableEmbedding); + expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding); + expect(config.header).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'"` + ); }); }); }); diff --git a/src/core/server/csp/csp_config.ts b/src/core/server/csp/csp_config.ts index 649c81576ef52..13778088d9df2 100644 --- a/src/core/server/csp/csp_config.ts +++ b/src/core/server/csp/csp_config.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { config, FRAME_ANCESTORS_RULE } from './config'; +import { config, CspConfigType } from './config'; +import { CspDirectives } from './csp_directives'; const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); @@ -50,8 +51,9 @@ export interface ICspConfig { * @public */ export class CspConfig implements ICspConfig { - static readonly DEFAULT = new CspConfig(); + static readonly DEFAULT = new CspConfig(DEFAULT_CONFIG); + readonly #directives: CspDirectives; public readonly rules: string[]; public readonly strict: boolean; public readonly warnLegacyBrowsers: boolean; @@ -62,16 +64,18 @@ export class CspConfig implements ICspConfig { * Returns the default CSP configuration when passed with no config * @internal */ - constructor(rawCspConfig: Partial> = {}) { - const source = { ...DEFAULT_CONFIG, ...rawCspConfig }; - - this.rules = [...source.rules]; - this.strict = source.strict; - this.warnLegacyBrowsers = source.warnLegacyBrowsers; - this.disableEmbedding = source.disableEmbedding; - if (!rawCspConfig.rules?.length && source.disableEmbedding) { - this.rules.push(FRAME_ANCESTORS_RULE); + constructor(rawCspConfig: CspConfigType) { + this.#directives = CspDirectives.fromConfig(rawCspConfig); + if (!rawCspConfig.rules?.length && rawCspConfig.disableEmbedding) { + this.#directives.clearDirectiveValues('frame-ancestors'); + this.#directives.addDirectiveValue('frame-ancestors', `'self'`); } - this.header = this.rules.join('; '); + + this.rules = this.#directives.getRules(); + this.header = this.#directives.getCspHeader(); + + this.strict = rawCspConfig.strict; + this.warnLegacyBrowsers = rawCspConfig.warnLegacyBrowsers; + this.disableEmbedding = rawCspConfig.disableEmbedding; } } diff --git a/src/core/server/csp/csp_directives.test.ts b/src/core/server/csp/csp_directives.test.ts new file mode 100644 index 0000000000000..1077b6ea9f3cd --- /dev/null +++ b/src/core/server/csp/csp_directives.test.ts @@ -0,0 +1,266 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CspDirectives } from './csp_directives'; +import { config as cspConfig } from './config'; + +describe('CspDirectives', () => { + describe('#addDirectiveValue', () => { + it('properly updates the rules', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo", + ] + `); + + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + ] + `); + }); + + it('properly updates the header', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo"`); + + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo bar"`); + }); + + it('handles distinct directives', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'bar'); + directives.addDirectiveValue('worker-src', 'dolly'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src foo bar; worker-src dolly"` + ); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + "worker-src dolly", + ] + `); + }); + + it('removes duplicates', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'foo'); + directives.addDirectiveValue('style-src', 'bar'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot(`"style-src foo bar"`); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "style-src foo bar", + ] + `); + }); + + it('automatically adds single quotes for keywords', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', 'none'); + directives.addDirectiveValue('style-src', 'self'); + directives.addDirectiveValue('style-src', 'strict-dynamic'); + directives.addDirectiveValue('style-src', 'report-sample'); + directives.addDirectiveValue('style-src', 'unsafe-inline'); + directives.addDirectiveValue('style-src', 'unsafe-eval'); + directives.addDirectiveValue('style-src', 'unsafe-hashes'); + directives.addDirectiveValue('style-src', 'unsafe-allow-redirects'); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src 'none' 'self' 'strict-dynamic' 'report-sample' 'unsafe-inline' 'unsafe-eval' 'unsafe-hashes' 'unsafe-allow-redirects'"` + ); + }); + + it('does not add single quotes for keywords when already present', () => { + const directives = new CspDirectives(); + directives.addDirectiveValue('style-src', `'none'`); + directives.addDirectiveValue('style-src', `'self'`); + directives.addDirectiveValue('style-src', `'strict-dynamic'`); + directives.addDirectiveValue('style-src', `'report-sample'`); + directives.addDirectiveValue('style-src', `'unsafe-inline'`); + directives.addDirectiveValue('style-src', `'unsafe-eval'`); + directives.addDirectiveValue('style-src', `'unsafe-hashes'`); + directives.addDirectiveValue('style-src', `'unsafe-allow-redirects'`); + + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"style-src 'none' 'self' 'strict-dynamic' 'report-sample' 'unsafe-inline' 'unsafe-eval' 'unsafe-hashes' 'unsafe-allow-redirects'"` + ); + }); + }); + + describe('#fromConfig', () => { + it('returns the correct rules for the default config', () => { + const config = cspConfig.schema.validate({}); + const directives = CspDirectives.fromConfig(config); + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + ] + `); + }); + + it('returns the correct header for the default config', () => { + const config = cspConfig.schema.validate({}); + const directives = CspDirectives.fromConfig(config); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'"` + ); + }); + + it('handles config with rules', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src 'self' http://foo.com`, `worker-src 'self'`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('adds single quotes for keyword for rules', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src self http://foo.com`, `worker-src self`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('handles multiple whitespaces when parsing rules', () => { + const config = cspConfig.schema.validate({ + rules: [` script-src 'self' http://foo.com `, ` worker-src 'self' `], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "worker-src 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; worker-src 'self'"` + ); + }); + + it('supports unregistered directives', () => { + const config = cspConfig.schema.validate({ + rules: [`script-src 'self' http://foo.com`, `img-src 'self'`, 'foo bar'], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'self' http://foo.com", + "img-src 'self'", + "foo bar", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'self' http://foo.com; img-src 'self'; foo bar"` + ); + }); + + it('adds default value for config with directives', () => { + const config = cspConfig.schema.validate({ + script_src: [`baz`], + worker_src: [`foo`], + style_src: [`bar`, `dolly`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self' baz", + "worker-src blob: 'self' foo", + "style-src 'unsafe-inline' 'self' bar dolly", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self' baz; worker-src blob: 'self' foo; style-src 'unsafe-inline' 'self' bar dolly"` + ); + }); + + it('adds additional values for some directives without defaults', () => { + const config = cspConfig.schema.validate({ + connect_src: [`connect-src`], + default_src: [`default-src`], + font_src: [`font-src`], + frame_src: [`frame-src`], + img_src: [`img-src`], + frame_ancestors: [`frame-ancestors`], + report_uri: [`report-uri`], + report_to: [`report-to`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + "connect-src 'self' connect-src", + "default-src 'self' default-src", + "font-src 'self' font-src", + "frame-src 'self' frame-src", + "img-src 'self' img-src", + "frame-ancestors 'self' frame-ancestors", + "report-uri report-uri", + "report-to report-to", + ] + `); + }); + + it('adds single quotes for keywords in added directives', () => { + const config = cspConfig.schema.validate({ + script_src: [`unsafe-hashes`], + }); + const directives = CspDirectives.fromConfig(config); + + expect(directives.getRules()).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self' 'unsafe-hashes'", + "worker-src blob: 'self'", + "style-src 'unsafe-inline' 'self'", + ] + `); + expect(directives.getCspHeader()).toMatchInlineSnapshot( + `"script-src 'unsafe-eval' 'self' 'unsafe-hashes'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'"` + ); + }); + }); +}); diff --git a/src/core/server/csp/csp_directives.ts b/src/core/server/csp/csp_directives.ts new file mode 100644 index 0000000000000..9e3b60f7f1e4f --- /dev/null +++ b/src/core/server/csp/csp_directives.ts @@ -0,0 +1,159 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CspConfigType } from './config'; + +export type CspDirectiveName = + | 'script-src' + | 'worker-src' + | 'style-src' + | 'frame-ancestors' + | 'connect-src' + | 'default-src' + | 'font-src' + | 'frame-src' + | 'img-src' + | 'report-uri' + | 'report-to'; + +/** + * The default rules that are always applied + */ +export const defaultRules: Partial> = { + 'script-src': [`'unsafe-eval'`, `'self'`], + 'worker-src': [`blob:`, `'self'`], + 'style-src': [`'unsafe-inline'`, `'self'`], +}; + +/** + * Per-directive rules that will be added when the configuration contains at least one value + * Main purpose is to add `self` value to some directives when the configuration specifies other values + */ +export const additionalRules: Partial> = { + 'connect-src': [`'self'`], + 'default-src': [`'self'`], + 'font-src': [`'self'`], + 'img-src': [`'self'`], + 'frame-ancestors': [`'self'`], + 'frame-src': [`'self'`], +}; + +export class CspDirectives { + private readonly directives = new Map>(); + + addDirectiveValue(directiveName: CspDirectiveName, directiveValue: string) { + if (!this.directives.has(directiveName)) { + this.directives.set(directiveName, new Set()); + } + this.directives.get(directiveName)!.add(normalizeDirectiveValue(directiveValue)); + } + + clearDirectiveValues(directiveName: CspDirectiveName) { + this.directives.delete(directiveName); + } + + getCspHeader() { + return this.getRules().join('; '); + } + + getRules() { + return [...this.directives.entries()].map(([name, values]) => { + return [name, ...values].join(' '); + }); + } + + static fromConfig(config: CspConfigType): CspDirectives { + const cspDirectives = new CspDirectives(); + + // adding `csp.rules` or `default` rules + const initialRules = config.rules ? parseRules(config.rules) : { ...defaultRules }; + Object.entries(initialRules).forEach(([key, values]) => { + values?.forEach((value) => { + cspDirectives.addDirectiveValue(key as CspDirectiveName, value); + }); + }); + + // adding per-directive configuration + const additiveConfig = parseConfigDirectives(config); + [...additiveConfig.entries()].forEach(([directiveName, directiveValues]) => { + const additionalValues = additionalRules[directiveName] ?? []; + [...additionalValues, ...directiveValues].forEach((value) => { + cspDirectives.addDirectiveValue(directiveName, value); + }); + }); + + return cspDirectives; + } +} + +const parseRules = (rules: string[]): Partial> => { + const directives: Partial> = {}; + rules.forEach((rule) => { + const [name, ...values] = rule.replace(/\s+/g, ' ').trim().split(' '); + directives[name as CspDirectiveName] = values; + }); + return directives; +}; + +const parseConfigDirectives = (cspConfig: CspConfigType): Map => { + const map = new Map(); + + if (cspConfig.script_src?.length) { + map.set('script-src', cspConfig.script_src); + } + if (cspConfig.worker_src?.length) { + map.set('worker-src', cspConfig.worker_src); + } + if (cspConfig.style_src?.length) { + map.set('style-src', cspConfig.style_src); + } + if (cspConfig.connect_src?.length) { + map.set('connect-src', cspConfig.connect_src); + } + if (cspConfig.default_src?.length) { + map.set('default-src', cspConfig.default_src); + } + if (cspConfig.font_src?.length) { + map.set('font-src', cspConfig.font_src); + } + if (cspConfig.frame_src?.length) { + map.set('frame-src', cspConfig.frame_src); + } + if (cspConfig.img_src?.length) { + map.set('img-src', cspConfig.img_src); + } + if (cspConfig.frame_ancestors?.length) { + map.set('frame-ancestors', cspConfig.frame_ancestors); + } + if (cspConfig.report_uri?.length) { + map.set('report-uri', cspConfig.report_uri); + } + if (cspConfig.report_to?.length) { + map.set('report-to', cspConfig.report_to); + } + + return map; +}; + +const keywordTokens = [ + 'none', + 'self', + 'strict-dynamic', + 'report-sample', + 'unsafe-inline', + 'unsafe-eval', + 'unsafe-hashes', + 'unsafe-allow-redirects', +]; + +function normalizeDirectiveValue(value: string) { + if (keywordTokens.includes(value)) { + return `'${value}'`; + } + return value; +} diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index c802163866423..55af02a08561b 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -69,7 +69,11 @@ configService.atPath.mockImplementation((path) => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 56095336d970b..06a4745632233 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -8,7 +8,7 @@ import uuid from 'uuid'; import { config, HttpConfig } from './http_config'; -import { CspConfig } from '../csp'; +import { config as cspConfig } from '../csp'; import { ExternalUrlConfig } from '../external_url'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost', '0.0.0.0']; @@ -459,7 +459,8 @@ describe('HttpConfig', () => { }, }, }); - const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT, ExternalUrlConfig.DEFAULT); + const rawCspConfig = cspConfig.schema.validate({}); + const httpConfig = new HttpConfig(rawConfig, rawCspConfig, ExternalUrlConfig.DEFAULT); expect(httpConfig.customResponseHeaders).toEqual({ string: 'string', diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index cbd300fdc9c09..c2023c5577d61 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -79,7 +79,11 @@ describe('core lifecycle handlers', () => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index b3180b43d0026..4e1a88e967f8f 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -56,7 +56,11 @@ configService.atPath.mockImplementation((path) => { } as any); } if (path === 'csp') { - return new BehaviorSubject({} as any); + return new BehaviorSubject({ + strict: false, + disableEmbedding: false, + warnLegacyBrowsers: true, + }); } throw new Error(`Unexpected config path: ${path}`); }); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index fcecf39f7e53a..3bc0b54635eb5 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -777,8 +777,12 @@ export interface CountResponse { // @public export class CspConfig implements ICspConfig { + // (undocumented) + #private; + // Warning: (ae-forgotten-export) The symbol "CspConfigType" needs to be exported by the entry point index.d.ts + // // @internal - constructor(rawCspConfig?: Partial>); + constructor(rawCspConfig: CspConfigType); // (undocumented) static readonly DEFAULT: CspConfig; // (undocumented) 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 d109a824ca81d..a224793bace3f 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 @@ -31,6 +31,17 @@ kibana_vars=( csp.rules csp.strict csp.warnLegacyBrowsers + csp.script_src + csp.worker_src + csp.style_src + csp.connect_src + csp.default_src + csp.font_src + csp.frame_src + csp.img_src + csp.frame_ancestors + csp.report_uri + csp.report_to data.autocomplete.valueSuggestions.terminateAfter data.autocomplete.valueSuggestions.timeout elasticsearch.customHeaders @@ -379,7 +390,8 @@ kibana_vars=( xpack.task_manager.monitored_aggregated_stats_refresh_rate xpack.task_manager.monitored_stats_required_freshness xpack.task_manager.monitored_stats_running_average_window - xpack.task_manager.monitored_stats_warn_delayed_task_start_in_seconds + xpack.task_manager.monitored_stats_health_verbose_log.enabled + xpack.task_manager.monitored_stats_health_verbose_log.warn_delayed_task_start_in_seconds xpack.task_manager.monitored_task_execution_thresholds xpack.task_manager.poll_interval xpack.task_manager.request_capacity diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index c584b44286e07..ff7708689c221 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -22,11 +22,14 @@ export { DashboardUrlGenerator, DashboardFeatureFlagConfig, } from './plugin'; + export { DASHBOARD_APP_URL_GENERATOR, createDashboardUrlGenerator, DashboardUrlGeneratorState, } from './url_generator'; +export { DashboardAppLocator, DashboardAppLocatorParams } from './locator'; + export { DashboardSavedObject } from './saved_dashboards'; export { SavedDashboardPanel, DashboardContainerInput } from './types'; diff --git a/src/plugins/dashboard/public/locator.test.ts b/src/plugins/dashboard/public/locator.test.ts new file mode 100644 index 0000000000000..0b647ac00ce31 --- /dev/null +++ b/src/plugins/dashboard/public/locator.test.ts @@ -0,0 +1,323 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DashboardAppLocatorDefinition } from './locator'; +import { hashedItemStore } from '../../kibana_utils/public'; +import { mockStorage } from '../../kibana_utils/public/storage/hashed_item_store/mock'; +import { esFilters } from '../../data/public'; + +describe('dashboard locator', () => { + beforeEach(() => { + // @ts-ignore + hashedItemStore.storage = mockStorage; + }); + + test('creates a link to a saved dashboard', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({}); + + expect(location).toMatchObject({ + app: 'dashboards', + path: '#/create?_a=()&_g=()', + state: {}, + }); + }); + + test('creates a link with global time range set up', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: '#/create?_a=()&_g=(time:(from:now-15m,mode:relative,to:now))', + state: {}, + }); + }); + + test('creates a link with filters, time range, refresh interval and query to a saved object', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + refreshInterval: { pause: false, value: 300 }, + dashboardId: '123', + filters: [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'hi' }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'hi' }, + $state: { + store: esFilters.FilterStateStore.GLOBAL_STATE, + }, + }, + ], + query: { query: 'bye', language: 'kuery' }, + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: `#/view/123?_a=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:hi))),query:(language:kuery,query:bye))&_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:hi))),refreshInterval:(pause:!f,value:300),time:(from:now-15m,mode:relative,to:now))`, + state: {}, + }); + }); + + test('searchSessionId', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + refreshInterval: { pause: false, value: 300 }, + dashboardId: '123', + filters: [], + query: { query: 'bye', language: 'kuery' }, + searchSessionId: '__sessionSearchId__', + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: `#/view/123?_a=(filters:!(),query:(language:kuery,query:bye))&_g=(filters:!(),refreshInterval:(pause:!f,value:300),time:(from:now-15m,mode:relative,to:now))&searchSessionId=__sessionSearchId__`, + state: {}, + }); + }); + + test('savedQuery', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + savedQuery: '__savedQueryId__', + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: `#/create?_a=(savedQuery:__savedQueryId__)&_g=()`, + state: {}, + }); + expect(location.path).toContain('__savedQueryId__'); + }); + + test('panels', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + panels: [{ fakePanelContent: 'fakePanelContent' }] as any, + }); + + expect(location).toMatchObject({ + app: 'dashboards', + path: `#/create?_a=(panels:!((fakePanelContent:fakePanelContent)))&_g=()`, + state: {}, + }); + }); + + test('if no useHash setting is given, uses the one was start services', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: true, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + + expect(location.path.indexOf('relative')).toBe(-1); + }); + + test('can override a false useHash ui setting', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + useHash: true, + }); + + expect(location.path.indexOf('relative')).toBe(-1); + }); + + test('can override a true useHash ui setting', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: true, + getDashboardFilterFields: async (dashboardId: string) => [], + }); + const location = await definition.getLocation({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + useHash: false, + }); + + expect(location.path.indexOf('relative')).toBeGreaterThan(1); + }); + + describe('preserving saved filters', () => { + const savedFilter1 = { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'savedfilter1' }, + }; + + const savedFilter2 = { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'savedfilter2' }, + }; + + const appliedFilter = { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'appliedfilter' }, + }; + + test('attaches filters from destination dashboard', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + return dashboardId === 'dashboard1' + ? [savedFilter1] + : dashboardId === 'dashboard2' + ? [savedFilter2] + : []; + }, + }); + + const location1 = await definition.getLocation({ + dashboardId: 'dashboard1', + filters: [appliedFilter], + }); + + expect(location1.path).toEqual(expect.stringContaining('query:savedfilter1')); + expect(location1.path).toEqual(expect.stringContaining('query:appliedfilter')); + + const location2 = await definition.getLocation({ + dashboardId: 'dashboard2', + filters: [appliedFilter], + }); + + expect(location2.path).toEqual(expect.stringContaining('query:savedfilter2')); + expect(location2.path).toEqual(expect.stringContaining('query:appliedfilter')); + }); + + test("doesn't fail if can't retrieve filters from destination dashboard", async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + if (dashboardId === 'dashboard1') { + throw new Error('Not found'); + } + return []; + }, + }); + + const location = await definition.getLocation({ + dashboardId: 'dashboard1', + filters: [appliedFilter], + }); + + expect(location.path).not.toEqual(expect.stringContaining('query:savedfilter1')); + expect(location.path).toEqual(expect.stringContaining('query:appliedfilter')); + }); + + test('can enforce empty filters', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + if (dashboardId === 'dashboard1') { + return [savedFilter1]; + } + return []; + }, + }); + + const location = await definition.getLocation({ + dashboardId: 'dashboard1', + filters: [], + preserveSavedFilters: false, + }); + + expect(location.path).not.toEqual(expect.stringContaining('query:savedfilter1')); + expect(location.path).not.toEqual(expect.stringContaining('query:appliedfilter')); + expect(location.path).toMatchInlineSnapshot( + `"#/view/dashboard1?_a=(filters:!())&_g=(filters:!())"` + ); + }); + + test('no filters in result url if no filters applied', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + if (dashboardId === 'dashboard1') { + return [savedFilter1]; + } + return []; + }, + }); + + const location = await definition.getLocation({ + dashboardId: 'dashboard1', + }); + + expect(location.path).not.toEqual(expect.stringContaining('filters')); + expect(location.path).toMatchInlineSnapshot(`"#/view/dashboard1?_a=()&_g=()"`); + }); + + test('can turn off preserving filters', async () => { + const definition = new DashboardAppLocatorDefinition({ + useHashedUrl: false, + getDashboardFilterFields: async (dashboardId: string) => { + if (dashboardId === 'dashboard1') { + return [savedFilter1]; + } + return []; + }, + }); + + const location = await definition.getLocation({ + dashboardId: 'dashboard1', + filters: [appliedFilter], + preserveSavedFilters: false, + }); + + expect(location.path).not.toEqual(expect.stringContaining('query:savedfilter1')); + expect(location.path).toEqual(expect.stringContaining('query:appliedfilter')); + }); + }); +}); diff --git a/src/plugins/dashboard/public/locator.ts b/src/plugins/dashboard/public/locator.ts new file mode 100644 index 0000000000000..e154351819ee9 --- /dev/null +++ b/src/plugins/dashboard/public/locator.ts @@ -0,0 +1,160 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SerializableState } from 'src/plugins/kibana_utils/common'; +import type { TimeRange, Filter, Query, QueryState, RefreshInterval } from '../../data/public'; +import type { LocatorDefinition, LocatorPublic } from '../../share/public'; +import type { SavedDashboardPanel } from '../common/types'; +import { esFilters } from '../../data/public'; +import { setStateToKbnUrl } from '../../kibana_utils/public'; +import { ViewMode } from '../../embeddable/public'; +import { DashboardConstants } from './dashboard_constants'; + +const cleanEmptyKeys = (stateObj: Record) => { + Object.keys(stateObj).forEach((key) => { + if (stateObj[key] === undefined) { + delete stateObj[key]; + } + }); + return stateObj; +}; + +export const DASHBOARD_APP_LOCATOR = 'DASHBOARD_APP_LOCATOR'; + +export interface DashboardAppLocatorParams extends SerializableState { + /** + * If given, the dashboard saved object with this id will be loaded. If not given, + * a new, unsaved dashboard will be loaded up. + */ + dashboardId?: string; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval & SerializableState; + + /** + * Optionally apply filers. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has filters saved with it, this will _replace_ those filters. + */ + filters?: Filter[]; + /** + * Optionally set a query. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has a query saved with it, this will _replace_ that query. + */ + query?: Query; + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; + + /** + * When `true` filters from saved filters from destination dashboard as merged with applied filters + * When `false` applied filters take precedence and override saved filters + * + * true is default + */ + preserveSavedFilters?: boolean; + + /** + * View mode of the dashboard. + */ + viewMode?: ViewMode; + + /** + * Search search session ID to restore. + * (Background search) + */ + searchSessionId?: string; + + /** + * List of dashboard panels + */ + panels?: SavedDashboardPanel[] & SerializableState; + + /** + * Saved query ID + */ + savedQuery?: string; +} + +export type DashboardAppLocator = LocatorPublic; + +export interface DashboardAppLocatorDependencies { + useHashedUrl: boolean; + getDashboardFilterFields: (dashboardId: string) => Promise; +} + +export class DashboardAppLocatorDefinition implements LocatorDefinition { + public readonly id = DASHBOARD_APP_LOCATOR; + + constructor(protected readonly deps: DashboardAppLocatorDependencies) {} + + public readonly getLocation = async (params: DashboardAppLocatorParams) => { + const useHash = params.useHash ?? this.deps.useHashedUrl; + const hash = params.dashboardId ? `view/${params.dashboardId}` : `create`; + + const getSavedFiltersFromDestinationDashboardIfNeeded = async (): Promise => { + if (params.preserveSavedFilters === false) return []; + if (!params.dashboardId) return []; + try { + return await this.deps.getDashboardFilterFields(params.dashboardId); + } catch (e) { + // In case dashboard is missing, build the url without those filters. + // The Dashboard app will handle redirect to landing page with a toast message. + return []; + } + }; + + // leave filters `undefined` if no filters was applied + // in this case dashboard will restore saved filters on its own + const filters = params.filters && [ + ...(await getSavedFiltersFromDestinationDashboardIfNeeded()), + ...params.filters, + ]; + + let path = setStateToKbnUrl( + '_a', + cleanEmptyKeys({ + query: params.query, + filters: filters?.filter((f) => !esFilters.isFilterPinned(f)), + viewMode: params.viewMode, + panels: params.panels, + savedQuery: params.savedQuery, + }), + { useHash }, + `#/${hash}` + ); + + path = setStateToKbnUrl( + '_g', + cleanEmptyKeys({ + time: params.timeRange, + filters: filters?.filter((f) => esFilters.isFilterPinned(f)), + refreshInterval: params.refreshInterval, + }), + { useHash }, + path + ); + + if (params.searchSessionId) { + path = `${path}&${DashboardConstants.SEARCH_SESSION_ID}=${params.searchSessionId}`; + } + + return { + app: DashboardConstants.DASHBOARDS_ID, + path, + state: {}, + }; + }; +} diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index b5d6eda71ca4a..53a8e90a8c35c 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -72,6 +72,7 @@ import { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGeneratorState, } from './url_generator'; +import { DashboardAppLocatorDefinition, DashboardAppLocator } from './locator'; import { createSavedDashboardLoader } from './saved_dashboards'; import { DashboardConstants } from './dashboard_constants'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; @@ -121,14 +122,25 @@ export interface DashboardStartDependencies { visualizations: VisualizationsStart; } -export type DashboardSetup = void; +export interface DashboardSetup { + locator?: DashboardAppLocator; +} export interface DashboardStart { getSavedDashboardLoader: () => SavedObjectLoader; getDashboardContainerByValueRenderer: () => ReturnType< typeof createDashboardContainerByValueRenderer >; + /** + * @deprecated Use dashboard locator instead. Dashboard locator is available + * under `.locator` key. This dashboard URL generator will be removed soon. + * + * ```ts + * plugins.dashboard.locator.getLocation({ ... }); + * ``` + */ dashboardUrlGenerator?: DashboardUrlGenerator; + locator?: DashboardAppLocator; dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; } @@ -142,7 +154,11 @@ export class DashboardPlugin private currentHistory: ScopedHistory | undefined = undefined; private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig; + /** + * @deprecated Use locator instead. + */ private dashboardUrlGenerator?: DashboardUrlGenerator; + private locator?: DashboardAppLocator; public setup( core: CoreSetup, @@ -205,6 +221,19 @@ export class DashboardPlugin }; }; + if (share) { + this.locator = share.url.locators.create( + new DashboardAppLocatorDefinition({ + useHashedUrl: core.uiSettings.get('state:storeInSessionStorage'), + getDashboardFilterFields: async (dashboardId: string) => { + const [, , selfStart] = await core.getStartServices(); + const dashboard = await selfStart.getSavedDashboardLoader().get(dashboardId); + return dashboard?.searchSource?.getField('filter') ?? []; + }, + }) + ); + } + const { appMounted, appUnMounted, @@ -333,6 +362,10 @@ export class DashboardPlugin order: 100, }); } + + return { + locator: this.locator, + }; } public start(core: CoreStart, plugins: DashboardStartDependencies): DashboardStart { @@ -417,6 +450,7 @@ export class DashboardPlugin }); }, dashboardUrlGenerator: this.dashboardUrlGenerator, + locator: this.locator, dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!, }; } diff --git a/src/plugins/dashboard/public/url_generator.ts b/src/plugins/dashboard/public/url_generator.ts index 58036ef70fa4a..5c0cd32ee5a16 100644 --- a/src/plugins/dashboard/public/url_generator.ts +++ b/src/plugins/dashboard/public/url_generator.ts @@ -26,6 +26,9 @@ export const GLOBAL_STATE_STORAGE_KEY = '_g'; export const DASHBOARD_APP_URL_GENERATOR = 'DASHBOARD_APP_URL_GENERATOR'; +/** + * @deprecated Use dashboard locator instead. + */ export interface DashboardUrlGeneratorState { /** * If given, the dashboard saved object with this id will be loaded. If not given, @@ -88,6 +91,9 @@ export interface DashboardUrlGeneratorState { savedQuery?: string; } +/** + * @deprecated Use dashboard locator instead. + */ export const createDashboardUrlGenerator = ( getStartServices: () => Promise<{ appBasePath: string; diff --git a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx index 210313aac5366..f1967d5b10b3e 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx +++ b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import moment from 'moment'; import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { IUiSettingsClient } from 'kibana/public'; @@ -47,8 +47,21 @@ export function DiscoverChart({ stateContainer: GetStateReturn; timefield?: string; }) { + const chartRef = useRef<{ element: HTMLElement | null; moveFocus: boolean }>({ + element: null, + moveFocus: false, + }); + + useEffect(() => { + if (chartRef.current.moveFocus && chartRef.current.element) { + chartRef.current.element.focus(); + } + }, [state.hideChart]); + const toggleHideChart = useCallback(() => { - stateContainer.setAppState({ hideChart: !state.hideChart }); + const newHideChart = !state.hideChart; + stateContainer.setAppState({ hideChart: newHideChart }); + chartRef.current.moveFocus = !newHideChart; }, [state, stateContainer]); const onChangeInterval = useCallback( @@ -102,9 +115,7 @@ export function DiscoverChart({ { - toggleHideChart(); - }} + onClick={toggleHideChart} data-test-subj="discoverChartToggle" > {!state.hideChart @@ -122,6 +133,8 @@ export function DiscoverChart({ {!state.hideChart && chartData && (
(chartRef.current.element = element)} + tabIndex={-1} aria-label={i18n.translate('discover.histogramOfFoundDocumentsAriaLabel', { defaultMessage: 'Histogram of found documents', })} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_field_details_footer.test.tsx.snap b/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_field_details_footer.test.tsx.snap deleted file mode 100644 index f976b961d8520..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/__snapshots__/discover_field_details_footer.test.tsx.snap +++ /dev/null @@ -1,705 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`discover sidebar field details footer renders properly 1`] = ` - - -
- -
- -
- - - -
-
-
-
-
-
-
-`; diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx index 26a3c482e9d3c..301866c762fbd 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx @@ -8,7 +8,7 @@ import './discover_field.scss'; -import React, { useState, useCallback, memo } from 'react'; +import React, { useState, useCallback, memo, useMemo } from 'react'; import { EuiPopover, EuiPopoverTitle, @@ -18,6 +18,7 @@ import { EuiIcon, EuiFlexGroup, EuiFlexItem, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UiCounterMetricType } from '@kbn/analytics'; @@ -27,7 +28,7 @@ import { FieldIcon, FieldButton } from '../../../../../../../kibana_react/public import { FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../../../data/public'; import { getFieldTypeName } from './lib/get_field_type_name'; -import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; +import { DiscoverFieldVisualize } from './discover_field_visualize'; function wrapOnDot(str?: string) { // u200B is a non-width white-space character, which allows @@ -172,6 +173,7 @@ const MultiFields: React.FC = memo( })} + {multiFields.map((entry) => ( multiFields?.map((f) => f.field), [multiFields]); + if (field.type === '_source') { return ( {multiFields && ( - - )} - {!details.error && ( - + <> + + + )} + )} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.scss b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.scss deleted file mode 100644 index ca48d67f75dec..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.scss +++ /dev/null @@ -1,10 +0,0 @@ -.dscFieldDetails { - color: $euiTextColor; - margin-bottom: $euiSizeS; -} - -.dscFieldDetails__visualizeBtn { - @include euiFontSizeXS; - height: $euiSizeL !important; - min-width: $euiSize * 4; -} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.test.tsx index a798abb60b833..8c9ad5bc9708a 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.test.tsx @@ -25,10 +25,11 @@ const indexPattern = getStubIndexPattern( ); describe('discover sidebar field details', function () { + const onAddFilter = jest.fn(); const defaultProps = { indexPattern, details: { buckets: [], error: '', exists: 1, total: 2, columns: [] }, - onAddFilter: jest.fn(), + onAddFilter, }; function mountComponent(field: IndexPatternField) { @@ -36,7 +37,7 @@ describe('discover sidebar field details', function () { return mountWithIntl(); } - it('should enable the visualize link for a number field', function () { + it('click on addFilter calls the function', function () { const visualizableField = new IndexPatternField({ name: 'bytes', type: 'number', @@ -47,37 +48,9 @@ describe('discover sidebar field details', function () { aggregatable: true, readFromDocValues: true, }); - const comp = mountComponent(visualizableField); - expect(findTestSubject(comp, 'fieldVisualize-bytes')).toBeTruthy(); - }); - - it('should disable the visualize link for an _id field', function () { - const conflictField = new IndexPatternField({ - name: '_id', - type: 'string', - esTypes: ['_id'], - count: 0, - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }); - const comp = mountComponent(conflictField); - expect(findTestSubject(comp, 'fieldVisualize-_id')).toEqual({}); - }); - - it('should disable the visualize link for an unknown field', function () { - const unknownField = new IndexPatternField({ - name: 'test', - type: 'unknown', - esTypes: ['double'], - count: 0, - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }); - const comp = mountComponent(unknownField); - expect(findTestSubject(comp, 'fieldVisualize-test')).toEqual({}); + const component = mountComponent(visualizableField); + const onAddButton = findTestSubject(component, 'onAddFilterButton'); + onAddButton.simulate('click'); + expect(onAddFilter).toHaveBeenCalledWith('_exists_', visualizableField.name, '+'); }); }); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx index ffa7b30de5280..e29799b720e21 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details.tsx @@ -6,27 +6,18 @@ * Side Public License, v 1. */ -import React, { useState, useEffect } from 'react'; -import { EuiIconTip, EuiText, EuiButton, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { EuiText, EuiSpacer, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; import { DiscoverFieldBucket } from './discover_field_bucket'; -import { getWarnings } from './lib/get_warnings'; -import { - triggerVisualizeActions, - isFieldVisualizable, - getVisualizeHref, -} from './lib/visualize_trigger_utils'; import { Bucket, FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../../../data/public'; -import './discover_field_details.scss'; interface DiscoverFieldDetailsProps { field: IndexPatternField; indexPattern: IndexPattern; details: FieldDetails; onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; - trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; } export function DiscoverFieldDetails({ @@ -34,46 +25,12 @@ export function DiscoverFieldDetails({ indexPattern, details, onAddFilter, - trackUiMetric, }: DiscoverFieldDetailsProps) { - const warnings = getWarnings(field); - const [showVisualizeLink, setShowVisualizeLink] = useState(false); - const [visualizeLink, setVisualizeLink] = useState(''); - - useEffect(() => { - isFieldVisualizable(field, indexPattern.id, details.columns).then( - (flag) => { - setShowVisualizeLink(flag); - // get href only if Visualize button is enabled - getVisualizeHref(field, indexPattern.id, details.columns).then( - (uri) => { - if (uri) setVisualizeLink(uri); - }, - () => { - setVisualizeLink(''); - } - ); - }, - () => { - setShowVisualizeLink(false); - } - ); - }, [field, indexPattern.id, details.columns]); - - const handleVisualizeLinkClick = (event: React.MouseEvent) => { - // regular link click. let the uiActions code handle the navigation and show popup if needed - event.preventDefault(); - if (trackUiMetric) { - trackUiMetric(METRIC_TYPE.CLICK, 'visualize_link_click'); - } - triggerVisualizeActions(field, indexPattern.id, details.columns); - }; - return ( <> -
- {details.error && {details.error}} - {!details.error && ( + {details.error && {details.error}} + {!details.error && ( + <>
{details.buckets.map((bucket: Bucket, idx: number) => ( ))}
- )} - - {showVisualizeLink && ( - <> - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - handleVisualizeLinkClick(e)} - href={visualizeLink} - size="s" - className="dscFieldDetails__visualizeBtn" - data-test-subj={`fieldVisualize-${field.name}`} - > + + + {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( + onAddFilter('_exists_', field.name, '+')} + data-test-subj="onAddFilterButton" + > + + + ) : ( - - {warnings.length > 0 && ( - )} - - )} -
+ + + )} ); } diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.test.tsx deleted file mode 100644 index aa93b2a663736..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.test.tsx +++ /dev/null @@ -1,71 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { findTestSubject } from '@elastic/eui/lib/test'; -import { mountWithIntl } from '@kbn/test/jest'; -// @ts-expect-error -import stubbedLogstashFields from '../../../../../__fixtures__/logstash_fields'; -import { coreMock } from '../../../../../../../../core/public/mocks'; -import { IndexPatternField } from '../../../../../../../data/public'; -import { getStubIndexPattern } from '../../../../../../../data/public/test_utils'; -import { DiscoverFieldDetailsFooter } from './discover_field_details_footer'; - -const indexPattern = getStubIndexPattern( - 'logstash-*', - (cfg: unknown) => cfg, - 'time', - stubbedLogstashFields(), - coreMock.createSetup() -); - -describe('discover sidebar field details footer', function () { - const onAddFilter = jest.fn(); - const defaultProps = { - indexPattern, - details: { buckets: [], error: '', exists: 1, total: 2, columns: [] }, - onAddFilter, - }; - - function mountComponent(field: IndexPatternField) { - const compProps = { ...defaultProps, field }; - return mountWithIntl(); - } - - it('renders properly', function () { - const visualizableField = new IndexPatternField({ - name: 'bytes', - type: 'number', - esTypes: ['long'], - count: 10, - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }); - const component = mountComponent(visualizableField); - expect(component).toMatchSnapshot(); - }); - - it('click on addFilter calls the function', function () { - const visualizableField = new IndexPatternField({ - name: 'bytes', - type: 'number', - esTypes: ['long'], - count: 10, - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }); - const component = mountComponent(visualizableField); - const onAddButton = findTestSubject(component, 'onAddFilterButton'); - onAddButton.simulate('click'); - expect(onAddFilter).toHaveBeenCalledWith('_exists_', visualizableField.name, '+'); - }); -}); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.tsx deleted file mode 100644 index 148dfc67c3e41..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_details_footer.tsx +++ /dev/null @@ -1,59 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EuiLink, EuiPopoverFooter, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { IndexPatternField } from '../../../../../../../data/common/index_patterns/fields'; -import { IndexPattern } from '../../../../../../../data/common/index_patterns/index_patterns'; -import { FieldDetails } from './types'; - -interface DiscoverFieldDetailsFooterProps { - field: IndexPatternField; - indexPattern: IndexPattern; - details: FieldDetails; - onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; -} - -export function DiscoverFieldDetailsFooter({ - field, - indexPattern, - details, - onAddFilter, -}: DiscoverFieldDetailsFooterProps) { - return ( - - - {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( - onAddFilter('_exists_', field.name, '+')} - data-test-subj="onAddFilterButton" - > - - - ) : ( - - )} - - - ); -} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx new file mode 100644 index 0000000000000..baf740531e6bf --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx @@ -0,0 +1,66 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, useState } from 'react'; +import { EuiButton, EuiPopoverFooter } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import type { IndexPattern, IndexPatternField } from 'src/plugins/data/common'; + +import { triggerVisualizeActions, VisualizeInformation } from './lib/visualize_trigger_utils'; +import type { FieldDetails } from './types'; +import { getVisualizeInformation } from './lib/visualize_trigger_utils'; + +interface Props { + field: IndexPatternField; + indexPattern: IndexPattern; + details: FieldDetails; + multiFields?: IndexPatternField[]; + trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; +} + +export const DiscoverFieldVisualize: React.FC = React.memo( + ({ field, indexPattern, details, trackUiMetric, multiFields }) => { + const [visualizeInfo, setVisualizeInfo] = useState(); + + useEffect(() => { + getVisualizeInformation(field, indexPattern.id, details.columns, multiFields).then( + setVisualizeInfo + ); + }, [details.columns, field, indexPattern, multiFields]); + + if (!visualizeInfo) { + return null; + } + + const handleVisualizeLinkClick = (event: React.MouseEvent) => { + // regular link click. let the uiActions code handle the navigation and show popup if needed + event.preventDefault(); + trackUiMetric?.(METRIC_TYPE.CLICK, 'visualize_link_click'); + triggerVisualizeActions(visualizeInfo.field, indexPattern.id, details.columns); + }; + + return ( + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + ); + } +); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/get_warnings.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/get_warnings.ts deleted file mode 100644 index 60ce5351e2cd3..0000000000000 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/get_warnings.ts +++ /dev/null @@ -1,33 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { IndexPatternField } from '../../../../../../../../data/public'; - -export function getWarnings(field: IndexPatternField) { - let warnings = []; - - if (field.scripted) { - warnings.push( - i18n.translate( - 'discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription', - { - defaultMessage: 'Scripted fields can take a long time to execute.', - } - ) - ); - } - - if (warnings.length > 1) { - warnings = warnings.map(function (warning, i) { - return (i > 0 ? '\n' : '') + (i + 1) + ' - ' + warning; - }); - } - - return warnings; -} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.test.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.test.ts new file mode 100644 index 0000000000000..0a61bf1ea6029 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.test.ts @@ -0,0 +1,116 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndexPatternField } from 'src/plugins/data/common'; +import type { Action } from 'src/plugins/ui_actions/public'; +import { getVisualizeInformation } from './visualize_trigger_utils'; + +const field = { + name: 'fieldName', + type: 'string', + esTypes: ['text'], + count: 1, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + visualizable: true, +} as IndexPatternField; + +const mockGetActions = jest.fn>>, [string, { fieldName: string }]>( + () => Promise.resolve([]) +); + +jest.mock('../../../../../../kibana_services', () => ({ + getUiActions: () => ({ + getTriggerCompatibleActions: mockGetActions, + }), +})); + +const action: Action = { + id: 'action', + type: 'VISUALIZE_FIELD', + getIconType: () => undefined, + getDisplayName: () => 'Action', + isCompatible: () => Promise.resolve(true), + execute: () => Promise.resolve(), +}; + +describe('visualize_trigger_utils', () => { + afterEach(() => { + mockGetActions.mockReset(); + }); + + describe('getVisualizeInformation', () => { + it('should return for a visualizeable field with an action', async () => { + mockGetActions.mockResolvedValue([action]); + const information = await getVisualizeInformation(field, '1', [], undefined); + expect(information).not.toBeUndefined(); + expect(information?.field).toHaveProperty('name', 'fieldName'); + expect(information?.href).toBeUndefined(); + }); + + it('should return field and href from the action', async () => { + mockGetActions.mockResolvedValue([{ ...action, getHref: () => Promise.resolve('hreflink') }]); + const information = await getVisualizeInformation(field, '1', [], undefined); + expect(information).not.toBeUndefined(); + expect(information?.field).toHaveProperty('name', 'fieldName'); + expect(information).toHaveProperty('href', 'hreflink'); + }); + + it('should return undefined if no field has a compatible action', async () => { + mockGetActions.mockResolvedValue([]); + const information = await getVisualizeInformation( + { ...field, name: 'rootField' } as IndexPatternField, + '1', + [], + [ + { ...field, name: 'multi1' }, + { ...field, name: 'multi2' }, + ] as IndexPatternField[] + ); + expect(information).toBeUndefined(); + }); + + it('should return information for the root field, when multi fields and root are having actions', async () => { + mockGetActions.mockResolvedValue([action]); + const information = await getVisualizeInformation( + { ...field, name: 'rootField' } as IndexPatternField, + '1', + [], + [ + { ...field, name: 'multi1' }, + { ...field, name: 'multi2' }, + ] as IndexPatternField[] + ); + expect(information).not.toBeUndefined(); + expect(information?.field).toHaveProperty('name', 'rootField'); + }); + + it('should return information for first multi field that has a compatible action', async () => { + mockGetActions.mockImplementation(async (_, { fieldName }) => { + if (fieldName === 'multi2' || fieldName === 'multi3') { + return [action]; + } + return []; + }); + const information = await getVisualizeInformation( + { ...field, name: 'rootField' } as IndexPatternField, + '1', + [], + [ + { ...field, name: 'multi1' }, + { ...field, name: 'multi2' }, + { ...field, name: 'multi3' }, + ] as IndexPatternField[] + ); + expect(information).not.toBeUndefined(); + expect(information?.field).toHaveProperty('name', 'multi2'); + }); + }); +}); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.ts b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.ts index 2fabaa0ddd100..f00b430e5acef 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.ts +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/lib/visualize_trigger_utils.ts @@ -41,30 +41,6 @@ async function getCompatibleActions( return compatibleActions; } -export async function getVisualizeHref( - field: IndexPatternField, - indexPatternId: string | undefined, - contextualFields: string[] -) { - if (!indexPatternId) return undefined; - const triggerOptions = { - indexPatternId, - fieldName: field.name, - contextualFields, - trigger: getTrigger(field.type), - }; - const compatibleActions = await getCompatibleActions( - field.name, - indexPatternId, - contextualFields, - getTriggerConstant(field.type) - ); - // enable the link only if only one action is registered - return compatibleActions.length === 1 - ? compatibleActions[0].getHref?.(triggerOptions) - : undefined; -} - export function triggerVisualizeActions( field: IndexPatternField, indexPatternId: string | undefined, @@ -80,21 +56,55 @@ export function triggerVisualizeActions( getUiActions().getTrigger(trigger).exec(triggerOptions); } -export async function isFieldVisualizable( +export interface VisualizeInformation { + field: IndexPatternField; + href?: string; +} + +/** + * Returns the field name and potentially href of the field or the first multi-field + * that has a compatible visualize uiAction. + */ +export async function getVisualizeInformation( field: IndexPatternField, indexPatternId: string | undefined, - contextualFields: string[] -) { + contextualFields: string[], + multiFields: IndexPatternField[] = [] +): Promise { if (field.name === '_id' || !indexPatternId) { - // for first condition you'd get a 'Fielddata access on the _id field is disallowed' error on ES side. - return false; + // _id fields are not visualizeable in ES + return undefined; } - const trigger = getTriggerConstant(field.type); - const compatibleActions = await getCompatibleActions( - field.name, - indexPatternId, - contextualFields, - trigger - ); - return compatibleActions.length > 0 && field.visualizable; + + for (const f of [field, ...multiFields]) { + if (!f.visualizable) { + continue; + } + // Retrieve compatible actions for the specific field + const actions = await getCompatibleActions( + f.name, + indexPatternId, + contextualFields, + getTriggerConstant(f.type) + ); + + // if the field has compatible actions use this field for visualizing + if (actions.length > 0) { + const triggerOptions = { + indexPatternId, + fieldName: f.name, + contextualFields, + trigger: getTrigger(f.type), + }; + + return { + field: f, + // We use the href of the first action always. Multiple actions will only work + // via the modal shown by triggerVisualizeActions that should be called via onClick. + href: await actions[0].getHref?.(triggerOptions), + }; + } + } + + return undefined; } diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 25d080dbfd546..80171e1ad2fab 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -69,6 +69,7 @@ export { EmbeddablePackageState, EmbeddableRenderer, EmbeddableRendererProps, + useEmbeddableFactory, } from './lib'; export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service'; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx index 457852c48ed77..b919672ad01e3 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.test.tsx @@ -9,14 +9,39 @@ import React from 'react'; import { waitFor } from '@testing-library/dom'; import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; import { HelloWorldEmbeddable, HelloWorldEmbeddableFactoryDefinition, HELLO_WORLD_EMBEDDABLE, } from '../../tests/fixtures'; -import { EmbeddableRenderer } from './embeddable_renderer'; +import { EmbeddableRenderer, useEmbeddableFactory } from './embeddable_renderer'; import { embeddablePluginMock } from '../../mocks'; +describe('useEmbeddableFactory', () => { + it('should update upstream value changes', async () => { + const { setup, doStart } = embeddablePluginMock.createInstance(); + const getFactory = setup.registerEmbeddableFactory( + HELLO_WORLD_EMBEDDABLE, + new HelloWorldEmbeddableFactoryDefinition() + ); + doStart(); + + const { result, waitForNextUpdate } = renderHook(() => + useEmbeddableFactory({ factory: getFactory(), input: { id: 'hello' } }) + ); + + const [, loading] = result.current; + + expect(loading).toBe(true); + + await waitForNextUpdate(); + + const [embeddable] = result.current; + expect(embeddable).toBeDefined(); + }); +}); + describe('', () => { test('Render embeddable', () => { const embeddable = new HelloWorldEmbeddable({ id: 'hello' }); diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx index 153564187d4b5..433b21e92cce5 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_renderer.tsx @@ -28,12 +28,6 @@ interface EmbeddableRendererPropsWithEmbeddable { embeddable: IEmbeddable; } -function isWithEmbeddable( - props: EmbeddableRendererProps -): props is EmbeddableRendererPropsWithEmbeddable { - return 'embeddable' in props; -} - interface EmbeddableRendererWithFactory { input: I; onInputUpdated?: (newInput: I) => void; @@ -46,6 +40,72 @@ function isWithFactory( return 'factory' in props; } +export function useEmbeddableFactory({ + input, + factory, + onInputUpdated, +}: EmbeddableRendererWithFactory) { + const [embeddable, setEmbeddable] = useState | ErrorEmbeddable | undefined>( + undefined + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const latestInput = React.useRef(input); + useEffect(() => { + latestInput.current = input; + }, [input]); + + useEffect(() => { + let canceled = false; + + // keeping track of embeddables created by this component to be able to destroy them + let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined; + setEmbeddable(undefined); + setLoading(true); + factory + .create(latestInput.current!) + .then((createdEmbeddable) => { + if (canceled) { + if (createdEmbeddable) { + createdEmbeddable.destroy(); + } + } else { + createdEmbeddableRef = createdEmbeddable; + setEmbeddable(createdEmbeddable); + } + }) + .catch((err) => { + if (canceled) return; + setError(err?.message); + }) + .finally(() => { + if (canceled) return; + setLoading(false); + }); + + return () => { + canceled = true; + if (createdEmbeddableRef) { + createdEmbeddableRef.destroy(); + } + }; + }, [factory]); + + useEffect(() => { + if (!embeddable) return; + if (isErrorEmbeddable(embeddable)) return; + if (!onInputUpdated) return; + const sub = embeddable.getInput$().subscribe((newInput) => { + onInputUpdated(newInput); + }); + return () => { + sub.unsubscribe(); + }; + }, [embeddable, onInputUpdated]); + + return [embeddable, loading, error] as const; +} + /** * Helper react component to render an embeddable * Can be used if you have an embeddable object or an embeddable factory @@ -82,72 +142,22 @@ function isWithFactory( export const EmbeddableRenderer = ( props: EmbeddableRendererProps ) => { - const { input, onInputUpdated } = props; - const [embeddable, setEmbeddable] = useState | ErrorEmbeddable | undefined>( - isWithEmbeddable(props) ? props.embeddable : undefined - ); - const [loading, setLoading] = useState(!isWithEmbeddable(props)); - const [error, setError] = useState(); - const latestInput = React.useRef(props.input); - useEffect(() => { - latestInput.current = input; - }, [input]); - - const factoryFromProps = isWithFactory(props) ? props.factory : undefined; - const embeddableFromProps = isWithEmbeddable(props) ? props.embeddable : undefined; - useEffect(() => { - let canceled = false; - if (embeddableFromProps) { - setEmbeddable(embeddableFromProps); - return; - } - - // keeping track of embeddables created by this component to be able to destroy them - let createdEmbeddableRef: IEmbeddable | ErrorEmbeddable | undefined; - if (factoryFromProps) { - setEmbeddable(undefined); - setLoading(true); - factoryFromProps - .create(latestInput.current!) - .then((createdEmbeddable) => { - if (canceled) { - if (createdEmbeddable) { - createdEmbeddable.destroy(); - } - } else { - createdEmbeddableRef = createdEmbeddable; - setEmbeddable(createdEmbeddable); - } - }) - .catch((err) => { - if (canceled) return; - setError(err?.message); - }) - .finally(() => { - if (canceled) return; - setLoading(false); - }); - } - - return () => { - canceled = true; - if (createdEmbeddableRef) { - createdEmbeddableRef.destroy(); - } - }; - }, [factoryFromProps, embeddableFromProps]); - - useEffect(() => { - if (!embeddable) return; - if (isErrorEmbeddable(embeddable)) return; - if (!onInputUpdated) return; - const sub = embeddable.getInput$().subscribe((newInput) => { - onInputUpdated(newInput); - }); - return () => { - sub.unsubscribe(); - }; - }, [embeddable, onInputUpdated]); + if (isWithFactory(props)) { + return ; + } + return ; +}; +// +const EmbeddableByFactory = ({ + factory, + input, + onInputUpdated, +}: EmbeddableRendererWithFactory) => { + const [embeddable, loading, error] = useEmbeddableFactory({ + factory, + input, + onInputUpdated, + }); return ; }; diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 71dfd73e534e7..eede745f31794 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -16,4 +16,8 @@ export { ErrorEmbeddable, isErrorEmbeddable } from './error_embeddable'; export { withEmbeddableSubscription } from './with_subscription'; export { EmbeddableRoot } from './embeddable_root'; export * from '../../../common/lib/saved_object_embeddable'; -export { EmbeddableRenderer, EmbeddableRendererProps } from './embeddable_renderer'; +export { + EmbeddableRenderer, + EmbeddableRendererProps, + useEmbeddableFactory, +} from './embeddable_renderer'; diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 22001608f63ab..1b3e0388e9bb0 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -542,3 +542,40 @@ test('Check when hide header option is true', async () => { const title = findTestSubject(component, `embeddablePanelHeading-HelloAryaStark`); expect(title.length).toBe(0); }); + +test('Should work in minimal way rendering only the inspector action', async () => { + const inspector = inspectorPluginMock.createStartContract(); + inspector.isAvailable = jest.fn(() => true); + + const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, { + getEmbeddableFactory, + } as any); + + const embeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Arya', + lastName: 'Stark', + }); + + const component = mount( + + Promise.resolve([])} + inspector={inspector} + hideHeader={false} + /> + + ); + + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1); + await nextTick(); + component.update(); + expect(findTestSubject(component, `embeddablePanelAction-openInspector`).length).toBe(1); + const action = findTestSubject(component, `embeddablePanelAction-ACTION_CUSTOMIZE_PANEL`); + expect(action.length).toBe(0); +}); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 8cf2de8c80743..b66950c170d69 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -54,16 +54,20 @@ const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; - getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; - getAllEmbeddableFactories: EmbeddableStart['getEmbeddableFactories']; - overlays: CoreStart['overlays']; - notifications: CoreStart['notifications']; - application: CoreStart['application']; - inspector: InspectorStartContract; - SavedObjectFinder: React.ComponentType; + getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory']; + getAllEmbeddableFactories?: EmbeddableStart['getEmbeddableFactories']; + overlays?: CoreStart['overlays']; + notifications?: CoreStart['notifications']; + application?: CoreStart['application']; + inspector?: InspectorStartContract; + SavedObjectFinder?: React.ComponentType; stateTransfer?: EmbeddableStateTransfer; hideHeader?: boolean; + actionPredicate?: (actionId: string) => boolean; reportUiCounter?: UsageCollectionStart['reportUiCounter']; + showShadow?: boolean; + showBadges?: boolean; + showNotifications?: boolean; } interface State { @@ -80,7 +84,11 @@ interface State { errorEmbeddable?: ErrorEmbeddable; } -interface PanelUniversalActions { +interface InspectorPanelAction { + inspectPanel: InspectPanelAction; +} + +interface BasePanelActions { customizePanelTitle: CustomizePanelTitleAction; addPanel: AddPanelAction; inspectPanel: InspectPanelAction; @@ -88,6 +96,15 @@ interface PanelUniversalActions { editPanel: EditPanelAction; } +const emptyObject = {}; +type EmptyObject = typeof emptyObject; + +type PanelUniversalActions = + | BasePanelActions + | InspectorPanelAction + | (BasePanelActions & InspectorPanelAction) + | EmptyObject; + export class EmbeddablePanel extends React.Component { private embeddableRoot: React.RefObject; private parentSubscription?: Subscription; @@ -117,10 +134,15 @@ export class EmbeddablePanel extends React.Component { } private async refreshBadges() { + if (!this.mounted) { + return; + } + if (this.props.showBadges === false) { + return; + } let badges = await this.props.getActions(PANEL_BADGE_TRIGGER, { embeddable: this.props.embeddable, }); - if (!this.mounted) return; const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { @@ -135,10 +157,15 @@ export class EmbeddablePanel extends React.Component { } private async refreshNotifications() { + if (!this.mounted) { + return; + } + if (this.props.showNotifications === false) { + return; + } let notifications = await this.props.getActions(PANEL_NOTIFICATION_TRIGGER, { embeddable: this.props.embeddable, }); - if (!this.mounted) return; const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { @@ -229,13 +256,18 @@ export class EmbeddablePanel extends React.Component { paddingSize="none" role="figure" aria-labelledby={headerId} + hasShadow={this.props.showShadow} > {!this.props.hideHeader && ( { }; private getUniversalActions = (): PanelUniversalActions => { + let actions = {}; + if (this.props.inspector) { + actions = { + inspectPanel: new InspectPanelAction(this.props.inspector), + }; + } + if ( + !this.props.getEmbeddableFactory || + !this.props.getAllEmbeddableFactories || + !this.props.overlays || + !this.props.notifications || + !this.props.SavedObjectFinder || + !this.props.application + ) { + return actions; + } + const createGetUserData = (overlays: OverlayStart) => async function getUserData(context: { embeddable: IEmbeddable }) { return new Promise<{ title: string | undefined; hideTitle?: boolean }>((resolve) => { @@ -308,6 +357,7 @@ export class EmbeddablePanel extends React.Component { // Universal actions are exposed on the context menu for every embeddable, they bypass the trigger // registry. return { + ...actions, customizePanelTitle: new CustomizePanelTitleAction(createGetUserData(this.props.overlays)), addPanel: new AddPanelAction( this.props.getEmbeddableFactory, @@ -317,7 +367,6 @@ export class EmbeddablePanel extends React.Component { this.props.SavedObjectFinder, this.props.reportUiCounter ), - inspectPanel: new InspectPanelAction(this.props.inspector), removePanel: new RemovePanelAction(), editPanel: new EditPanelAction( this.props.getEmbeddableFactory, @@ -338,9 +387,13 @@ export class EmbeddablePanel extends React.Component { regularActions = regularActions.filter(removeDisabledActions); } - const sortedActions = [...regularActions, ...Object.values(this.state.universalActions)].sort( - sortByOrderField - ); + let sortedActions = regularActions + .concat(Object.values(this.state.universalActions || {}) as Array>) + .sort(sortByOrderField); + + if (this.props.actionPredicate) { + sortedActions = sortedActions.filter(({ id }) => this.props.actionPredicate!(id)); + } return await buildContextMenuForActions({ actions: sortedActions.map((action) => ({ diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 403aa3e3f4c9f..742a2d1909941 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -36,7 +36,7 @@ export interface PanelHeaderProps { embeddable: IEmbeddable; headerId?: string; showPlaceholderTitle?: boolean; - customizeTitle: CustomizePanelTitleAction; + customizeTitle?: CustomizePanelTitleAction; } function renderBadges(badges: Array>, embeddable: IEmbeddable) { @@ -177,7 +177,7 @@ export function PanelHeader({ > {title || placeholderTitle} - ) : ( + ) : customizeTitle ? ( {title || placeholderTitle} - ); + ) : null; } return description ? ( ({ input, factory, onInputUpdated, }: EmbeddableRendererWithFactory): readonly [ErrorEmbeddable | IEmbeddable | undefined, boolean, string | undefined]; + // Warning: (ae-missing-release-tag) "VALUE_CLICK_TRIGGER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap index b949fa7995d30..153438b34eb47 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap @@ -1,819 +1,707 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`home change home route should render a link to change the default route in advanced settings if advanced settings is enabled 1`] = ` -
- + />, + "rightSideItems": Array [], } - /> -
+ - - - - - - - - -
-
+ + + + + + + +
+
+ +